Files
Codeman/test/reliable-input-recovery.test.ts
T
Codeman maintainer ead3d34411 refactor(terminal): seams for a second terminal pane (input socket map, targeted links, copy, paste)
No behavior change. Prepares the split pane's second terminal (and later
grid tiles) to share what today only the primary terminal has:

- _inputSocketFor/_registerInputSocket/_unregisterInputSocket: the
  exactly-once input queue, its ACK handling and the redelivery sweep now
  deliver over any registered socket bound to a session, not only
  this._ws. ACKs are routed by the receiving socket's session; silence is
  judged per socket; a stale handle cannot unregister its replacement.
- registerFilePathLinkProvider, cleanedTerminalSelection,
  copyTerminalSelection and _handleImagePaste take an optional target
  terminal and session (defaults: the primary pane).
- _focusedPane() is the one place to ask which pane the keyboard is in
  (primary only, for now); _forEachTile() replaces the _splitPane special
  cases in the font, family, weight, skin and resize paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:55:05 +02:00

79 lines
3.8 KiB
TypeScript

/**
* @fileoverview A client whose seq counter rolled back must heal itself.
*
* The failure: the browser tags input with (clientId, seq) and persists the
* counters to localStorage on a DEBOUNCED write. A tab killed between a send and
* that write comes back counting from BELOW the server's watermark, so every
* later keystroke is rejected as a duplicate — and, because a rejected frame was
* ACKed exactly like an applied one, the client dropped it from its queue and the
* UI looked perfectly healthy while the terminal took no input at all. Reloading
* could not help: clientId and the stale counter both come back from localStorage.
*
* Observed live on a server session: a fresh browser (new clientId, no watermark)
* typed into the same session fine, which is what isolated it to client state.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const wsSource = readFileSync(resolve(import.meta.dirname, '../src/web/routes/ws-routes.ts'), 'utf8');
describe('the duplicate ACK carries what the client needs', () => {
it('marks a rejected frame as dup and reports the watermark', () => {
// A bare ACK is indistinguishable from "applied" — that ambiguity is the bug.
expect(wsSource).toMatch(/"dup":true,"last":\$\{watermark\}/);
expect(wsSource).toContain('lastInputSeq');
});
it('reads the watermark defensively, so a port without it still ACKs', () => {
// The session arrives through a structural port. A throw inside the message
// handler aborts it before the ACK is sent, stranding the frame in the
// client's durable queue — which is worse than the ambiguity being fixed here.
expect(wsSource).toMatch(/typeof \(session as \{ lastInputSeq\?/);
});
it('still ACKs a rejected frame, so the client can drop it from its queue', () => {
// Silence would strand the record and the redelivery sweep would spin on it.
const block = wsSource.slice(wsSource.indexOf('if (seq !== null && socket.readyState === 1)'));
expect(block.slice(0, 1200)).toContain('"t":"ia"');
});
});
describe('the client lifts itself over the watermark', () => {
const handler = appSource.slice(
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
appSource.indexOf('/** Called from ws.onopen')
);
it('raises the counter to the watermark it was handed', () => {
expect(handler).toMatch(/_seqCounters\.set\(sessionId, watermark\)/);
});
it('re-queues a FIRST-attempt frame, whose input was genuinely lost', () => {
expect(handler).toMatch(/rec\.tries <= 1/);
expect(handler).toMatch(/this\._reliableSend\(sessionId, lost/);
});
it('does NOT re-queue a retry, which the dedup correctly suppressed', () => {
// A retry called a duplicate means the original DID land; re-sending it would
// type the same thing twice — the exact thing exactly-once delivery prevents.
expect(handler).toMatch(/const lost = rec && rec\.tries <= 1 \? rec\.data : null;/);
});
it('persists the raised counter immediately, not on the debounce', () => {
expect(handler).toContain('this._persistReliableNow()');
});
});
describe('the seq counter is persisted synchronously on every send', () => {
it('_reliableSend uses the immediate writer, never the debounced one', () => {
// The counter is precisely what must survive a crash, so it cannot ride the
// path most likely to be lost. (The queue PAYLOAD may still be debounced.)
const send = appSource.slice(appSource.indexOf('_reliableSend(sessionId, data, useMux)'));
const body = send.slice(0, send.indexOf('_nextSeq(sessionId) {'));
expect(body).toContain('this._persistReliableNow();');
expect(body).not.toMatch(/list\.push\(rec\);\s*\n\s*this\._persistReliableState\(\);/);
});
});