From 1f8d799cc7edc862bf131a75de392b1093b52f49 Mon Sep 17 00:00:00 2001 From: Alvar Date: Mon, 11 May 2026 14:50:57 +0100 Subject: [PATCH] fix: surface cross-loop AsyncRobotClient misuse with a clear error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsyncRobotClient is implicitly single-loop-bound: its persistent UDP DatagramTransport, RX asyncio.Queue, request/endpoint asyncio.Locks, and shared status asyncio.Event are all bound at first-use to the event loop running at the time. Reusing the same instance from a different loop raises a cryptic ``RuntimeError: is bound to a different event loop`` from deep inside ``_request_ok_raw`` / ``_request``, with a traceback that points at the asyncio queue's internal ``_get_loop()`` and gives no hint as to which loops are mismatched. The trap is easy to fall into when wrapping AsyncRobotClient inside a sync RobotClient (which drives its own private thread-loop) and then ALSO calling AsyncRobotClient methods directly from a different loop — for example, ``loop.create_task(inner.halt())`` from a NiceGUI page handler. The sync wrapper has bound the client to its thread-loop; the page handler's create_task schedules the coroutine on the main loop instead, so the next ``queue.get()`` raises. The UDP HALT packet still goes through (``sendto`` is synchronous), so the controller halts — but the Python side surfaces the confusing error. Track the bound loop on first endpoint creation and check it on every subsequent ``_ensure_endpoint`` call. Mismatches now raise an actionable error that names both loops and points the caller at ``asyncio.run_coroutine_threadsafe(coro, bound_loop)`` as the correct cross-loop dispatch primitive. No behavior change for correct single-loop callers: the new check short-circuits on the same-loop fast path before any lock or queue operation, so the common case is one extra identity comparison. --- parol6/client/async_client.py | 66 ++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/parol6/client/async_client.py b/parol6/client/async_client.py index ec66551..4ee571e 100644 --- a/parol6/client/async_client.py +++ b/parol6/client/async_client.py @@ -267,6 +267,19 @@ def __init__( ) self._ep_lock = asyncio.Lock() + # The event loop that owns the persistent UDP transport, RX queue, + # and async locks. Assigned by ``_ensure_endpoint`` on the first + # call that creates the endpoint. Subsequent calls verify the + # current loop matches and raise a clear error otherwise instead + # of letting ``asyncio.Queue``/``Lock`` surface the cryptic + # "bound to a different event loop" RuntimeError from deep inside + # ``_request_ok_raw``. Most commonly hit when wrapping an + # AsyncRobotClient inside a sync RobotClient (which drives its + # own private thread-loop) and then ALSO calling AsyncRobotClient + # methods directly from a different loop, e.g. + # ``loop.create_task(inner.halt())`` from a NiceGUI page handler. + self._bound_loop: asyncio.AbstractEventLoop | None = None + # Serialize request/response self._req_lock = asyncio.Lock() @@ -354,10 +367,60 @@ def port(self, value: int) -> None: # --------------- Internal helpers --------------- async def _ensure_endpoint(self) -> None: - """Lazily create a persistent asyncio UDP datagram endpoint.""" + """Lazily create a persistent asyncio UDP datagram endpoint. + + AsyncRobotClient is single-loop-bound: once an endpoint is + created on a particular event loop, every async method on this + instance must be invoked from that same loop. The UDP transport, + RX queue, request/endpoint locks, and shared status event are + all asyncio primitives bound to the loop that constructed them, + and reusing them from a different loop raises ``RuntimeError: + is bound to a different event loop`` from deep + inside ``_request_ok_raw``. + + This method enforces the single-loop contract up-front with a + clear error referencing the offending loop. The most common + cause is wrapping an AsyncRobotClient inside a sync RobotClient + (which drives its own private thread-loop) and then ALSO + calling AsyncRobotClient methods directly from a different + loop, e.g. ``loop.create_task(inner.halt())`` from a NiceGUI + page handler — the sync wrapper has already bound the client + to its thread-loop, and the page-handler's create_task + schedules the coroutine on the main loop instead. + + Callers that genuinely need cross-loop access should either + dispatch through ``asyncio.run_coroutine_threadsafe(coro, + bound_loop)`` (preserving the original binding) or route + every call through the sync RobotClient wrapper. + """ if self._closed: raise RuntimeError("AsyncRobotClient is closed") if self._transport is not None: + # Endpoint already created — verify the calling loop matches + # the one that owns it. Falling through silently would let + # asyncio.Queue.get() raise its own less-helpful error. + try: + current_loop = asyncio.get_running_loop() + except RuntimeError: + # No running loop — sync caller hit an async method + # without an event loop in scope. Let the original error + # surface from the await site (better than fabricating + # a different one here). + return + if ( + self._bound_loop is not None + and self._bound_loop is not current_loop + ): + raise RuntimeError( + "AsyncRobotClient endpoint is bound to event loop " + f"{self._bound_loop!r} but called from " + f"{current_loop!r}. AsyncRobotClient is single-loop" + "-bound; reusing one instance across loops (e.g. " + "via a sync RobotClient wrapper) requires " + "dispatching all calls through the original loop, " + "or wrapping cross-loop calls in " + "asyncio.run_coroutine_threadsafe(coro, bound_loop)." + ) return async with self._ep_lock: if self._closed: @@ -365,6 +428,7 @@ async def _ensure_endpoint(self) -> None: if self._transport is not None: return loop = asyncio.get_running_loop() + self._bound_loop = loop self._rx_queue = asyncio.Queue(maxsize=256) transport, protocol = await loop.create_datagram_endpoint( lambda: _UDPClientProtocol(self._rx_queue),