Skip to content

Give the upgrade socket an error listener before it can be reset - #40

Merged
sweetcornna merged 1 commit into
mainfrom
fix/upgrade-socket-error-listener
Jul 27, 2026
Merged

Give the upgrade socket an error listener before it can be reset#40
sweetcornna merged 1 commit into
mainfrom
fix/upgrade-socket-error-listener

Conversation

@sweetcornna

Copy link
Copy Markdown
Collaborator

What & why

Closes #39.

handleUpgrade received a bare socket from the http server (which drops its own error handler before emitting 'upgrade') and then awaited resolveTarget — seconds, on a cold wake — before attaching any listener. A TCP reset in that window is a listener-less 'error', which throws; the server installs no uncaughtException handler, so the daemon exits and every in-flight exec/restore/archive dies with it.

The listener moves to the first statement, before any await. The 'error' twin further down is deleted rather than kept alongside it: the new listener destroys the socket, and a destroyed socket always reaches 'close', which already runs the same settle() + upstream.destroy(). Net one listener, not two.

Scope, stated plainly because it first looked worse than it is: only a reset triggers it (a graceful FIN does not), and an HTTP reverse proxy absorbs a client's reset rather than passing it upstream — measured with nginx. With the daemon bound to loopback this is a local robustness defect, not a remotely reachable one, and it hits none of SECURITY.md's three criteria. The realistic trigger is a local client aborting during a wake.

Verifying against a real daemon turned up the same bug a second time, in FakeExecutor: its echo upstream self-pipes the upgrade socket with no error listener, so the pipe re-emits 'error' on a destination with zero listeners when the connection is torn down. The real executor's upstream is a separate process and cannot do this, which made the fake deadlier than what it stands in for.

Checklist

  • pnpm lint clean
  • pnpm typecheck clean (8 projects)
  • pnpm test green — server 559 (was 557; +2), console 25, sdk 31, cli 59, e2e 86 pass / 11 skip
  • Both new tests verified red without their own fix, green with it
  • New tests run 6× consecutively without flaking
  • Real-daemon acceptance: built daemon (fake executor, DORMICE_SANDBOX_DOMAIN set) survives 120 staggered upgrade-then-reset connections, still serves the proxy afterwards, zero crash traces; normal upgrade still completes 101 + echo. Temp dirs and sandbox cleaned up after.
  • No changeset — packages/server is private and never published

The http server drops its own socket error handler before emitting
'upgrade', so handleUpgrade receives a bare socket — and then awaited
resolveTarget, which walks locks.run into wakeSandbox and can sit there
for seconds on a cold wake, before attaching any listener of its own. A
TCP reset inside that window is a listener-less 'error' event, which
throws; nothing in the server installs an uncaughtException handler, so
the daemon exits and every in-flight exec, restore and archive dies with
it. handleRequest never had the problem: its ServerResponse keeps the
server's own cleanup.

Only a reset does this — a graceful FIN does not — and an HTTP reverse
proxy absorbs a client's reset rather than passing it upstream, so with
the daemon bound to loopback this is a local robustness defect, not a
remotely reachable one. The realistic trigger is a local client aborting
during a wake.

The listener moves to the first statement, before any await. Its 'error'
twin further down goes away rather than being kept in parallel: the new
listener destroys the socket, and a destroyed socket always reaches
'close', which already runs the same settle() and upstream.destroy().

Verifying the fix against a real daemon turned up the same bug a second
time, in the fake: its echo upstream self-pipes the upgrade socket with
no error listener, so tearing the connection down after a client reset
re-emits 'error' on a pipe destination with zero listeners. The real
executor's upstream is a separate process and cannot take the daemon
down that way, which made the stand-in deadlier than the thing it stands
in for. Both tests are red without their own fix: one resets while the
wake is held in the key slot, the other after the handshake, staggered,
because a single well-timed close does not reproduce it.
@sweetcornna
sweetcornna merged commit f26ba37 into main Jul 27, 2026
3 of 4 checks passed
@sweetcornna
sweetcornna deleted the fix/upgrade-socket-error-listener branch July 27, 2026 08:48
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.

sandbox proxy: handleUpgrade leaves the socket without an error listener during wake, so a client reset kills the daemon

1 participant