fix(terminal): four silent-failure paths — renderer freeze, replay race, reconnect gap, unbounded fetches - #431
rounakdatta wants to merge 2 commits into
Conversation
…es, 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>
|
Thanks for this, and for the forensics write-up that goes with it. Four terminal silent-failure paths plus the service-worker precache is a lot of careful reading, and the replay-clear item in particular fixes a real corruption path nothing else here covered. One thing worth saying up front: I verified the piece you flagged as most wanting eyes. Driving the repo's own xterm 6.0.0 under jsdom, Two things need a change before this lands. 1. The fetch deadline only covers time-to-headers (src/web/public/app.js:2543). 2. The precache still never gets used (src/web/public/sw.js:56). Two smaller ones while you are in there. 3. 4. CLAUDE.md:337 says "all of them measured rather than reasoned", which your own "Not verified here" section contradicts. That line is load-bearing in this file: its value is that a reader can trust a claim without re-deriving it. Please say which half was measured (the replay race) and which is reasoned from xterm's source. You can now describe the watchdog's mechanism as verified against 6.0.0 given the check above; the iOS rAF-discard premise stays reasoned. While editing: the WebSocket reconcile and the sw.js/build.mjs contract get no CLAUDE.md line at all, and the sw contract in particular is the kind of build-time "keep these two in sync or the build throws" rule this file carries elsewhere. Nits, take or leave: One process note for next time: this is six changes in one PR, and the service worker plus build script is a different subsystem from the four terminal fixes, with the only build-breaking risk in the set. Splitting that out would have let the terminal work land on its own timeline. Everything else is green here: typecheck, lint, format, frontend-syntax, public-assets, and the full gate (386 files, 7273 tests, 546s). The cron-service failure you saw locally does not reproduce on my machine. Send the fixes for 1 and 2 (plus 3 and 4 if you have the appetite) and I will merge. I would still like a real-device pass on the watchdog before the release that carries it, as you suggested. |
… cache-bust query
Review fixes. Two of these are defects in the previous commit.
1. The fetch deadline only covered time-to-headers. `await fetch()` settles on
response headers, so clearing the abort timer in a finally around it left the
body — the multi-megabyte `?full=1` capture the deadline exists for —
completely unbounded; it only ever bounded a server that accepts a connection
and never replies. Measured against a server that sends headers immediately
and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, timer
cleared there, body completed at 4026ms unaborted. Now the body is read
inside `_fetchTerminalCapture`, which returns {json, headers, headersAt} —
headers because two callers read server-timing, headersAt because those same
callers measure header-vs-body time and can no longer observe that moment.
`_terminalCaptureInflight` is scoped the same way, so a body still streaming
counts toward a capture starting beside it. Same test now aborts at 1005ms.
2. The precache could never be hit, and the previous commit made that expensive
rather than free. `renderIndexHtml` runs `cacheBustAssets`, which appends
`?v=<mtime>` to every same-origin .js/.css reference INCLUDING content-hashed
names — confirmed against a running instance:
`vendor/xterm-zerolag-input.6fee72f2.js?v=1789402869101`. `caches.match` is
query-sensitive, so entries keyed on the bare hashed path were unreachable;
deriving the list from the manifest turned cheap 404s into ~1.3MB downloaded
at every install that nothing could read back, once per deploy now that
CACHE_NAME rotates. The fallback match takes `{ ignoreSearch: true }`, which
also lets runtime-cached entries survive an mtime change.
3. `_wsOutputGapSession` was only cleared in ws.onopen, so paths that already
repaint the buffer left it set and the socket replayed everything a second
time. `selectSession` loads the buffer and only THEN calls `_connectWs`, so
neither the _isLoadingBuffer nor the _terminalRefreshOwner guard applied.
`_markTerminalBufferReconciled()` is now called from _onSessionNeedsRefresh's
finally, from selectSession after its load, and from _cleanupSessionData.
The scope claim was also wrong and is corrected in the comment: when the
network drops, SSE drops with it and handleInit's keepTerminal branch already
reconciles. The genuinely uncovered case is the WS dying while SSE stays up,
where _onSSETerminal discards SSE terminal frames until _wsReady flips in
onclose — up to the ping+pong window of output nothing writes.
4. CLAUDE.md said "all of them measured rather than reasoned", which the PR's
own "not verified" section contradicted. Split explicitly: the replay race is
measured, the watchdog mechanism is verified against xterm 6.0.0 under jsdom
(field path resolves, a forced stale handle makes refreshRows a no-op, the
kick schedules a fresh frame), and the iOS rAF-discard premise is reasoned
and still wants a device. Adds the two missing entries — the WebSocket
reconcile and the sw.js/build.mjs "keep these in sync or the build throws"
contract.
Also: test/xterm-private-api.test.ts pins the RESOLVED lockfile version instead
of the declared `^6.0.0` range, which was the wrong assertion in both directions
— a real upgrade to 6.4.0 can rename a private field while resolving inside the
range, and an innocuous range edit failed while changing nothing installed. And
test/sw-precache-manifest.test.ts now parses HASHABLE out of scripts/build.mjs
rather than hand-copying it, which was the same drift this PR exists to fix; the
parse is guarded against silently matching nothing.
The deadline fix has a behavioural test against a real socket plus a source
guard asserting `await res.json()` precedes the finally — verified to fail when
the helper is reverted to the old shape, so it is not vacuous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thank you for this — particularly for verifying the field path and the heal under jsdom. That covers the half I flagged and couldn't reach, and it's the reason I'm comfortable with the watchdog now. All four are addressed in d200c0e, plus both nits. Two of them were defects in my previous commit and one was a regression I introduced, so taking each in turn. 1. Deadline only covered time-to-headers. You're right, and I reproduced your measurement before fixing it: headers at 30ms, timer cleared there, body completed at 4026ms unaborted under a 1000ms deadline. So the item didn't do the thing it was written for — it only bounded a server that accepts a connection and never replies.
It has a behavioural test against a real socket, plus a source guard asserting 2. Precache unreachable. Confirmed on the running instance — 3. Marker only cleared in Your second point there is the more important one: the PR description was wrong, not just imprecise. SSE drops with the network and 4. CLAUDE.md overstated. Fair, and it's the failure mode that file exists to prevent. Now split explicitly: the replay race is measured, the watchdog mechanism is verified against 6.0.0 (credited to your check), the iOS rAF-discard premise is reasoned and still wants a device. Added the two missing entries — the WebSocket reconcile, and the sw.js/build.mjs contract as a build-time "keep these in sync or the build throws" rule. Nits, both taken. The range assertion was wrong in both directions, exactly as you say — 6.4.0 can rename a private field while resolving inside On the split. Agreed. The sw/build work is a different subsystem and carries the only build-breaking risk in the set, and separating it would have let the terminal fixes move independently. Happy to split it now if you'd prefer — say the word and I'll put the sw/build commit on its own branch and rebase this one down to the four terminal fixes. Gate here: 7276 passed, 1 skipped, and the same Still no browser my end, so the real-device pass on the watchdog is still worth doing before the release that carries it, as you said. |
Four fixes for ways the mobile terminal can silently stop being correct — where "silently" is the operative word: in every case the buffer keeps updating, nothing throws, and the user's only recourse is a reload.
They came out of reading Big-Pony/pocketshell, a mobile-first terminal that has done unusually deep forensics on xterm's failure modes. Each item below was re-verified against Codeman's own source before being written — several things that repo warns about turned out to be already handled here, and those are not in this PR.
1. Renderer freeze after backgrounding (
terminal-ui.js)iOS discards scheduled
requestAnimationFramecallbacks when a PWA goes to the background — not deferred, never delivered. xterm'sRenderDebounceronly clears its_animationFramehandle from inside that callback:One dropped callback leaves the handle permanently set, so every later render request returns on line one. Parsing is decoupled from rendering, so bytes keep filling the buffer correctly and nothing errors — the terminal is simply frozen. Closing and reopening fixes it because that constructs a new
Terminal.Codeman is more exposed than an app that mounts a terminal per session: there is exactly one xterm instance for the whole page load, so a single backgrounding can wedge it until a reload.
_startRenderLivenessWatchdog()polls every 2s and, when a visible terminal has been written to but has produced no frame for 4s,_kickRenderer()does what the dropped_innerRefreshwould have: cancel the stale handle, clear the field, force a repaint._renderServiceonly exists afteropen()(needs a real DOM), so the gate can't assert the field path.test/xterm-private-api.test.tsnow pins the resolved lockfile version rather than the declared^6.0.0range — the range was the wrong assertion in both directions, since a real upgrade to 6.4.0 could rename a private field while resolving inside it, and an innocuous range edit would fail while changing nothing installed.Thank you for independently verifying the field path and the heal against 6.0.0 under jsdom — that covers the half of this I couldn't reach. The remaining unverified half is the premise: that iOS discards scheduled rAF callbacks on backgrounding, which is what leaves the handle stale. That still wants a real device.
2. Replay clears race live output (
app.js)xterm's
write()is asynchronously queued;Terminal.reset()is synchronous and, per upstream's own docs, "does not clear input buffers and does not reset the parser, thus the terminal will continue to apply pending input data." Bytes queued just before a reset are parsed after it and fuse into the snapshot written next.Reproduced against the real xterm 6 in this repo:
_resetTerminalForReplay()already got this right by following the syncreset()with a queued\x1b[3J\x1b[H\x1b[2J. Two other paths — theneedsRefreshreload and theclearTerminalrefresh — hand-rolledclear()+reset()with no in-stream erase and were genuinely exposed.All three now go through one function, which is a single queued
\x1bc(RIS). RIS rather than the erase because3J/H/2Jleaves modes, charsets, scroll regions and SGR state alone, so leftover bytes can park the terminal in alt-screen and survive the clear. Callers can still chunk the content — ordering in the queue is what matters, not writing it in one call.3. Output lost when the WebSocket dies but SSE stays up (
app.js)The terminal WS protocol is asymmetric in a way that's easy to miss, because one half is excellent. Input frames carry
seq+cid, the server applies each pair at-most-once and ACKs, and the client holds a durable queue until the ACK lands. Output frames ({"t":"o","d":...}) carry nothing, andws.onopenre-sends dimensions and flushes queued input only.I traced every emitter of
needsRefresh, the one server signal that could cover the gap — there are two: external-CLI startup 3s after spawn (session.ts), and SSE backpressure drain (sse-stream-manager.ts). Neither fires on a WS reconnect.Scope correction from review: an earlier draft of this described the gap as "output produced while a phone is off-network." That was wrong. If the network drops, SSE drops with it, and
handleInit'skeepTerminalbranch already calls_onSessionNeedsRefreshfor exactly this reason. The genuinely uncovered case is narrower: the WS dying while SSE stays up — a half-open socket, a proxy idle-timeout, a ping timeout._onSSETerminaldiscards every SSE terminal frame while_wsReadyis true, and_wsReadyonly flips inws.onclose, so detecting a half-open socket takes up to the ping+pong window and that whole span produces output nothing writes to the terminal.Still worth closing, and this is the interim fix rather than the real one: reaching
oncloseat all means the drop was unintentional (_disconnectWsnulls the handler first), so the session is marked and the next successful open reconciles from the server's buffer. Sequencing the output frames properly is the follow-up._markTerminalBufferReconciled()clears the marker from every path that repaints that session's buffer —_onSessionNeedsRefresh'sfinally,selectSessionafter its load, and_cleanupSessionData. Without that,selectSession(which loads the buffer and only then calls_connectWs) would have the socket replay the whole buffer a second time on top of the one just written.4. Terminal captures had no deadline (
app.js)No fetch in the frontend carried a timeout — no
AbortController, noAbortSignal— including?full=1, which_maybeRefetchFullHistoryitself describes as "unbounded-ish work: at the default history limit it can be megabytes."Corrected from review: the first version of this cleared its abort timer in a
finallyaroundawait fetch(url)— which settles on response headers, not the body. So it only ever bounded a server that accepts a connection and never replies; the multi-megabyte body it was written for stayed unbounded. Reproduced against a server that sends headers immediately and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, the timer cleared there, body completed at 4026ms unaborted._fetchTerminalCapturenow reads the body inside the helper and returns{json, headers, headersAt}—headersbecause two callers readserver-timing,headersAtbecause those same callers measure header-vs-body time and can no longer observe that moment themselves._terminalCaptureInflightis scoped the same way, so a body still streaming counts toward the budget of a capture starting beside it. Same test now aborts at 1005ms.The budget scales with full-vs-tail and with captures already in flight, and degrades to a plain fetch where
AbortControlleris missing — the deadline is a safety net, not a dependency.Also included
Service worker precache was dead, and my first fix made it worse. The build content-hashes assets and rewrites
index.html, butsw.jswas maintained by hand with the pre-hash names, so every entry 404'd andcache.add(...).catch(() => {})hid it (15 of 23, verified against a running instance). Offline still worked, because the fetch handler caches every successful GET at runtime.Corrected from review: deriving the list from the manifest was not sufficient.
renderIndexHtmlrunscacheBustAssets, which appends?v=<mtime>to every same-origin.js/.cssreference including content-hashed names — confirmed on a live instance:vendor/xterm-zerolag-input.6fee72f2.js?v=1789402869101.caches.matchis query-sensitive, so a precache keyed on the bare path could never be hit, and my change turned entries that failed cheaply into ~1.3MB downloaded at every install that nothing could ever read back — once per deploy, sinceCACHE_NAMEnow rotates. The fallback match now passes{ ignoreSearch: true }, which also lets runtime-cached entries survive an mtime change. Pinned by a test, since it is subtle enough to be tidied away.The per-build
CACHE_NAMEstands on its own:activatedeletes every cache that is not the current one, so the old constant'codeman-v1'meant that cleanup never ran and assets from every past release accumulated forever.Crash-trail hygiene. Entries are joined with
\ninto one localStorage value and beaconed, and at least one call site interpolates a server-controlled WS closereason— a newline there forges entries. Now flattened and length-capped.Testing
cron-service.test.ts"blocks a sensitive system file via the blocklist", which fails identically on cleanorigin/masterin my sandbox and — per your run — does not reproduce on yours. Environmental, not a regression.npm run typecheck,npm run lint,npm run format:check,npm run check:frontend-syntax,npm run check:public-assets— all green.test/terminal-resilience.test.ts,test/sw-precache-manifest.test.ts,test/xterm-private-api.test.ts. All run in the gate, which is deliberate given the mobile suite is excluded from CI.await res.json()runs before thefinally— I verified that guard fails when the helper is reverted to the old shape, so it is not vacuous.test/sw-precache-manifest.test.tsnow parsesHASHABLEout ofscripts/build.mjsinstead of hand-copying it. You were right that the copy was the same drift this PR exists to fix; the parse is guarded against silently matching nothing.Not verified here
Still no browser on my machine, so no runtime verification from my side. With your jsdom check of the field path and the heal, what remains unverified is the premise of §1 — that iOS discards scheduled rAF callbacks on backgrounding — and the §3 reconnect path, which is traced through code rather than observed on a device. Both are worth the real-device pass you suggested before the release that carries this.
On the split
Agreed, and noted for next time — the service worker and build script are a different subsystem from the four terminal fixes, and the only build-breaking risk in the set. If you would still prefer them separated before merging, say the word and I will split the sw/build commit onto its own branch and rebase this one to the terminal work alone.
🤖 Generated with Claude Code