Skip to content

fix(client): connect contract + abortable open — subscribe races, revived-transport latch, CONNECTING leak - #43

Merged
grrowl merged 1 commit into
mainfrom
fix/transport-connect-contract
Aug 26, 2026
Merged

fix(client): connect contract + abortable open — subscribe races, revived-transport latch, CONNECTING leak#43
grrowl merged 1 commit into
mainfrom
fix/transport-connect-contract

Conversation

@grrowl

@grrowl grrowl commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Fixes two facets of one defect family in the transport's connect/close boundary — the in-flight-open() window between "start dialing" and "socket adopted". Recorded as ADR-0020 (amends ADR-0016 + ADR-0011's Transport seam; supersedes neither).

Issue #37connect() could resolve having adopted no socket

The close-epoch discard returned, resolving connect() with this.ws === null. The awaiting subscribe/sendMut/fetch then sent on a null socket and threw — floating an unhandled rejection (measured: 9 per cold page load) and leaving the collection silently empty. Two residues shared the surface: intentionallyClosed latched forever (a transport revived by a later connect() — connection pools reuse instances — lost auto-reconnect), and the same latch silenced onClosed on revived transports.

Fix:

  • connect() never resolves disconnected — at the epoch discard it re-dials a revived transport (defers to whichever dial now owns it) or rejects the new typed TransportClosedError.
  • Dialing clears intentionallyClosed — one line restores both auto-reconnect and onClosed on a revived transport.
  • Only the dial that still owns connectPromise drives failure recovery (ownership-guarded catch), so a re-dialing superseded attempt can't null a newer dial's promise or double-schedule its reconnect.

Issue #38close() could not abort an in-flight open()

close() disposes through this.ws, which is null during the handshake, so a slow/never-completing open() leaked a socket stuck in CONNECTING forever.

Fix: open gains an optional AbortSignal that close() aborts while the handshake is in flight; the default browser opener honours it (closes the socket, rejects). Backward-compatible — a signal-ignoring opener still works via the epoch-discard backstop, but a never-resolving handshake then leaks (documented; the only residual gap, opt-in, closed by honouring the signal).

Acceptance criteria — test by test

New tests/connect-contract.test.ts (7):

Rewrote reconnect-policy.test.ts "intentional close() is permanent" → close() suppresses auto-reconnect until a later connect() REVIVES the transport (the old test pinned the now-reversed permanence contract; #37 acceptance is revival).

Validation

  • npm test: 277 passed (was 270 on this base), 54 files, no skips.
  • npm run typecheck: clean.

Codex adversary pass (gpt-5.6-sol)

No serious findings. It confirmed: the epoch re-dial cannot resolve before a socket installs; abort ownership is per-dial; the connectPromise ownership guard prevents wiping a newer dial / double-consuming the policy; ADR-0016 policy is not bypassed (the epoch branch re-dials only after another connect() cleared the latch); ADR-0011 stale-socket receipt semantics intact. One theoretical edge dismissed: a hostile/non-conforming injected WebSocketLike whose addEventListener/binaryType setter/send() synchronously re-enters close() between the epoch check and install — browser WebSockets never dispatch those synchronously, so it's out of the documented opener contract.

Deliberate departures / scope

Fixes #37
Fixes #38

🤖 Generated with Claude Code

…ived-transport latch, CONNECTING leak

connect() and close() shared one race-prone window: the in-flight open()
between "start dialing" and "socket adopted". Two defects lived there
(issues #37, #38), fixed coherently per ADR-0020.

discard `return`ed). The awaiting subscribe/sendMut/fetch then sent on a
null socket and threw, floating an unhandled rejection and leaving the
collection silently empty. And `intentionallyClosed` latched forever: a
transport revived by a later connect() (connection pools do this) lost
auto-reconnect AND onClosed delivery.

  - connect() never resolves disconnected: it re-dials a revived transport
    or rejects the new typed TransportClosedError.
  - Dialing clears the intentional-close latch, restoring auto-reconnect and
    onClosed on a revived transport.
  - Only the dial that still owns connectPromise drives failure recovery
    (ownership-guarded catch), so a re-dialing superseded attempt can't null
    a newer dial's promise or double-schedule its reconnect.

(this.ws is null during the handshake), so a slow/never-completing handshake
leaked a CONNECTING socket forever.

  - open() gains an optional AbortSignal that close() aborts; the default
    browser opener honours it (closes the socket, rejects). Backward-
    compatible: a signal-ignoring opener still works via the epoch-discard
    backstop, but a never-resolving handshake then leaks (documented).

Does not bypass ADR-0016 reconnect policy; preserves ADR-0011 stale-socket
receipt semantics and the no-idle-timers invariant. Issue #39 out of scope.

Tests: new tests/connect-contract.test.ts (7) pin each acceptance criterion;
the reconnect-policy "close() is permanent" test is rewritten to pin revival.
Full suite green, typecheck clean. Reviewed by codex (gpt-5.6-sol): no
serious findings.

Fixes #37
Fixes #38

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@grrowl
grrowl merged commit acae3b2 into main Aug 26, 2026
1 check passed
@grrowl
grrowl deleted the fix/transport-connect-contract branch August 26, 2026 05:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant