wait for the client's close frame before terminating the twisted websocket connection - #50
Merged
Merged
Conversation
bentsku
added this pull request to stack #51
September 30, 2026 21:42
…ocket connection When the server closed the websocket, it terminated the TCP connection right after sending its close frame. RFC 6455 section 5.5.1 only has the server terminate it once it has both sent and received a close frame. Terminating it right away stops reading the client's frames, and closing a socket with unread data makes the kernel reset the connection, which discards the frames the client has not read yet (RFC 6455 section 1.4). The channel now waits for the client's close frame, and terminates the TCP connection after `closeTimeout` if the client never sends it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bentsku
force-pushed
the
twisted-websocket-close-handshake
branch
from
September 30, 2026 21:45
bed56a1 to
4d74399
Compare
The close timeout started when the close frame was queued, while the messages sent before it could still be buffered on the server. A slow client could run out of time while reading them. The channel now registers as a producer on the request, and only starts the timeout once the transport's send buffer is no longer full, like the `websockets` library, which waits for the write buffer to drain. Also throttle the pinger in test_server_close_while_client_is_sending, which sent pings in a busy loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the client stops reading after the server sent its close frame, the send buffer never drains, so the close timeout never starts and the request never finishes, which also keeps Twisted's HTTP timeouts from applying. The connection and its buffered data were never released. The channel now aborts the TCP connection `closeAbortTimeout` (30 s) after sending its close frame, if it is still open by then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
#42 made the twisted channel terminate the TCP connection as part of the closing handshake, citing RFC 6455 section 7. For a client-initiated close that's right, and this PR doesn't change it: the server echoes the close frame and terminates the TCP connection immediately.
For a server-initiated close (
wsClose), the channel also terminated the TCP connection right after sending its close frame. The RFC ties that to having both sent and received a close frame:Section 1.4 explains why the server has to wait, and it describes the failure this causes:
This shows up when the server closes while the client is still sending (for example pings), and the client hasn't read all the server's messages yet. Twisted stops reading once the connection is terminated, the client's frames stay unread, and the kernel resets the connection. The client then gets
ECONNRESETand loses the messages it hadn't read. #49 movescloseonto the reactor right after the queued sends, which makes this happen every time instead of sometimes.Changes
wsClosesends the close frame, but keeps the TCP connection open and keeps reading. The connection is terminated in either of these cases:dataReceivedalready handles that, and terminates the connection immediately, as section 5.5.1 requires;WebSocketChannel.closeTimeout(5 s) runs out because the client never echoes. Section 7.1.1 allows closing "via any means available when necessary". The pending timeout is cancelled when the connection closes earlier.pauseProducing/resumeProducingwhen the buffer fills and drains. Thewebsocketslibrary does the same, by waiting for its write buffer to drain before starting its close timer.WebSocketChannel.closeAbortTimeout(30 s) bounds the whole close. If the TCP connection is still open that long after the close frame was sent, it's aborted and any buffered data is dropped. Without it, a client that stops reading would keep the send buffer full forever: the close timeout never starts, and since the request isn't finished, Twisted's HTTP timeouts don't apply either. It also covers a graceful close that stalls for the same reason.WebSocketChanneltakes the reactor, so the timeout uses the same reactor as the rest of the resource. Since schedule twisted websocket writes onto the reactor thread #49, all channel operations run on the reactor thread.Testing
test_server_close_while_client_is_sending(twisted only; hypercorn terminates the TCP connection right away as well): the server streams 50k messages and closes while the client keeps pinging. The client must receive every message and then the close frame. It fails every run without this change (Linux: 5 out of 5 failed), and passes with it (0 out of 10 failed on Linux and on macOS).test_close_handshake_server_initiated(twisted and asgi) now echoes the close frame the way a well-behaved client does, and expects the server to terminate TCP after that.test_close_handshake_server_initiated_client_does_not_echo(twisted): a client that never echoes still gets its TCP connection terminated, aftercloseTimeout(lowered to 0.5 s in the test).test_server_close_client_stops_reading(twisted): the server sends 8 MiB and closes, with small socket buffers, while the client doesn't read. The server has to abort the connection aftercloseAbortTimeout(lowered to 0.5 s in the test). Without the abort deadline it stays connected.test_server_close_while_client_is_sendingsleeps 1 ms between pings, instead of sending them in a busy loop.ws/wsssends, and connect/send/close cycles of 0.67 / 1.27 ms. The early-close scenario went from 20 out of 20 connections broken to 0 out of 20.CI failure on the first run
The first CI run failed
test_server_close_while_client_is_sendingonce, on Python 3.13, on a slow runner (81 s for the suite, versus about 21 s locally). The connection closed while the client was still reading the stream. I couldn't reproduce it: there were no failures in about 40 runs at the original commit, including the full suite and runs in CPU-limited Linux containers. The later CI run passed on all versions.A likely cause is the close timeout starting before the send buffer drained, which the timeout change above addresses. The busy-loop pinger may also have been what slowed that runner down. Neither is confirmed, since a test for a slow client passed with and without the change, so I left that test out. The timeout change is hardening, not a proven fix.
The same log also has 200
forceAbortClienttracebacks (AttributeError: 'NoneType' object has no attribute 'shutdown'), one per connection of #49's TLS test. When a websocket closes,Request.finish()makes theHTTPChannelschedule its 15 s force-abort. TheHTTPChannelnever getsconnectionLost, because the websocket channel replaced it as the protocol, so the timers fire later on connections that are already closed. That dates from #42's close handling. It's harmless noise and not part of this PR.🤖 Generated with Claude Code