Summary
WebSocketTransport.dispose() cancels the read loop but schedules the cleanup as a
detached task rather than awaiting it. If the event loop closes before that task runs, the
cancelled Connection.recv coroutine is never closed, and GC finalizing it later raises
RuntimeError: Event loop is closed.
Benign in an application, where the loop outlives close(). It is visible as persistent
noise in the test suite.
Mechanism
async def dispose(self):
self.is_disposed = True
# Cancel tasks but don't await them yet to avoid deadlock
tasks_to_await = []
if self.read_loop:
self.read_loop.cancel()
tasks_to_await.append(self.read_loop)
...
# Schedule cleanup of cancelled tasks in the background to avoid blocking dispose()
# This prevents deadlock when dispose() is called from within these tasks
if tasks_to_await:
asyncio.create_task(self._cleanup_tasks(tasks_to_await))
The detachment is deliberate and the comment explains why — dispose() can be called from
inside one of the tasks it cancels, so awaiting them there would deadlock. The consequence
is that cleanup is not ordered with respect to loop shutdown.
ws_read_loop does async for raw in self.websocket, which awaits Connection.recv
internally. When the loop is cancelled but never awaited, that coroutine stays suspended;
GC finalizes it, GeneratorExit propagates into websockets' messages.py, which calls
get_waiter.cancel() → loop.call_soon on a closed loop.
How it presents
PytestUnraisableExceptionWarning: Exception ignored in: <coroutine object Connection.recv>
File ".../websockets/asyncio/messages.py", line 51, in get
await self.get_waiter
GeneratorExit
...
File ".../asyncio/base_events.py", line 515, in _check_closed
raise RuntimeError('Event loop is closed')
RuntimeError: Event loop is closed
It appears on passing runs as well as failing ones, and the test it is attributed to varies
between runs — it is attributed to whichever test's loop was closing at the time, not to a
test that did anything wrong. That makes it actively misleading when reading CI output: it
was initially mistaken for the cause of an unrelated TimeoutError.
Suggested direction
Await the cancelled tasks in dispose() when it is not being called re-entrantly from one
of them, and keep the detached path only for the re-entrant case. Whatever the shape, the
goal is that a normal close() leaves no unfinalized coroutines behind.
🤖 Generated with Claude Code
┆Issue is synchronized with this Jira Task by Unito
Summary
WebSocketTransport.dispose()cancels the read loop but schedules the cleanup as adetached task rather than awaiting it. If the event loop closes before that task runs, the
cancelled
Connection.recvcoroutine is never closed, and GC finalizing it later raisesRuntimeError: Event loop is closed.Benign in an application, where the loop outlives
close(). It is visible as persistentnoise in the test suite.
Mechanism
The detachment is deliberate and the comment explains why —
dispose()can be called frominside one of the tasks it cancels, so awaiting them there would deadlock. The consequence
is that cleanup is not ordered with respect to loop shutdown.
ws_read_loopdoesasync for raw in self.websocket, which awaitsConnection.recvinternally. When the loop is cancelled but never awaited, that coroutine stays suspended;
GC finalizes it,
GeneratorExitpropagates into websockets'messages.py, which callsget_waiter.cancel()→loop.call_soonon a closed loop.How it presents
It appears on passing runs as well as failing ones, and the test it is attributed to varies
between runs — it is attributed to whichever test's loop was closing at the time, not to a
test that did anything wrong. That makes it actively misleading when reading CI output: it
was initially mistaken for the cause of an unrelated
TimeoutError.Suggested direction
Await the cancelled tasks in
dispose()when it is not being called re-entrantly from oneof them, and keep the detached path only for the re-entrant case. Whatever the shape, the
goal is that a normal
close()leaves no unfinalized coroutines behind.🤖 Generated with Claude Code
┆Issue is synchronized with this Jira Task by Unito