#3175·sanic

Task was destroyed but it is pending! for WebsocketImplProtocol.async_data_received on abrupt client disconnect

Author: matejscerbaCreated Jul 22, 2026Updated Sep 12, 2026
Labelsbug

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 ref

eof_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 task

Use _schedule_io(...) in both data_received and eof_received, and cancel/await _io_tasks during teardown.