Skip to content

client: close() cannot abort a socket whose open() is in flight — leaks CONNECTING sockets #38

Description

@grrowl

Found 2026-07-30 during agent-canvas's live-workspace delivery, against the 0.6.0 dist (real-browser observation, Chrome devtools WS panel, plus source trace). Verified still present on current main (post-#36, 2026-08-26).

Symptom

Disposing a transport (close()) while its open() is still in flight leaves the WebSocket stuck as pending (CONNECTING) in devtools, indefinitely. Repeated workspace switches, and React StrictMode's dev mount→unmount→remount, accumulate several pending sockets that never clean up.

Root cause

connect() assigns the socket only after the handshake resolves; close() closes through that field (this.ws?.close()). When close() runs while open() is in flight, this.ws is null and the close is a no-op. The one path that disposes an in-flight socket is the close-epoch discard inside the connect body — but that only runs when open() resolves. If the handshake is slow or never completes (a dev proxy that doesn't upgrade promptly, a hung upgrade, a half-open LB connection), the discard never runs and the socket leaks in CONNECTING forever — close() never had a handle to abort it.

Expected

close() can abort a still-connecting socket. Options, in rough preference order:

  • The transport retains a handle to the in-flight socket so close() can close it regardless of readyState. With the pluggable async open() this likely means changing the open contract: open() returns the socket plus a whenOpen promise, or open accepts an AbortSignal the transport aborts on close(), or the transport owns socket creation and open only signals readiness.
  • At minimum, document that a custom open must make its socket abortable on close().

Impact

Medium. Deterministic socket leak on any dispose-before-open. Benign in production (handshakes complete in ms, the discard path fires), but user-visible in dev and a real leak wherever a handshake can hang. A consumer worked around it in ~15 lines at the open seam; every consumer that disposes transports eagerly needs the same guard.

Acceptance criteria

  • close() during an in-flight open() aborts/closes that socket (or its eventual resolution closes it immediately, including the never-resolves case via abort).
  • If the open contract changes, it's a documented, typed change — README + changelog — and the default browser open implements it.
  • Test pins: close-during-handshake leaves no CONNECTING socket behind (observable via a fake open that exposes its socket).

Same in-flight-open window as the connect()-contract issue; fix them coherently (one PR is fine), without bypassing ADR-0016 reconnect policy.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions