Files
Codeman/test/terminal-resilience.test.ts
T
Rounak DattaandClaude Opus 5 c0422c4e21 feat(terminal): renderer watchdog, atomic replay clear, fetch deadlines, reconnect recovery
Four ways the terminal can silently stop being correct — in each case the
buffer keeps updating, nothing throws, and the only recourse is a reload.

1. Renderer freeze after backgrounding. iOS DISCARDS scheduled rAF callbacks
   when a PWA backgrounds, and xterm's RenderDebouncer only clears its
   `_animationFrame` handle from inside that callback — so one drop leaves it
   permanently set and every later refresh() early-returns. Parsing is
   decoupled from rendering, so bytes keep filling the buffer correctly while
   nothing paints. Codeman has exactly ONE xterm for the whole page load, so a
   single backgrounding wedges it until a reload. Adds a 2s liveness poll and
   `_kickRenderer()`, which does what the dropped `_innerRefresh` would have.

2. Replay clears raced live output. xterm's write() is async-queued while
   reset() is synchronous and, per upstream, "does not clear input buffers and
   does not reset the parser" — so bytes queued before a reset are parsed after
   it and fuse into the snapshot. Verified against the real xterm 6 here:
   write('p8'); reset(); write('rmissions') renders "p8rmissions". The main
   path was already safe via a queued erase; the needsRefresh and clearTerminal
   paths were not. All three now share one queued `\x1bc` (RIS), which unlike
   3J/H/2J also resets modes, charsets, scroll regions and SGR state.

3. Output lost on WebSocket reconnect. Input frames carry seq+cid and are
   delivered exactly once; output frames carry nothing. ws.onopen re-sends dims
   and flushes queued input, and needsRefresh only fires on external-CLI
   startup and SSE backpressure drain — never on reconnect. Output produced
   while offline was simply absent afterwards. Interim fix: reaching onclose
   means the drop was unintentional, so the session is marked and the next open
   reconciles from the server buffer. Sequencing output is the follow-up.

4. Terminal captures had no deadline. No AbortController anywhere in the
   frontend, including `?full=1`, which the code itself calls "unbounded-ish
   work: at the default history limit it can be megabytes". Adds a budget that
   scales with full-vs-tail and with captures in flight, degrading to a plain
   fetch where AbortController is missing.

Also: the service-worker precache was dead — the build content-hashes assets
but sw.js listed pre-hash names, so 15 of 23 entries 404'd (verified against a
running instance) and cache.add().catch() hid it. Offline still worked via
runtime caching, but CACHE_NAME was a constant so activate's cleanup never
deleted anything and every past release's assets accumulated. Both are now
derived from the build manifest. Crash-trail entries are flattened and capped,
since they are joined with \n into one value and one call site interpolates a
server-controlled WS close reason.

The watchdog reads xterm privates — there is no public API. Every access is
optional-chained so a shape change degrades to a no-op. `_renderService` only
exists after open(), which needs a real DOM, so the gate cannot assert the
field path; test/xterm-private-api.test.ts pins the dependency range instead.

Tests: 23 new (terminal-resilience, sw-precache-manifest, xterm-private-api),
all pure/static so they run in the gate, which excludes the mobile suite. One
static source guard in history-truncation-notice updated for the renamed call;
the behaviour it pins is unchanged.

Not verified: no browser available, so no runtime reproduction of the freeze
and no real-device test of the reconnect path. Both warrant a device pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 12:24:52 +05:30

