Skip to content

Sending requests is actually blocking #51

@Prunoideae

Description

@Prunoideae

Describe the bug
The websocket limits only one coroutine to await on recv to prevent racing problem. However current pyvts introduces blocking behavior in

pyvts/pyvts/vts.py

Lines 117 to 118 in 3c5fb84

await self.websocket.send(json.dumps(request_msg))
response_msg = await self.websocket.recv()

Because every spawned coroutine will await on the same websocket object and cause error.

To Reproduce
Steps to reproduce the behavior:

  1. Send multiple requests at once using asyncio.gather.
  2. RuntimeError: cannot call recv while another coroutine is already waiting for the next message

Expected behavior
Requests should be fully async no matter how many requests are being sent at once.

Desktop (please complete the following information):

  • OS: Windows
  • Version: 0.3.3

Additional context

I have a draft implementation of a full async WS event handler here (though it's very not following the original implementation of vts):

class vts:
    """
    A fully async implementation of VTube Studio API
    """

    ws: WebSocketClientProtocol
    requests: dict[str, Event]

    def __init__(
        self,
        name: str,
        developer: str,
        icon: str = None,
        auth_token: str = None,
        ip: str = "localhost",
        port: int = 8001,
    ) -> None:
        self.auth_info = {
            "pluginName": name,
            "pluginDeveloper": developer,
            "pluginIcon": icon,
        }
        self.auth_token = auth_token

        self.ip = ip
        self.port = port

        self.ws = None
        self.requests = {}

    async def connect(self) -> None:
        if self.ws is not None:
            raise RuntimeError("Already connected")

        self.ws = await websockets.connect(f"ws://{self.ip}:{self.port}")
        self.recv_loop = asyncio.create_task(self.run_recv_loop())

    async def run_recv_loop(self) -> None:
        """
        Only the recv loop should read from the websocket.

        We identify each incoming response by the requestID.
        """
        async for message in self.ws:
            resp = json.loads(message)
            id = resp["requestID"]
            event = self.requests.pop(id)
            if event is not None:
                setattr(event, "data", resp.get("data", None))
                event.set()  # Notify the request is done

    async def request(
        self,
        command: str,
        payload: dict[str, Any] = None,
        id: str = None,
    ) -> None:
        """
        Send a request to the server. This does not throw if there are
        multiple requests.
        """
        if id is None:
            id = str(uuid4())

        apiPayload = {
            "apiName": "VTubeStudioPublicAPI",
            "apiVersion": "1.0",
            "requestID": id,
            "messageType": command,
        }

        if payload is not None:
            apiPayload["payload"] = payload

        event = Event()  # We make a signal here to wait for the response
        self.requests[id] = event
        await self.ws.send(json.dumps(apiPayload))
        await event.wait()
        return getattr(event, "data", None)

    async def close(self) -> None:
        self.recv_loop.cancel()
        await self.ws.close()

        self.ws = None
        self.requests = {}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions