mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
70 lines
3.4 KiB
TypeScript
70 lines
3.4 KiB
TypeScript
// Port: none (dependency-range guard — no browser, no server).
|
|
//
|
|
// terminal-ui.js's `_kickRenderer` reaches into xterm internals to unwedge a
|
|
// frozen RenderDebouncer:
|
|
//
|
|
// terminal._core._renderService._renderDebouncer._animationFrame
|
|
// terminal._core._renderService.refreshRows(start, end)
|
|
//
|
|
// There is no public API for any of it — xterm exposes no way to ask "are you
|
|
// still producing frames" or "drop your stale animation handle" — and the bug
|
|
// it heals (iOS discarding a scheduled rAF, leaving that handle permanently set
|
|
// so every later refresh() early-returns) is otherwise unrecoverable without a
|
|
// page reload.
|
|
//
|
|
// That path CANNOT be asserted in this suite. `_renderService` is constructed
|
|
// by `Terminal.open()`, which needs a real DOM, and the CI gate runs in node —
|
|
// a headless Terminal reports `_renderService: undefined`, so a test here would
|
|
// pass whether or not the field still exists, which is worse than no test.
|
|
//
|
|
// So this guards the next best thing: the dependency range those field names
|
|
// were verified against. A major bump fails here, loudly, and sends someone to
|
|
// re-verify `_kickRenderer` by hand in a browser. The failure mode being
|
|
// defended against is silent — every access in `_kickRenderer` is
|
|
// optional-chained, so a renamed field degrades it to a permanent no-op with no
|
|
// error, no log, and a terminal that simply freezes again.
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const root = resolve(import.meta.dirname, '..');
|
|
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as {
|
|
dependencies: Record<string, string>;
|
|
};
|
|
const terminalUi = readFileSync(resolve(root, 'src/web/public/terminal-ui.js'), 'utf8');
|
|
|
|
// The major line `_kickRenderer`'s field path was verified against.
|
|
const VERIFIED_XTERM_RANGE = '^6.0.0';
|
|
|
|
describe('xterm private-API dependency guard', () => {
|
|
it('pins the xterm range _kickRenderer was verified against', () => {
|
|
expect(
|
|
pkg.dependencies['@xterm/xterm'],
|
|
'xterm moved off the verified range — re-verify _kickRenderer in a real browser ' +
|
|
'(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame), then update ' +
|
|
'VERIFIED_XTERM_RANGE here. The accessor is optional-chained, so a renamed field ' +
|
|
'degrades to a silent no-op and the freeze it heals comes back unnoticed.'
|
|
).toBe(VERIFIED_XTERM_RANGE);
|
|
});
|
|
|
|
// If someone deletes the watchdog, this guard is pointless noise — keep the
|
|
// two tied together so the range check cannot outlive what it protects.
|
|
it('is guarding a watchdog that still exists', () => {
|
|
expect(terminalUi).toContain('_kickRenderer()');
|
|
expect(terminalUi).toContain('_renderDebouncer');
|
|
expect(terminalUi).toContain('_animationFrame');
|
|
});
|
|
|
|
// Every private read must stay optional-chained. This is the property that
|
|
// makes reaching into internals acceptable at all: upstream can rename
|
|
// anything and the worst case is that healing stops, never that the terminal
|
|
// throws on a timer every two seconds.
|
|
it('reads every private field defensively', () => {
|
|
expect(terminalUi).toContain('this.terminal?._core?._renderService');
|
|
const body = terminalUi.slice(terminalUi.indexOf('_kickRenderer() {'));
|
|
const fn = body.slice(0, body.indexOf('\n },'));
|
|
expect(fn).toContain('try {');
|
|
expect(fn).toContain('catch');
|
|
});
|
|
});
|