fix(client): connect contract + abortable open — subscribe races, revived-transport latch, CONNECTING leak - #43
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sTransportseam; supersedes neither).Issue #37 —
connect()could resolve having adopted no socketThe close-epoch discard
returned, resolvingconnect()withthis.ws === null. The awaitingsubscribe/sendMut/fetchthen 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:intentionallyClosedlatched forever (a transport revived by a laterconnect()— connection pools reuse instances — lost auto-reconnect), and the same latch silencedonClosedon 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 typedTransportClosedError.intentionallyClosed— one line restores both auto-reconnect andonClosedon a revived transport.connectPromisedrives failure recovery (ownership-guardedcatch), so a re-dialing superseded attempt can't null a newer dial's promise or double-schedule its reconnect.Issue #38 —
close()could not abort an in-flightopen()close()disposes throughthis.ws, which isnullduring the handshake, so a slow/never-completingopen()leaked a socket stuck in CONNECTING forever.Fix:
opengains an optionalAbortSignalthatclose()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):subscribe before the handshake completes still lands the sub frame once connected— subscribe-before-open.close() during the handshake rejects the in-flight connect() typed— never resolves disconnected; rejectsTransportClosedError.close() during the first subscribe's handshake, then a fresh connect() revives and lands the sub— close-during-handshake then re-connect; first subscribe rejects typed (no unhandled), revived dial lands the frame.auto-reconnects on a 1006 drop after a connect() revives a closed transport— revived-transport reconnect on 1006.delivers onClosed on a terminal 4xxx close after a connect() revives a closed transport— revived-transportonClosedon a terminal code.close() during a never-resolving open() aborts (closes) the still-CONNECTING socket— no CONNECTING leak, via a fakeopenthat exposes its socket.a custom open() that IGNORES the signal still has its late socket discarded and closed— backward-compat + "eventual resolution closes it immediately".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
connectPromiseownership guard prevents wiping a newer dial / double-consuming the policy; ADR-0016 policy is not bypassed (the epoch branch re-dials only after anotherconnect()cleared the latch); ADR-0011 stale-socket receipt semantics intact. One theoretical edge dismissed: a hostile/non-conforming injectedWebSocketLikewhoseaddEventListener/binaryTypesetter/send()synchronously re-entersclose()between the epoch check and install — browser WebSockets never dispatch those synchronously, so it's out of the documented opener contract.Deliberate departures / scope
close()is no longer permanent across a laterconnect(). Code relying on permanent terminality must not re-connect()the same instance.Fixes #37
Fixes #38
🤖 Generated with Claude Code