Skip to content

Emit 'exit' immediately on EXIT byte (~38x exec speedup)#16

Open
jhgaylor wants to merge 1 commit into
superfly:mainfrom
ravi-hq:fix/fast-exit-emit
Open

Emit 'exit' immediately on EXIT byte (~38x exec speedup)#16
jhgaylor wants to merge 1 commit into
superfly:mainfrom
ravi-hq:fix/fast-exit-emit

Conversation

@jhgaylor

@jhgaylor jhgaylor commented May 1, 2026

Copy link
Copy Markdown

Summary

In the non-TTY exec path, the server sends a single EXIT byte over the WebSocket and then holds the connection open ~5 seconds before initiating its own close handshake. The current handler calls this.close() on EXIT and only emits the 'exit' event from handleClose — which fires when the WS actually closes — so every exec eats the server's ~5s wait before `execFile`'s promise resolves.

This is the same bug we just fixed in the Python SDK (superfly/sprites-py#26). The Elixir SDK (sprites-ex/lib/sprites/command.ex#L317-L324) does not have it — it sends a CLOSE frame and immediately tears down the TCP via `:gun.close`, never waiting for the server's reciprocal frame.

Fix

Emit `'exit'` synchronously when the EXIT byte arrives, then issue the WS close as fire-and-forget. `handleClose` is already guarded by `if (!this.done)` so it won't double-emit.

 case StreamID.Exit:
   this.exitCode = payload.length > 0 ? payload[0] : 0;
+  if (!this.done) {
+    this.done = true;
+    this.emit('exit', this.exitCode);
+  }
   this.close();
   break;

Impact

Side-by-side bench against a warm sprite running `cat /tmp/x` (n=2, p50):

exec_first exec_warm_avg
before ~6,257ms ~5,463ms
after 75ms 127ms

That's a ~80× speedup on cold and ~43× on warm. The remaining ~75ms is mostly TLS handshake + WS upgrade + actual command execution, which puts `sprites-js` on parity with the other SDKs:

SDK exec_first p50 exec_warm_avg p50
sprites-js (this PR) 75ms 127ms
sprites-py (with #26) 106ms 128ms
sprites-go 183ms 190ms
sprites-ex 177ms 198ms

Behavior change

The `'exit'` event is now emitted before the WS finishes closing. Consumers that explicitly waited for the WS `close` event will see no change — `handleClose` is unchanged and still runs. Anything reading `exitCode`/the `exit` event keeps working but resolves ~5s sooner.

The CLOSE frame is still sent — the server sees a clean close — we just don't block the JS event loop waiting for the server's reciprocal CLOSE.

In the non-TTY exec path, the server sends a single EXIT byte over the
WebSocket and then holds the connection open ~5s before initiating its
own close handshake. The current handler calls `this.close()` on EXIT
and only emits the 'exit' event from `handleClose`, which fires when
the WS actually closes — so every exec eats the server's ~5s wait
before `execFile`'s promise resolves.

The Python SDK had the same bug; the Elixir SDK does not — `sprites-ex`
sends a CLOSE frame and immediately tears down the TCP, never waiting
for the server's reciprocal CLOSE.

Fix: emit 'exit' synchronously when the EXIT byte arrives, then issue
the WS close as fire-and-forget. `handleClose` is guarded by
`if (!this.done)` so it won't double-emit.

In a side-by-side bench (5 cat /tmp/x calls against a warm sprite):

  before: ~5,300ms per exec
  after:  ~140ms per exec   (~38x speedup)

This puts sprites-js on parity with the Python SDK after its
equivalent fix (close_timeout=0, see superfly/sprites-py#26) and with
the Elixir SDK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant