Skip to content

Set close_timeout=0 on exec WebSocket (~38x exec speedup)#26

Open
jhgaylor wants to merge 1 commit into
superfly:mainfrom
jhgaylor:fix/close-timeout
Open

Set close_timeout=0 on exec WebSocket (~38x exec speedup)#26
jhgaylor wants to merge 1 commit into
superfly:mainfrom
jhgaylor:fix/close-timeout

Conversation

@jhgaylor

@jhgaylor jhgaylor commented May 1, 2026

Copy link
Copy Markdown

Closes #24.

Summary

websockets.connect() in WSCommand.start is called without an explicit close_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's await 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-ex sends a CLOSE frame via :gun.ws_send on EXIT and immediately calls :gun.close to tear down the TCP — it never waits for the server's reciprocal CLOSE. close_timeout=0 in websockets-lib produces the same shape: the CLOSE frame is sent, but ws.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_timeout per-exec latency speedup
10s (library default) ~5,300ms
0.5s ~650ms
0 ~140ms 38×

This puts sprites-py on parity with sprites-ex on the exec hot path. Cross-SDK bench (n=3, p50):

phase sprites-py sprites-ex
exec_first 128ms 156ms
exec_warm_avg 121ms 188ms

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.

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>
@jhgaylor jhgaylor force-pushed the fix/close-timeout branch from 858250f to da34f83 Compare May 1, 2026 06:47
@jhgaylor jhgaylor changed the title Set close_timeout=0.5 on exec WebSocket (~8x exec speedup) Set close_timeout=0 on exec WebSocket (~38x exec speedup) May 1, 2026
@kylemclaren kylemclaren requested a review from Copilot May 22, 2026 11:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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=0 on websockets.connect() in WSCommand.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.

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.

Every WS exec waits ~5s for a server close handshake

2 participants