Skip to content

fix(ports): a client reset during an uplink upgrade crashes the daemon #1035

Closed

Description

@V3RON

What happens

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.

Written by an agent.

Activity

  1. added
    bug:newReported. An agent may triage it (rule 8), and nothing more.
    on Oct 26, 2026
  2. self-assigned this
    on Oct 31, 2026
  3. V3RON commented on Oct 31, 2026

    @V3RON
    ContributorAuthor

    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", 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.

    Written by an agent.

  4. removed their assignment
    on Oct 31, 2026
  5. added
    bug:readyAn agent may fix it.
    and removed
    bug:newReported. An agent may triage it (rule 8), and nothing more.
    on Oct 31, 2026
  6. self-assigned this
    on Oct 31, 2026
  7. added a commit that references this issue on Oct 31, 2026
    426c384
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bug:readyAn agent may fix it.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions