You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(ports): a client reset during an uplink upgrade crashes the daemon #1035
handleUpgrade in src/ports/uplink-websocket.ts:217-266 waits for authentication on a raw upgrade socket that has no error listener. If the client resets the TCP connection during that wait (ECONNRESET), the socket emits error with nobody listening, and the daemon process crashes.
How it was found
The review of PR #1034 (#1017) found the same problem in its new upgrade router. A probe sent an upgrade request, called resetAndDestroy(), and then released the auth gate. The result was Error: read ECONNRESET as an uncaught error. #1034 fixed its router (R5). The uplink path is the same code at 16fac8f and on main, so the bug predates that PR.
Expected
A client that resets during the upgrade is dropped, and the daemon keeps running.
The bug is confirmed as reported. It is a defect: the test's expectation is the right behaviour.
src/ports/uplink-websocket.test.ts, "WebSocketUplinkListenerFactory client reset > survives a client that resets while authenticate is pending, and never accepts it", on bug/1035-repro (ebd2cb6). It sends a raw /v1/uplink upgrade, waits until authenticate is called, resets the TCP connection, then resolves authenticate with accept.
pnpm exec vitest run --project unit src/ports/uplink-websocket.test.ts -t "client reset"
AssertionError: expected [ Error: read ECONNRESET { …(3) } ] to deeply equal []
❯ src/ports/uplink-websocket.test.ts:190:23
190| expect(crashes).toEqual([]);
Root cause
src/ports/uplink-websocket.ts:252 awaits handlers.authenticate while the raw socket has no error listener. When Node's HTTP server emits upgrade, it removes its own socket error handler, and ws adds its handler only inside this.#server.handleUpgrade (line 266). An ECONNRESET during that await is emitted with nothing listening, so it becomes an uncaught exception. The daemon has no uncaughtException handler, so the process exits. The router's lease path had the same gap and #1034 fixed it at src/ports/upgrade-router.ts:68.
Simplest fix
Make socket.on("error", () => undefined); the first line of handleUpgrade, as leaseUpgrade does. I tried this locally and all 14 tests in the file pass. A reset socket is already dropped: ws destroys a socket that is no longer readable and never calls back, so accept is not reached.
Alternatives rejected
Add the listener in createUpgradeRouter for every path: attach would still have the gap, and the router would own an uplink detail.
A process-wide uncaughtException handler: it would hide other faults and keep running in an unknown state.
Risk
The no-op listener stays on the socket after ws takes it over. This is harmless (ws adds its own), and the lease path already does the same.
Check that no other await happens before a listener exists on any upgrade path.
Activity
V3RON commentedon Oct 31, 2026
Reproduction
The bug is confirmed as reported. It is a defect: the test's expectation is the right behaviour.
src/ports/uplink-websocket.test.ts, "WebSocketUplinkListenerFactory client reset > survives a client that resets while authenticate is pending, and never accepts it", onbug/1035-repro(ebd2cb6). It sends a raw/v1/uplinkupgrade, waits untilauthenticateis called, resets the TCP connection, then resolvesauthenticatewithaccept.Root cause
src/ports/uplink-websocket.ts:252awaitshandlers.authenticatewhile the raw socket has noerrorlistener. When Node's HTTP server emitsupgrade, it removes its own socket error handler, andwsadds its handler only insidethis.#server.handleUpgrade(line 266). AnECONNRESETduring that await is emitted with nothing listening, so it becomes an uncaught exception. The daemon has nouncaughtExceptionhandler, so the process exits. The router's lease path had the same gap and #1034 fixed it atsrc/ports/upgrade-router.ts:68.Simplest fix
Make
socket.on("error", () => undefined);the first line ofhandleUpgrade, asleaseUpgradedoes. I tried this locally and all 14 tests in the file pass. A reset socket is already dropped:wsdestroys a socket that is no longer readable and never calls back, soacceptis not reached.Alternatives rejected
createUpgradeRouterfor every path:attachwould still have the gap, and the router would own an uplink detail.uncaughtExceptionhandler: it would hide other faults and keep running in an unknown state.Risk
wstakes it over. This is harmless (wsadds its own), and the lease path already does the same.awaithappens before a listener exists on any upgrade path.Written by an agent.
fix(ports): drop the destroyed-socket guard ws already covers (#1035)
fix(ports): survive a client reset during uplink authenticate (callst…