fix twisted websocket close handshake - #42
Merged
Conversation
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>
bentsku
approved these changes
Aug 31, 2026
bentsku
left a comment
Collaborator
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The twisted serving layer never completes the WebSocket closing handshake, in either direction:
WebSocketChannel.dataReceivedonly finishes the twisted request — no close frame is echoed back, and the TCP connection is left open.wsClose), the close frame is sent, but the TCP connection is again never terminated.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 — theHofHTTP/1.1— decodes as a fragmented CLOSE control frame, so clients fail the connection with1002 "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
websocketsdefault). In LocalStack's integration test job this surfaced as a persistent failure on slow runners, where the timeout expiry additionally races the reader thread insidewebsockets' sync client and dies onassert self.protocol.state is CLOSED. Observed on the wire before this fix: client sendsCLOSE 1000→ server answers with the mis-decoded 1002 garbage frame (or nothing at all) and the TCP connection stays open indefinitely.Changes
WebSocketChannelnow completes the closing handshake per RFC 6455 section 7:CloseConnectionevent is answered withevent.response()(wsproto's close echo) before the channel shuts down.close()marks the request as written (startedWriting = 1) before callingRequest.finish()when the connection was upgraded, so twisted emits no stray HTTP response — and then terminates the TCP connection viatransport.loseConnection()(twisted flushes pending writes first, so the close frame still goes out).wsRejectno longer attempts to write a response on an already-finished request.After the fix, the wire shows a textbook close:
CLOSE 1000in →CLOSE 1000echoed → 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
rolocheckout overlaid: the previously failing test suite passes, and test execution time for affected tests dropped since clients no longer wait out their close timeout.