Set close_timeout=0 on exec WebSocket (~38x exec speedup)#26
Open
jhgaylor wants to merge 1 commit into
Open
Conversation
websockets.connect() in WSCommand.start was called without an
explicit close_timeout, inheriting the library default of 10s.
After the server sends the EXIT byte it holds the WebSocket open
for ~5s before initiating its own close handshake; the client's
`await ws.close()` blocks waiting for that reply, adding a fixed
~5s tax to every exec.
The Elixir SDK (sprites-ex, lib/sprites/command.ex) doesn't wait
for the server at all — on EXIT it sends a CLOSE frame via
:gun.ws_send and immediately calls :gun.close to tear down the
TCP connection. close_timeout=0 in the websockets-lib client
mirrors that behavior: the CLOSE frame is sent but ws.close()
returns immediately rather than awaiting the reciprocal frame.
A side-by-side bench (cat /tmp/x against a warm sprite, n=3):
close_timeout=10 ~5.3s per exec (library default)
close_timeout=0.5 ~650ms per exec (8x speedup)
close_timeout=0 ~140ms per exec (38x speedup, on par
with sprites-ex)
Closes superfly#24.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
858250f to
da34f83
Compare
There was a problem hiding this comment.
Pull request overview
This PR addresses a consistent ~5s latency penalty on the WebSocket exec path by explicitly setting close_timeout=0 on the client WebSocket connection, so the client doesn’t wait for the server’s delayed reciprocal close handshake after receiving the EXIT frame.
Changes:
- Set
close_timeout=0onwebsockets.connect()inWSCommand.start()for exec connections. - Add an in-code comment documenting the rationale and linking to the observed behavior/regression (#24).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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.
Closes #24.
Summary
websockets.connect()inWSCommand.startis called without an explicitclose_timeout, so it inherits the library default of 10s. After the server sends the EXIT byte it holds the WebSocket open for ~5s before initiating its own close handshake — the client'sawait ws.close()blocks waiting for that reply, adding a fixed ~5s tax to every exec. Full root-cause analysis and phase-by-phase timings in #24.Fix
Mirror the Elixir SDK's behavior.
sprites-exsends a CLOSE frame via:gun.ws_sendon EXIT and immediately calls:gun.closeto tear down the TCP — it never waits for the server's reciprocal CLOSE.close_timeout=0inwebsockets-lib produces the same shape: the CLOSE frame is sent, butws.close()returns immediately rather than awaiting the server.self.ws = await websockets.connect( url, additional_headers=headers, ping_interval=WS_PING_INTERVAL, ping_timeout=WS_PONG_WAIT, max_size=10 * 1024 * 1024, + close_timeout=0, )Impact
Side-by-side bench against a warm sprite running
cat /tmp/x(n=3, p50):close_timeoutThis puts
sprites-pyon parity withsprites-exon the exec hot path. Cross-SDK bench (n=3, p50):Behavior change
The client no longer waits for the server's reciprocal CLOSE frame. The CLOSE frame is still sent — the server will see a clean close — we just don't block our own coroutine waiting on it. There is no resource leak: the underlying TCP socket is torn down by
websockets-lib's normal cleanup path regardless.