fix(client): settle in-flight mut/call across an unexpected close (#39) - #45
Merged
Conversation
…old-and-replay + typed ConnectionLostError An in-flight mut/call whose socket dropped unexpectedly was abandoned to its generic 5s confirmation timeout, fully decoupled from the close event. The sharp edge is server-side ordering: recordTx + broadcast run BEFORE the `committed` send, so a drop in that window rolled back a write that had already durably committed (commit -> rollback -> reappear), and the mut promise rejected — with a timeout indistinguishable from a quiet server — for a write that landed. Primary (hold-and-replay): on an unexpected drop with subscriptions active, PARK each in-flight mut/call (swap its generic-timeout timer for a bounded ConnectionLostError timer, retain the encoded frame) and RESEND it on reconnect. The server's dedup table answers the replayed txId with its true recorded outcome — a committed write resolves `committed` (never a timeout rollback) within the dedup window; a rejected one rejects with its real MutationRejectedError; a never-received one executes once. Exactly-once holds either way (ADR-0002 C5). Fallback (typed): where no reconnect can resolve it — a terminal 4xxx close, no active subscriptions, or replay slower than timeoutMs from the drop — the mut/call settles promptly with the new exported ConnectionLostError, instanceof-distinct from MutationRejectedError, TransportClosedError, and the generic timeout. The type is the contract: an app holds its optimistic overlay instead of flashing a rollback. Timeout semantics for a socket that stays open are unchanged. Also (codex adversary review): - unsubscribe() of the last sub while a reconnect is pending cancels the pending timer, clears the reconnecting flag (a handshake already in flight installs without replaying) and settles parked txs typed — a subless reconnect must never replay. - reject a CONCURRENT duplicate in-flight txId loud (MutationRejectedError DUPLICATE_TXID) so a timer's delete-by-key can't evict a different waiter. - export TransportClosedError from the client barrel (declared a public export in ADR-0020 but never wired) so the full error taxonomy is instanceof-usable. New ADR-0021. Fixes #39. 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 #39.
An in-flight
mut/callwhose socket dropped unexpectedly was abandoned to its generic 5 s confirmation timeout, fully decoupled from the close event. The sharp edge is server-side ordering (src/server/mixin.ts#handleMut/#handleCall):recordTx+ the delta broadcast run before thecommittedsend, so a drop in that window rolled back a write that had already durably committed — the app saw commit → rollback → reappear, and themutpromise rejected (with a timeout indistinguishable from a quiet server) for a write that landed.Design chosen — hold-and-replay (b) primary + typed
ConnectionLostError(a) fallbackIssue #39 sketched (a) settle-immediately-typed and (b) reconcile-via-dedup-replay. I chose (b) as primary with (a) as the bounded fallback — recorded in ADR-0021.
mut/callis parked (its generic-timeout timer swapped for a boundedConnectionLostErrortimer; the encoded frame retained) and resent on reconnect. The server's dedup table answers the replayedtxIdwith its true recorded outcome — a committed write resolvescommitted(never a timeout rollback) within the dedup window; a rejected one rejects with its realMutationRejectedError; a never-received one executes once. Exactly-once holds either way (the dedup contract, ADR-0002 C5).timeoutMsfrom the drop — themut/callsettles promptly with the new exportedConnectionLostError,instanceof-distinct fromMutationRejectedError,TransportClosedError, and the generic timeout. The type is the contract: an app catches it to hold its optimistic overlay instead of flashing a rollback.Why not (a)-only: it can only ever report "unknown", so an app holding its overlay on that signal would be wrong for a write the server actually rejected (no catch-up delta ever corrects it). (b) resolves committed-vs-rejected from the authoritative record; (a) is retained only where truth is genuinely unreachable, and bounded by the parked timer.
Acceptance criteria → tests (
tests/pending-tx-close.test.ts)committedreceipt, the server socket is dropped, reconnect replays → resolves; row asserted committed server-side)instanceof, exported)ConnectionLostErrorvsMutationRejectedErrorvs generic timeout; exported from./clientconfirmation timeout, notConnectionLostError)Adversary pass (codex
gpt-5.6-sol, read-only)Two rounds. Findings and resolutions:
unsubscribe()of the last sub during a pending reconnect still reconnected and replayed (contradicting the no-subs fallback / ADR-0016). Fixed:unsubscribenow cancels a pending reconnect timer, clears thereconnectingflag, and settles parked txs typed. Test: "unsubscribing the last sub while a reconnect is pending…".timeoutMs≪dedupRetentionMs. Dismissed as a documented deployment invariant. The re-execute-after-retention property is inherent to the ADR-0002 C5 dedup contract for any client retry — not introduced here — and the automatic resend is bounded totimeoutMsfrom the drop (default 5 s ≪ 1 h default retention, three orders of magnitude). The client cannot seededupRetentionMs; the invarianttimeoutMs ≤ dedupRetentionMsis documented in ADR-0021. Fully closing it needs a protocol change (server frame distinguishing "expired/unknown txId"), out of scope.MutationRejectedErrorDUPLICATE_TXID); a sequential retry after settlement is unaffected. Test: "rejects a CONCURRENT duplicate in-flight txId loud".unsubscribeonly cancels a reconnect still on its timer; a handshake already in flight can install an idle socket. Resolved deliberately: the client: in-flight mut/call across an unexpected close is abandoned to its timeout — after the server may have committed #39 guarantee (no replay, parked txs settled typed) holds in both cases; force-aborting an in-flight dial would entangle ADR-0020's epoch/revival machinery (which must not regress). The residual — an idle, demand-less socket that the app disposes viaclose()— is pre-existing (an initial connect can do the same) and documented. Test pins the real contract: "unsubscribing the last sub while the reconnect HANDSHAKE is already in flight… never replays".Codex confirmed clean: no ADR-0011 stale-socket cursor regression (receipts still settle ID-scoped waiters; only the cursor is
stale-guarded), no double-settle, no leaked/stale-timer eviction after the fixes.Validation
npm test— 290 passed (56 files); +9 new tests over the base.npm run typecheck— clean.Changed files
src/client/transport.ts—ConnectionLostError; park/resend/fail-pending helpers; unexpected-close + terminal + unsubscribe wiring; duplicate-txId guard.src/client/index.ts— exportConnectionLostErrorandTransportClosedError(the latter declared public in ADR-0020 but never wired).docs/adr/0021-in-flight-settlement-unexpected-close.md(new) + index.CHANGELOG.md.tests/pending-tx-close.test.ts(new).🤖 Generated with Claude Code