Skip to content

Fix twisted websocket close after a client-initiated close - #48

Merged
bentsku merged 1 commit into
mainfrom
fix-websocket-close-after-client-close
Sep 30, 2026
Merged

bentsku merged 1 commit into
mainfrom
fix-websocket-close-after-client-close

Conversation

@bentsku

@bentsku bentsku commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Since #46, WebSocketChannel.dataReceived queues the client's CloseConnection event first, then completes the closing handshake on the reactor thread: it echoes the close frame (wsproto becomes CLOSED) and then finishes the request. The application thread can read the queued event in between, and when it closes the websocket (explicitly, or by leaving with request.accept() as ws:), wsClose sends a CloseConnection while the request is not finished yet. wsSend only returns early on request.finished, so wsproto raises:

wsproto.utilities.LocalProtocolError: Event CloseConnection(code=1000, reason=None) cannot be sent in state ConnectionState.CLOSED.

Before #46 the event was queued after the request was finished, which hid the race. It is timing-dependent: in LocalStack it showed up in CI (Docker) after bumping to rolo 0.8.5, where the endpoint failed right after a client close, the $disconnect route was skipped and the request was logged as WEBSOCKET /dev => 500.

Changes

  • WebSocketChannel.wsSend is a no-op once wsproto is LOCAL_CLOSING or CLOSED, so closing (or sending) after the closing handshake has started does not raise. The surface the client's websocket close code and reason #46 ordering is unchanged: the client's close event, with its code and reason, still comes first.
  • The ASGI adapter does not need a fix: hypercorn ignores app_send once the stream is closed and swallows wsproto's LocalProtocolError.

Testing

  • New unit tests in tests/serving/test_twisted.py drive WebSocketChannel with a request stand-in that stays unfinished, which freezes the channel in the race window:
    • test_websocket_close_after_client_close_before_request_finished: the client's close frame is received and echoed, the app gets WebSocketDisconnectedError(4001, "bye"), then close() twice must not raise nor write anything else.
    • test_websocket_close_twice_before_request_finished: a second close() (and a send()) after a server-initiated close is a no-op.
  • New end-to-end test_server_close_after_client_close (twisted): the reactor's close() is held until the app has closed the websocket explicitly and via the with exit, and the app must complete without error.
  • All three fail without the fix (LocalProtocolError: Event CloseConnection(code=1000, reason=None) cannot be sent in state ConnectionState.CLOSED., resp. LOCAL_CLOSING) and pass with it.
  • Full test suite passes (182 passed), lint clean.

🤖 Generated with Claude Code

Since the client's close event is queued before the reactor echoes it and finishes the request,
the application can close the websocket while wsproto is already CLOSED but the request is not
finished yet, which raised a LocalProtocolError. wsSend is now a no-op once a close was sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bentsku
bentsku marked this pull request as ready for review September 30, 2026 15:49
@bentsku
bentsku merged commit cb3d10b into main Sep 30, 2026
5 checks passed
@bentsku
bentsku deleted the fix-websocket-close-after-client-close branch September 30, 2026 15:49
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.

1 participant