A "sent" prompt could vanish with no trace on a flaky connection (e.g. a train):
with local echo on, Enter cleared the overlay then sent over the WebSocket
fire-and-forget. On a half-open socket (readyState===OPEN, dead TCP) ws.send()
doesn't throw, so the frame was silently discarded, nothing was enqueued, and
navigator.onLine stayed true — the prompt was lost and never resent.
Replace the best-effort offline queue with a durable, acknowledged delivery layer:
- Client (app.js): every input frame is recorded with a stable clientId +
monotonic per-session seq and persisted to localStorage BEFORE delivery, and
only dropped on a server ACK. Delivered over WS (acked via {t:'ia',seq}) or,
when the socket is down, POST in seq order (HTTP 2xx = ACK). A 2s sweep
force-reconnects a WS whose oldest frame is unacked past 4s (half-open sockets
never recover on their own); on reconnect/reload all pending frames re-deliver.
Survives reconnects AND page reloads. Connection indicator shows pending count.
- Server: Session.shouldApplyInput(clientId, seq) applies each frame exactly once
(bounded MRU map); ws-routes + POST /input dedup a redelivered seq but still ACK
it (200 / {t:'ia'}), so an at-least-once resend can never type the prompt twice.
Untagged input (curl/legacy) applies unconditionally — no behavior change.
- terminal-ui.js sendInput() (voice / keyboard-accessory / paste) now routes
through the same durable layer.
Tests: test/reliable-input-dedup.test.ts (exactly-once semantics on the real
Session) + POST /input dedup route tests. Design: docs/reliable-input-delivery.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3.8 KiB
Reliable input delivery (exactly-once, durable)
The bug this fixes
With local echo on, pressing Enter cleared the overlay and then sent the prompt
over the WebSocket fire-and-forget (ws.send({t:'i',d})). On a flaky link
(e.g. a moving train) the socket is frequently half-open: readyState === OPEN
so ws.send() does not throw, but the underlying TCP is dead, so the frame is
silently discarded. Nothing was enqueued (the send "succeeded"), the on-screen
prompt was already wiped, and navigator.onLine stays true — so a long typed
prompt vanished with no trace and no resend.
The guarantee
Every byte of user input is recorded durably before delivery and only
dropped once the server ACKs it — so a half-open socket, a reconnect, or a page
reload can never lose input. Redelivery is exactly-once: the server applies
each (clientId, seq) at most once, so a resend can't type the prompt twice.
How it works
Client (app.js)
- A stable
clientId(localStorage['codeman:clientId']) identifies this browser to the server's dedup across reconnects and reloads. - Each input frame gets a monotonic per-session
seq. Frame records ({seq,data,useMux,ts,tries,sentAt}) live in_pendingDeliveries(Map<sessionId, record[]>), persisted (debounced, + flushed onpagehide/visibilitychange) tolocalStorage['codeman:pendingInput']. The seq counters persist too, so seqs stay monotonic across reloads (never reset — a reset would let the server treat fresh input as an already-applied duplicate). - Delivery (
_drainSession):- WS path — when the socket is
OPENfor the session, send each not-yet-sent record (sentAt === 0) in seq order over the single ordered stream. Records stay pending until the server's{t:'ia',seq}ACK removes them. - POST path — when no WS, POST records in order, awaiting each (the HTTP 2xx is the ACK). A 404/410 (session gone) drops the record rather than retry forever.
- WS path — when the socket is
- Half-open recovery (
_redeliverSweep, every 2s): if the active WS session's oldest record is unacked past_reliableAckTimeoutMs(4s), the socket is assumed dead —ws.close()forces a fast reconnect;onopen(_onWsReady) resetssentAt = 0and re-sends everything pending. Also re-drains background sessions over POST, and fires on SSE-reconnect /online. - The connection indicator shows pending count/bytes (
_pendingBytes).
Server
Session.shouldApplyInput(clientId, seq)— returnstrueexactly once per(clientId, seq): the first time a seq strictly greater than that client's last-applied is seen. A replayed/lower seq returnsfalse. Bounded MRU map (MAX_INPUT_DEDUP_CLIENTS = 256).- WS route (
ws-routes.ts) — parses optionalcid/seqon{t:'i'}; applies viashouldApplyInput(skips a duplicate, still ACKs with{t:'ia',seq}so the client drops it). Untagged frames apply unconditionally (no behavior change). - POST route (
/api/sessions/:id/input) — optionalseq/clientIdinSessionInputWithLimitSchema; a deduped duplicate returns 200 without writing (the 200 is the client's ACK).curl/legacy callers omit the fields and always apply.
Known limitation
Dedup state is in-memory on the server. A server restart between a write and the client's redelivery of that same seq could re-apply it (a rare duplicate). This is a deliberate trade-off: favor never losing input over a rare duplicate across the narrow restart window.
Tests
test/reliable-input-dedup.test.ts—Session.shouldApplyInputexactly-once semantics (monotonic, per-client, gap-tolerant, eviction-safe).test/routes/session-routes.test.ts— POST/inputapplies a tagged(clientId, seq)once on redelivery; untagged input always applies.