155 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Port: none (pure helpers — no browser, no server).
//
// Three small decision functions behind the mobile terminal resilience work,
// pinned here because the code that consumes them lives in app.js /
// terminal-ui.js, which the CI gate cannot execute. Keeping the decision pure
// and the DOM half thin is what makes any of this testable without a browser.
//
// The renderer-liveness case is the one worth reading. iOS DISCARDS scheduled
// requestAnimationFrame callbacks when a PWA backgrounds — never delivered, not
// deferred — and xterm's RenderDebouncer only clears its `_animationFrame`
// handle from inside that callback. One drop leaves the handle permanently set,
// so every later refresh() returns immediately and the terminal freezes while
// its buffer keeps updating correctly. Codeman has exactly one xterm instance
// per page load, so a single backgrounding can wedge it until a reload.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
function loadConstants() {
const context = vm.createContext({ window: {}, globalThis: {} });
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
vm.runInContext(source, context, { filename: 'constants.js' });
const w = context.window as {
CodemanRenderLiveness: {
shouldKickRenderer: (s: {
wroteAt: number;
renderedAt: number;
now: number;
visible: boolean;
thresholdMs?: number;
}) => boolean;
RENDER_STALL_MS: number;
RENDER_LIVENESS_POLL_MS: number;
};
CodemanFetchDeadline: {
terminalFetchDeadlineMs: (s: { full?: boolean; inflight?: number }) => number;
FETCH_DEADLINE_TAIL_MS: number;
FETCH_DEADLINE_FULL_MS: number;
FETCH_DEADLINE_MAX_MS: number;
};
CodemanDiag: {
sanitizeDiagEntry: (msg: unknown) => string;
DIAG_ENTRY_MAX_CHARS: number;
};
};
return w;
}
describe('shouldKickRenderer', () => {
const { CodemanRenderLiveness } = loadConstants();
const { shouldKickRenderer, RENDER_STALL_MS } = CodemanRenderLiveness;
// The signature of the real failure: bytes were written, the element is
// visible, and no frame has been produced since.
it('kicks when a visible terminal owes a frame past the threshold', () => {
expect(shouldKickRenderer({ wroteAt: 1000, renderedAt: 500, now: 1000 + RENDER_STALL_MS, visible: true })).toBe(
true
);
});
it('does not kick before the threshold elapses', () => {
expect(shouldKickRenderer({ wroteAt: 1000, renderedAt: 500, now: 1000 + RENDER_STALL_MS - 1, visible: true })).toBe(
false
);
});
// A render at or after the last write means the pipeline is alive. This is
// the common case on every healthy terminal and must never kick.
it('does not kick when a render landed after the last write', () => {
expect(shouldKickRenderer({ wroteAt: 1000, renderedAt: 1000, now: 99_999, visible: true })).toBe(false);
expect(shouldKickRenderer({ wroteAt: 1000, renderedAt: 1200, now: 99_999, visible: true })).toBe(false);
});
// A hidden terminal legitimately stops rendering — xterm pauses it. Kicking
// there would fire on every backgrounded tab, forever.
it('never kicks a hidden terminal', () => {
expect(shouldKickRenderer({ wroteAt: 1000, renderedAt: 500, now: 99_999, visible: false })).toBe(false);
});
// A quiet terminal is the normal state, not a stalled one. Gating on "no
// render recently" instead of "owes a frame" would kick every idle session.
it('never kicks a terminal that has never been written to', () => {
expect(shouldKickRenderer({ wroteAt: 0, renderedAt: 0, now: 99_999, visible: true })).toBe(false);
});
it('tolerates missing and malformed input rather than throwing', () => {
expect(shouldKickRenderer(undefined as never)).toBe(false);
expect(shouldKickRenderer({} as never)).toBe(false);
expect(shouldKickRenderer({ wroteAt: NaN, renderedAt: NaN, now: NaN, visible: true } as never)).toBe(false);
});
it('polls coarsely enough not to wake an idle phone every second', () => {
expect(CodemanRenderLiveness.RENDER_LIVENESS_POLL_MS).toBeGreaterThanOrEqual(1000);
});
});
describe('terminalFetchDeadlineMs', () => {
const { CodemanFetchDeadline } = loadConstants();
const { terminalFetchDeadlineMs, FETCH_DEADLINE_TAIL_MS, FETCH_DEADLINE_FULL_MS, FETCH_DEADLINE_MAX_MS } =
CodemanFetchDeadline;
// A full scrollback capture can be megabytes where a tail is one frame, so a
// single fixed timeout is wrong in both directions on a mobile link.
it('gives a full capture more budget than a tail', () => {
expect(terminalFetchDeadlineMs({ full: true })).toBeGreaterThan(terminalFetchDeadlineMs({ full: false }));
expect(terminalFetchDeadlineMs({ full: false })).toBe(FETCH_DEADLINE_TAIL_MS);
expect(terminalFetchDeadlineMs({ full: true })).toBe(FETCH_DEADLINE_FULL_MS);
});
// Eight tabs resuming must not all expire together because each assumed it
// had the link to itself.
it('scales with captures already in flight', () => {
const alone = terminalFetchDeadlineMs({ full: false, inflight: 0 });
const queued = terminalFetchDeadlineMs({ full: false, inflight: 3 });
expect(queued).toBeGreaterThan(alone);
});
it('is bounded — a stuck link still fails eventually', () => {
expect(terminalFetchDeadlineMs({ full: true, inflight: 1000 })).toBe(FETCH_DEADLINE_MAX_MS);
});
it('treats absent and nonsense input as a lone tail fetch', () => {
expect(terminalFetchDeadlineMs({})).toBe(FETCH_DEADLINE_TAIL_MS);
expect(terminalFetchDeadlineMs({ inflight: -5 } as never)).toBe(FETCH_DEADLINE_TAIL_MS);
expect(terminalFetchDeadlineMs({ inflight: NaN } as never)).toBe(FETCH_DEADLINE_TAIL_MS);
});
});
describe('sanitizeDiagEntry', () => {
const { CodemanDiag } = loadConstants();
const { sanitizeDiagEntry, DIAG_ENTRY_MAX_CHARS } = CodemanDiag;
// The crash trail is joined with '\n' into one localStorage value and
// beaconed, and at least one call site interpolates a WebSocket close
// `reason`, which the server controls. A newline there forges entries.
it('collapses every newline form so an entry cannot forge another', () => {
expect(sanitizeDiagEntry('WS CLOSE reason=a\nFAKE ENTRY')).toBe('WS CLOSE reason=a FAKE ENTRY');
expect(sanitizeDiagEntry('a\r\nb')).toBe('a b');
expect(sanitizeDiagEntry('a
b
c')).toBe('a b c');
});
it('bounds length so one entry cannot exhaust the storage quota', () => {
const out = sanitizeDiagEntry('x'.repeat(DIAG_ENTRY_MAX_CHARS * 3));
expect(out).toHaveLength(DIAG_ENTRY_MAX_CHARS);
});
it('never throws on the values a diagnostic call site can actually pass', () => {
expect(sanitizeDiagEntry(null)).toBe('');
expect(sanitizeDiagEntry(undefined)).toBe('');
expect(sanitizeDiagEntry(42)).toBe('42');
expect(sanitizeDiagEntry({ toString: () => 'obj' })).toBe('obj');
});
});