mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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:
@@ -39,6 +39,16 @@ export class MockSession extends EventEmitter {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Exactly-once input dedup — mirrors Session.shouldApplyInput so route tests
|
||||
* exercising the reliable-delivery path behave like production. */
|
||||
private _appliedInputSeq = new Map<string, number>();
|
||||
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||
const last = this._appliedInputSeq.get(clientId);
|
||||
if (last !== undefined && seq <= last) return false;
|
||||
this._appliedInputSeq.set(clientId, seq);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Get the last written data */
|
||||
get lastWrite(): string | undefined {
|
||||
return this.writeBuffer[this.writeBuffer.length - 1];
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @fileoverview Exactly-once input delivery — Session.shouldApplyInput dedup.
|
||||
*
|
||||
* Guards the server half of the reliable-input-delivery feature: the web client
|
||||
* tags each input frame with a stable clientId + a monotonic per-session seq and
|
||||
* redelivers anything it hasn't seen ACKed (a half-open socket silently drops
|
||||
* frames on a flaky link). shouldApplyInput must apply each (clientId, seq)
|
||||
* exactly once so a redelivery can never type the prompt twice — while still
|
||||
* applying untagged input (curl/legacy) unconditionally at the call sites.
|
||||
*
|
||||
* See docs/reliable-input-delivery.md.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
function makeSession(): Session {
|
||||
// workingDir is the only required field; no PTY is spawned until start(),
|
||||
// and TmuxManager no-ops under VITEST — so this is a cheap, side-effect-free
|
||||
// instance for exercising the pure dedup bookkeeping.
|
||||
return new Session({ workingDir: '/tmp' });
|
||||
}
|
||||
|
||||
describe('Session.shouldApplyInput (exactly-once input dedup)', () => {
|
||||
it('applies a fresh (clientId, seq) exactly once', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('clientA', 1)).toBe(true);
|
||||
// Same seq redelivered (lost ACK) — must NOT apply again.
|
||||
expect(s.shouldApplyInput('clientA', 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('applies strictly increasing seqs and rejects stale ones', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('c', 1)).toBe(true);
|
||||
expect(s.shouldApplyInput('c', 2)).toBe(true);
|
||||
expect(s.shouldApplyInput('c', 3)).toBe(true);
|
||||
// Out-of-order / replayed lower seqs are duplicates.
|
||||
expect(s.shouldApplyInput('c', 2)).toBe(false);
|
||||
expect(s.shouldApplyInput('c', 1)).toBe(false);
|
||||
// The next genuinely-new seq still applies.
|
||||
expect(s.shouldApplyInput('c', 4)).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks each client independently', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('a', 5)).toBe(true);
|
||||
// A different client at seq 1 is not shadowed by client a's higher seq.
|
||||
expect(s.shouldApplyInput('b', 1)).toBe(true);
|
||||
expect(s.shouldApplyInput('b', 1)).toBe(false);
|
||||
expect(s.shouldApplyInput('a', 6)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates a seq gap (skips never collapse a new seq to a duplicate)', () => {
|
||||
const s = makeSession();
|
||||
expect(s.shouldApplyInput('c', 1)).toBe(true);
|
||||
// Client jumped seq (e.g. resumed after a reload that kept the counter).
|
||||
expect(s.shouldApplyInput('c', 100)).toBe(true);
|
||||
expect(s.shouldApplyInput('c', 100)).toBe(false);
|
||||
expect(s.shouldApplyInput('c', 50)).toBe(false);
|
||||
expect(s.shouldApplyInput('c', 101)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps recent clients dedup-correct past the eviction bound', () => {
|
||||
const s = makeSession();
|
||||
// Far exceed MAX_INPUT_DEDUP_CLIENTS (256) with one-shot clients, then prove
|
||||
// a freshly-active client is still deduped correctly (MRU eviction).
|
||||
for (let i = 0; i < 400; i++) {
|
||||
expect(s.shouldApplyInput(`oneshot-${i}`, 1)).toBe(true);
|
||||
}
|
||||
expect(s.shouldApplyInput('recent', 1)).toBe(true);
|
||||
expect(s.shouldApplyInput('recent', 1)).toBe(false);
|
||||
expect(s.shouldApplyInput('recent', 2)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -355,6 +355,43 @@ describe('session-routes', () => {
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('applies a tagged (clientId, seq) input exactly once on redelivery', async () => {
|
||||
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
|
||||
session.writeBuffer.length = 0;
|
||||
|
||||
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
|
||||
|
||||
// First delivery of seq 1 — applied (200, written once).
|
||||
const first = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
|
||||
expect(first.statusCode).toBe(200);
|
||||
|
||||
// Redelivery of the SAME seq (client never saw the ACK) — still 200, but
|
||||
// must NOT write again.
|
||||
const dup = await post({ input: 'prompt', seq: 1, clientId: 'cid-1' });
|
||||
expect(dup.statusCode).toBe(200);
|
||||
|
||||
// A genuinely new seq — applied.
|
||||
const next = await post({ input: '\r', seq: 2, clientId: 'cid-1' });
|
||||
expect(next.statusCode).toBe(200);
|
||||
|
||||
expect(session.writeBuffer).toEqual(['prompt', '\r']);
|
||||
});
|
||||
|
||||
it('always applies untagged input (curl/legacy, no dedup)', async () => {
|
||||
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
|
||||
session.writeBuffer.length = 0;
|
||||
|
||||
const post = () => harness.app.inject({ method: 'POST', url, payload: { input: 'x' } });
|
||||
await post();
|
||||
await post();
|
||||
// No seq/clientId ⇒ no dedup ⇒ both writes land.
|
||||
expect(session.writeBuffer).toEqual(['x', 'x']);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/resize ==========
|
||||
|
||||
Reference in New Issue
Block a user