Skip to content

fix(ws): tolerate a client that disconnects mid-broadcast - #3181

Merged
vpetersson merged 5 commits into
masterfrom
fix/anthias-1k-ws-send-after-close
Jul 10, 2026
Merged

fix(ws): tolerate a client that disconnects mid-broadcast#3181
vpetersson merged 5 commits into
masterfrom
fix/anthias-1k-ws-send-after-close

Conversation

@vpetersson

Copy link
Copy Markdown
Contributor

What

AssetConsumer.asset_update did an unguarded await self.send(). A browser can disconnect in the window between the group_send dispatch and this send, so the ASGI server has already emitted websocket.close and channels raises RuntimeError("Unexpected ASGI message 'websocket.send', after sending 'websocket.close'").

Why

A benign disconnect-vs-broadcast race that surfaces as an unhandled error in Sentry (ANTHIAS-1K, seen on pi5). The WS frame is only a best-effort "something changed" nudge — the client's 5s poll keeps the table consistent — and group_discard runs in disconnect(), so the stale channel is already on its way out.

How

Wrap the send in try/except RuntimeError, log at debug, and drop the nudge. Scoped to RuntimeError so a genuine failure elsewhere still propagates.

Tests

New tests/test_consumers.py: the happy path forwards the asset_id frame; a send-after-close RuntimeError is swallowed without propagating.

🤖 Generated with Claude Code

AssetConsumer.asset_update did an unguarded await self.send(). A browser
can disconnect in the window between the group_send dispatch and this
send, so the ASGI server has already emitted 'websocket.close' and
channels raises RuntimeError("Unexpected ASGI message 'websocket.send',
after sending 'websocket.close'") — a disconnect-vs-broadcast race
surfacing as an unhandled error in Sentry (ANTHIAS-1K).

The nudge is best-effort (the client's 5s poll backs it up) and
group_discard runs in disconnect(), so the stale channel is already on
its way out. Swallow the RuntimeError and log at debug rather than let it
reach Sentry.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@vpetersson
vpetersson requested a review from a team as a code owner July 10, 2026 08:38
@vpetersson
vpetersson requested a review from Copilot July 10, 2026 08:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the WebSocket broadcast path by tolerating a client disconnect that occurs between a group_send dispatch and the consumer’s subsequent send(), preventing a benign race from surfacing as an unhandled runtime error.

Changes:

  • Wrap AssetConsumer.asset_update()’s send() in a try/except RuntimeError and drop the best-effort nudge on send-after-close.
  • Add unit tests covering the happy path and the send-after-close RuntimeError swallow behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/anthias_server/app/consumers.py Swallow RuntimeError from send() during mid-broadcast disconnects and log at debug.
tests/test_consumers.py Adds tests for forwarding asset_id and for suppressing send-after-close errors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/anthias_server/app/consumers.py
Address Copilot review: logging the swallowed RuntimeError without
detail made the expected send-after-close race indistinguishable from an
unexpected RuntimeError out of send(). Log the asset_id and exc_info at
debug — same swallow behavior, but diagnostics are preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

Comment thread src/anthias_server/app/consumers.py
Comment thread tests/test_consumers.py
Comment thread tests/test_consumers.py Outdated
Address Copilot re-review: the broad `except RuntimeError` swallowed
every RuntimeError from send(), which contradicts the intent (tolerate
only the disconnect race) and could hide genuine failures. Match the
channels "Unexpected ASGI message 'websocket.send'" message and re-raise
anything else. Add a regression test asserting an unrelated RuntimeError
still propagates.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/anthias_server/app/consumers.py Outdated
Address Copilot re-review: the substring check matched any RuntimeError
mentioning "Unexpected ASGI message 'websocket.send'", which could hide
an unrelated ASGI state bug. Require the close/completed clause too
('websocket.close' or 'response already completed') so only the genuine
send-after-close race is swallowed. Add a test that a websocket.send
RuntimeError without the close clause still propagates.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

The direct `consumer.send = AsyncMock()` assignments tripped mypy's
method-assign check (run-mypy CI failure). Patch the instance attribute
with mock.patch.object instead — same behaviour, restores cleanly, and
no assignment to a method-typed attribute.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@vpetersson
vpetersson merged commit bd78617 into master Jul 10, 2026
10 checks passed
@vpetersson
vpetersson deleted the fix/anthias-1k-ws-send-after-close branch July 10, 2026 09:48
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.

2 participants