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>
This commit is contained in:
Rounak Datta
2026-09-22 12:24:52 +05:30
co-authored by Claude Opus 5
parent 9466acfc1a
commit c0422c4e21
10 changed files with 743 additions and 31 deletions
+154
View File
@@ -0,0 +1,154 @@
// 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');
});
});