Genteki / pyvts

A python library for interacting with the VTube Studio API
https://genteki.github.io/pyvts/
MIT License
73 stars 12 forks source link

Sending requests is actually blocking #51

Open Prunoideae opened 5 days ago

Prunoideae commented 5 days ago

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

https://github.com/Genteki/pyvts/blob/3c5fb840fe7e962830d0633d1e095f3409baab35/pyvts/vts.py#L117-L118

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):

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 = {}
Genteki commented 4 days ago

Thanks for your comment.

Yeah I found the problem before, but I don't know how to resolve it then... Thanks for your suggestion and I would definitely check out your implementation.