Found 2026-07-29 while wiring terminal-close handling in agent-canvas, against the 0.6.0 dist. Verified still present on current main (post-#36, 2026-08-26): the unexpected-close listener never touches pendingTx/pendingFetches — only the app's own close() clears them (src/client/transport.ts:425-436).
The behavior
A mut/call in flight when the socket drops (terminal 4xxx or a plain blip) is not failed fast; it sits until its own 5s timeoutMs, fully decoupled from the close event.
The sharp edge: server-side, recordTx and the broadcast/flush run before the committed send, with no try/catch around the send. A close in that window means the write is already durably committed while the client's optimistic overlay times out and rolls back a write that succeeded. On reconnect the resubscribe converges the row back — but the app saw commit → rollback → reappear, and the mut promise rejected for a write that landed.
Note: the SSR lift (#36) improved one adjacent edge — a stale socket's late committed/rejected receipt now settles the waiter (receipt-aware stale-socket dispatch). The drop-to-timeout path this issue describes remains.
What would help
Either (or both):
- On unexpected close, reject/park pending txs immediately with a distinguishable "connection lost, outcome unknown" error instead of the generic timeout — apps can then hold the optimistic overlay until resubscribe answers, rather than flashing a rollback.
- After resubscribe, reconcile pending txs against the caught-up state before rolling anything back (the tx ids are known client-side; the server's dedup table already answers replayed txIds with the recorded outcome).
The onClosed ctor hook fires correctly on terminal closes and is the right place for apps to learn why — but it can't fix the pending-tx race from outside, since the timeout races the app's handler.
Acceptance criteria
- An in-flight
mut whose socket drops unexpectedly settles promptly (not via the generic timeout) with a typed, distinguishable outcome — or is resolved correctly after reconnect via dedup replay/reconciliation.
- A write the server committed is never surfaced to the app as a plain timeout rejection when the client reconnects within the dedup window.
- Tests pin: drop-after-commit-before-receipt (the sharp edge), drop-before-server-receives (clean reject path), and the distinguishable error shape.
Touches the same transport.ts region as the connect()-contract work — sequence after it to avoid conflicts.
Found 2026-07-29 while wiring terminal-close handling in agent-canvas, against the 0.6.0 dist. Verified still present on current
main(post-#36, 2026-08-26): the unexpected-close listener never touchespendingTx/pendingFetches— only the app's ownclose()clears them (src/client/transport.ts:425-436).The behavior
A
mut/callin flight when the socket drops (terminal 4xxx or a plain blip) is not failed fast; it sits until its own 5stimeoutMs, fully decoupled from the close event.The sharp edge: server-side,
recordTxand the broadcast/flush run before thecommittedsend, with no try/catch around the send. A close in that window means the write is already durably committed while the client's optimistic overlay times out and rolls back a write that succeeded. On reconnect the resubscribe converges the row back — but the app saw commit → rollback → reappear, and themutpromise rejected for a write that landed.Note: the SSR lift (#36) improved one adjacent edge — a stale socket's late
committed/rejectedreceipt now settles the waiter (receipt-aware stale-socket dispatch). The drop-to-timeout path this issue describes remains.What would help
Either (or both):
The
onClosedctor hook fires correctly on terminal closes and is the right place for apps to learn why — but it can't fix the pending-tx race from outside, since the timeout races the app's handler.Acceptance criteria
mutwhose socket drops unexpectedly settles promptly (not via the generic timeout) with a typed, distinguishable outcome — or is resolved correctly after reconnect via dedup replay/reconciliation.Touches the same
transport.tsregion as the connect()-contract work — sequence after it to avoid conflicts.