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>
This commit is contained in:
Codeman maintainer
2026-06-19 16:58:40 +02:00
parent 9d12fc7f94
commit 1255e28f6f
10 changed files with 567 additions and 88 deletions
+36
View File
@@ -2213,6 +2213,42 @@ export class Session extends EventEmitter {
}
}
/**
* Per-client highest-applied input sequence, for exactly-once input delivery.
* Keyed by the web client's stable `clientId`. Bounded so many devices over a
* long-lived session can't grow it without limit (insertion order = MRU, so
* eviction drops the least-recently-active client).
*/
private _appliedInputSeq = new Map<string, number>();
private static readonly MAX_INPUT_DEDUP_CLIENTS = 256;
/**
* Decide whether an input frame should be applied to the PTY or skipped as a
* duplicate redelivery. Returns true exactly once per (clientId, seq): the
* first time a seq strictly greater than the client's last-applied is seen.
* A redelivery of an already-applied seq (the client never got our ACK and
* resent) returns false. Callers should ACK regardless — a duplicate is, from
* the client's view, "delivered" — and only `write()` the PTY when this is
* true. Relies on the client delivering one client's frames in seq order over
* a single ordered stream, so `seq <= last` ⇒ already applied.
*
* Without this, the client's at-least-once redelivery (needed because a
* half-open socket silently drops frames with no error) would type a prompt
* twice whenever an ACK is lost after the write landed.
*/
shouldApplyInput(clientId: string, seq: number): boolean {
const last = this._appliedInputSeq.get(clientId);
if (last !== undefined && seq <= last) return false;
// Re-insert to move this client to the MRU end for fair eviction.
if (last !== undefined) this._appliedInputSeq.delete(clientId);
this._appliedInputSeq.set(clientId, seq);
if (this._appliedInputSeq.size > Session.MAX_INPUT_DEDUP_CLIENTS) {
const oldest = this._appliedInputSeq.keys().next().value;
if (oldest !== undefined) this._appliedInputSeq.delete(oldest);
}
return true;
}
/**
* Sends input via the terminal multiplexer's direct input mechanism.
*