Task was destroyed but it is pending! for WebsocketImplProtocol.async_data_received on abrupt client disconnect
Is there an existing issue for this?
- I have searched the existing issues
Describe the bug
Closing a browser tab (or otherwise abruptly dropping a WebSocket TCP connection) intermittently logs:
Task was destroyed but it is pending!
task: <Task pending name='Task-...' coro=<WebsocketImplProtocol.async_data_received() running at .../sanic/server/websockets/impl.py:817> wait_for=<Future pending cb=[Task.task_wakeup()]>>This does not crash the server, but it pollutes logs on a normal client disconnect path (GraphQL subscriptions / any long-lived WebSocket).
Related but distinct from #2564 / draft #3157, which address WebsocketFrameAssembler.get() cancellation during recv(). This report is specifically about the orphaned async_data_received task created from data_received().
Root cause
In sanic/server/websockets/impl.py:
def data_received(self, data):
self.ws_proto.receive_data(data)
data_to_send = self.ws_proto.data_to_send()
events_to_process = self.ws_proto.events_received()
if len(data_to_send) > 0 or len(events_to_process) > 0:
asyncio.create_task(
self.async_data_received(data_to_send, events_to_process)
)Call path after upgrade:
asyncio/uvloop transport
-> WebSocketProtocol.data_received
-> WebsocketImplProtocol.data_received
-> asyncio.create_task(async_data_received(...)) # no strong refeof_received has the same issue for async_eof_received.
By contrast, keepalive_ping_task and auto_closer_task are stored on the protocol instance. The IO bridge tasks created from sync transport callbacks are not.
Code snippet
No response
Expected Behavior
Abrupt client disconnect should tear down WebSocket protocol tasks cleanly, without Task was destroyed but it is pending! for Sanic-internal websocket IO tasks.
How do you run Sanic?
Sanic CLI
Operating System
Linux
Sanic Version
Sanic 25.12.1; Routing 23.12.0
Additional context
Suggested fix
Track and drain these tasks on the WebsocketImplProtocol instance:
Keep a set of pending IO tasks created from data_received / eof_received. Add a done-callback to discard completed tasks (strong-ref pattern). On connection_lost / force-close paths, cancel pending IO tasks and await them (gather(..., return_exceptions=True)), or otherwise ensure they cannot be GC’d while still pending.
Sketch:
self._io_tasks: set[asyncio.Task] = set()
def _schedule_io(self, coro):
task = asyncio.create_task(coro)
self._io_tasks.add(task)
task.add_done_callback(self._io_tasks.discard)
return taskUse _schedule_io(...) in both data_received and eof_received, and cancel/await _io_tasks during teardown.
Source: sanic-org/sanic