Skip to content

fix twisted websocket close handshake - #42

Merged
alexrashed merged 1 commit into
mainfrom
fix-ws-connection-close
Aug 31, 2026
Merged

fix twisted websocket close handshake#42
alexrashed merged 1 commit into
mainfrom
fix-ws-connection-close

Conversation

@alexrashed

Copy link
Copy Markdown
Member

Motivation

The twisted serving layer never completes the WebSocket closing handshake, in either direction:

  • When a client sends a close frame, WebSocketChannel.dataReceived only finishes the twisted request — no close frame is echoed back, and the TCP connection is left open.
  • When the server closes (wsClose), the close frame is sent, but the TCP connection is again never terminated.
  • On top of that, Request.finish() believes no response was ever started (the 101 upgrade is written raw to the transport, bypassing twisted's request), so it writes its default HTTP response into the upgraded websocket stream. The first byte — the H of HTTP/1.1 — decodes as a fragmented CLOSE control frame, so clients fail the connection with 1002 "Invalid attempt to fragment control frame".

Real-world impact: every well-behaved client burns its full close timeout waiting for a close reply that never comes (~10s with the websockets default). In LocalStack's integration test job this surfaced as a persistent failure on slow runners, where the timeout expiry additionally races the reader thread inside websockets' sync client and dies on assert self.protocol.state is CLOSED. Observed on the wire before this fix: client sends CLOSE 1000 → server answers with the mis-decoded 1002 garbage frame (or nothing at all) and the TCP connection stays open indefinitely.

Changes

WebSocketChannel now completes the closing handshake per RFC 6455 section 7:

  • An incoming CloseConnection event is answered with event.response() (wsproto's close echo) before the channel shuts down.
  • close() marks the request as written (startedWriting = 1) before calling Request.finish() when the connection was upgraded, so twisted emits no stray HTTP response — and then terminates the TCP connection via transport.loseConnection() (twisted flushes pending writes first, so the close frame still goes out).
  • wsReject no longer attempts to write a response on an already-finished request.

After the fix, the wire shows a textbook close: CLOSE 1000 in → CLOSE 1000 echoed → TCP FIN.

Testing

  • Two new regression tests in tests/websocket/test_websockets.py, run against both the twisted and asgi backends:

    • test_close_handshake_client_initiated — server must echo the close frame and terminate TCP;
    • test_close_handshake_server_initiated — client must receive a proper close frame followed by TCP termination.

    Both fail on the twisted backend without the fix; the asgi backend already conformed and passes unchanged.

  • Full test suite passes (168 passed).

  • Verified end-to-end against LocalStack with this rolo checkout overlaid: the previously failing test suite passes, and test execution time for affected tests dropped since clients no longer wait out their close timeout.

The twisted WebSocketChannel never completed the closing handshake:
a client-initiated close was not answered with a close frame, the TCP
connection was never terminated, and Request.finish() wrote its
never-started HTTP response into the upgraded websocket stream, which
clients decoded as a malformed (fragmented) close frame. Clients ended
up burning their close timeout and, depending on thread timing, failing
inside their own close logic.

Complete the handshake per RFC 6455 section 7: echo the close frame,
mark the request as written before finishing so twisted emits nothing,
and terminate the TCP connection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexrashed
alexrashed requested a review from bentsku as a code owner August 31, 2026 08:22

@bentsku bentsku left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, thanks a lot for the fix, I was also curious in the CI failures but didn't get to look at it yet, so thanks a lot!

This repo has the tag based release workflow so it should be easy to release 0.8.4 👍 let me know if you want me to do it

@alexrashed
alexrashed merged commit 65dd99f into main Aug 31, 2026
5 checks passed
@alexrashed
alexrashed deleted the fix-ws-connection-close branch August 31, 2026 09:02
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