Files
Codeman/docs/reliable-input-delivery.md
T
Codeman maintainer 1255e28f6f fix(input): durable exactly-once input delivery so a dropped link can't lose a prompt
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>
2026-06-19 16:58:40 +02:00

73 lines
3.8 KiB
Markdown

# 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 on `pagehide`/
`visibilitychange`) to `localStorage['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 `OPEN` for 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.
- **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`) resets
`sentAt = 0` and 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)`** — returns `true` exactly once per
`(clientId, seq)`: the first time a seq strictly greater than that client's
last-applied is seen. A replayed/lower seq returns `false`. Bounded MRU map
(`MAX_INPUT_DEDUP_CLIENTS = 256`).
- **WS route** (`ws-routes.ts`) — parses optional `cid`/`seq` on `{t:'i'}`; applies
via `shouldApplyInput` (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`) — optional `seq`/`clientId` in
`SessionInputWithLimitSchema`; 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.shouldApplyInput` exactly-once
semantics (monotonic, per-client, gap-tolerant, eviction-safe).
- `test/routes/session-routes.test.ts` — POST `/input` applies a tagged
`(clientId, seq)` once on redelivery; untagged input always applies.