Skip to content

Fix orphaned WebSocket IO tasks logging 'Task was destroyed but it is pending!' on abrupt disconnect - #3184

Open
pranjalm37 wants to merge 1 commit into
sanic-org:mainfrom
pranjalm37:fix/websocket-task-destroyed-3175
Open

Fix orphaned WebSocket IO tasks logging 'Task was destroyed but it is pending!' on abrupt disconnect#3184
pranjalm37 wants to merge 1 commit into
sanic-org:mainfrom
pranjalm37:fix/websocket-task-destroyed-3175

Conversation

@pranjalm37

Copy link
Copy Markdown

Description

Closes #3175.

data_received() and eof_received() in WebsocketImplProtocol create fire-and-forget tasks (async_data_received/async_eof_received) via a bare asyncio.create_task(...), with nothing keeping a reference to them:

def data_received(self, data):
    ...
    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)
        )

On an abrupt client disconnect (e.g. closing a browser tab mid-WebSocket-session), one of these tasks can still be pending with nothing else referencing it. The event loop's task-GC then destroys it and logs:

Task was destroyed but it is pending!
task: <Task pending name='Task-...' coro=<WebsocketImplProtocol.async_data_received() ...>>

It doesn't crash anything, but it's a normal-disconnect-path log pollution issue, especially noisy for long-lived WebSocket connections (e.g. GraphQL subscriptions).

keepalive_ping_task and auto_closer_task already avoid this exact problem by being stored as attributes on the protocol instance. This PR applies the same "keep a strong reference" pattern to the IO tasks created from data_received/eof_received.

Changes

  • Added self.io_tasks: set[asyncio.Task] to WebsocketImplProtocol.
  • Added a _schedule_io() helper that creates the task, adds it to io_tasks, and registers a done-callback to discard it once it completes — the standard "keep a strong reference" pattern for fire-and-forget tasks (see the asyncio docs' create_task example).
  • data_received/eof_received now go through _schedule_io() instead of a bare asyncio.create_task(...).
  • connection_lost() now cancels any IO tasks still pending, so they can't be silently GC'd mid-flight, and don't run forever past the point the connection is gone.

Testing

  • Added test_connection_lost_cancels_pending_io_tasks in tests/test_websockets.py, which schedules a never-completing coroutine via _schedule_io, calls connection_lost, and asserts the task is actually cancelled and pruned from io_tasks — verified this fails against the pre-fix code (_schedule_io doesn't exist yet).
  • pytest tests/ -k "websocket or ws_" — 71 passed, no new warnings.
  • ruff check / ruff format --check — clean.
  • mypy sanic/server/websockets/impl.py — no new errors (the file itself reports none; the 3 project-wide mypy errors are pre-existing and unrelated, in sanic/pages/error.py and sanic/mixins/startup.py).

Checklist

  • I have read the CONTRIBUTING document
  • I have updated tests where applicable
  • I have added myself to CONTRIBUTORS.txt (skipped — first-time contributor, will happily add if desired)

…eak them

data_received/eof_received create fire-and-forget asyncio.Task objects
(async_data_received/async_eof_received) with no reference kept anywhere.
On an abrupt client disconnect, these can still be pending when nothing
else holds them, so the event loop's GC destroys them and logs "Task was
destroyed but it is pending!" -- harmless but log-polluting on every normal
disconnect during in-flight data/eof handling.

keepalive_ping_task and auto_closer_task already avoid this by being
stored on the protocol instance; apply the same pattern to these IO tasks
via a small _schedule_io helper that tracks them in self.io_tasks (pruned
via a done-callback) and cancels any still-pending ones in connection_lost.

Fixes sanic-org#3175.
@pranjalm37
pranjalm37 requested a review from a team as a code owner August 11, 2026 04:16
@pranjalm37

Copy link
Copy Markdown
Author

The 3 test_asgi.py failures in "Check coverage" are unrelated to this PR's change (`sanic/server/websockets/impl.py`, exercised only by the WebSocket tests, which pass):

```
ImportError: cannot import name 'ServerProtocol' from 'websockets.server'
.tox/coverage/lib/python3.12/site-packages/uvicorn/protocols/websockets/websockets_sansio_impl.py:21
```

This is a dependency-version mismatch between the freshly-resolved `uvicorn` (a test dependency here) and `websockets` packages in this run's environment, not anything in this diff. Confirmed by checking main's own CI on the exact commit this branch is based on (`5ffc7b37`) — it passed cleanly two days ago with the same test file untouched. The `type-checking` job failures are also pre-existing and unrelated: 2 mypy errors in `sanic/pages/error.py`, present on `main` before this PR.

The websocket-focused test run (`pytest tests/ -k "websocket or ws_"`) passes clean, including the new regression test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant