Skip to content

fix(client): settle in-flight mut/call across an unexpected close (#39) - #45

Merged
grrowl merged 1 commit into
mainfrom
fix/pending-tx-unexpected-close
Aug 26, 2026
Merged

fix(client): settle in-flight mut/call across an unexpected close (#39)#45
grrowl merged 1 commit into
mainfrom
fix/pending-tx-unexpected-close

Conversation

@grrowl

@grrowl grrowl commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Fixes #39.

An in-flight mut/call whose 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 the committed send, so a drop in that window rolled back a write that had already durably committed — the app saw commit → rollback → reappear, and the mut promise rejected (with a timeout indistinguishable from a quiet server) for a write that landed.

Design chosen — hold-and-replay (b) primary + typed ConnectionLostError (a) fallback

Issue #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.

  • Primary (hold-and-replay). On an unexpected drop with subscriptions active, each in-flight mut/call is parked (its generic-timeout timer swapped for a bounded ConnectionLostError timer; the encoded frame retained) and resent 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 (the dedup contract, ADR-0002 C5).
  • Fallback (typed). Where no reconnect can resolve it — a terminal 4xxx close, no active subscriptions (ADR-0016 does not reconnect for zero subs, preserving hibernation — not bypassed), 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 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)

Criterion Test
Drop-after-commit-before-receipt: never a plain timeout rollback; the true outcome after reconnect full-stack, real dedup: "drop-after-commit-before-receipt: the mut RESOLVES committed after reconnect" (a wrapper swallows the committed receipt, the server socket is dropped, reconnect replays → resolves; row asserted committed server-side)
True outcome both ways full-stack: "drop-after-reject-before-receipt: settles with the TRUE MutationRejectedError after reconnect"
Drop-before-server-receives: clean typed settlement "drop-before-server-receives with NO subscriptions settles PROMPTLY with a typed ConnectionLostError"
Distinguishable error shape (instanceof, exported) asserted throughout: ConnectionLostError vs MutationRejectedError vs generic timeout; exported from ./client
Timeout semantics unchanged for an open socket "timeout semantics are UNCHANGED for a socket that stays open" (generic confirmation timeout, not ConnectionLostError)
Held-then-replayed mechanics "holds an in-flight mut across a transient drop and REPLAYS it on reconnect"
Terminal close "a TERMINAL 4xxx close settles the in-flight mut with ConnectionLostError (and fires onClosed)"

Adversary pass (codex gpt-5.6-sol, read-only)

Two rounds. Findings and resolutions:

  1. [High] unsubscribe() of the last sub during a pending reconnect still reconnected and replayed (contradicting the no-subs fallback / ADR-0016). Fixed: unsubscribe now cancels a pending reconnect timer, clears the reconnecting flag, and settles parked txs typed. Test: "unsubscribing the last sub while a reconnect is pending…".
  2. [High] dedup-window safety relied on timeoutMsdedupRetentionMs. 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 to timeoutMs from the drop (default 5 s ≪ 1 h default retention, three orders of magnitude). The client cannot see dedupRetentionMs; the invariant timeoutMs ≤ dedupRetentionMs is documented in ADR-0021. Fully closing it needs a protocol change (server frame distinguishing "expired/unknown txId"), out of scope.
  3. [Medium] concurrent duplicate in-flight txId could let one waiter's timer evict another's entry (a pre-existing latent bug). Fixed: a concurrent duplicate is rejected loud (MutationRejectedError DUPLICATE_TXID); a sequential retry after settlement is unaffected. Test: "rejects a CONCURRENT duplicate in-flight txId loud".
  4. [Medium, follow-on] unsubscribe only 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 via close() — 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 test290 passed (56 files); +9 new tests over the base.
  • npm run typecheck — clean.

Changed files

  • src/client/transport.tsConnectionLostError; park/resend/fail-pending helpers; unexpected-close + terminal + unsubscribe wiring; duplicate-txId guard.
  • src/client/index.ts — export ConnectionLostError and TransportClosedError (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

…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>
@grrowl
grrowl merged commit 6531be2 into main Aug 26, 2026
1 check passed
@grrowl
grrowl deleted the fix/pending-tx-unexpected-close branch August 26, 2026 06:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

client: in-flight mut/call across an unexpected close is abandoned to its timeout — after the server may have committed

1 participant