Skip to content

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

Description

@grrowl

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions