mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(terminal): deadline must cover the body, precache must ignore the 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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c0422c4e21
commit
abd39318e6
@@ -346,7 +346,11 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Mobile prompt composer** (PR #444, the first slice of #359, `keyboard-accessory.js`): the agent bars' Paste key is now **Compose**, a dialog with a native multiline textarea (autocorrect, autocapitalize, spellcheck) where Enter adds a line and only **Send** submits; the shell bar keeps the direct Paste dialog, since shell input is not an agent prompt. Opening it ADOPTS the whole editable terminal prompt (`_takePendingLocalEcho`): the local-echo overlay's pending text has never reached the PTY, but the flushed prefix has, so that prefix is erased with backspaces counted in CODE POINTS (`Array.from(text).length`; measured on Claude Code 2.1.278, `a` + emoji + `b` takes three, and the UTF-16 count sent four and ate the neighbour; `clearTerminalInput()` in terminal-ui.js moved with it). ⚠️ **Drafts are per-session and in memory only** (`_composerDrafts`, never persisted: prompts routinely carry secrets, and persisting them would need the 0600 treatment the intent store gets). Every non-Send exit (Cancel, backdrop, Escape, "Use terminal keyboard") leaves the taken text ONLY in the draft, with the dot on the key (`has-draft`, kept in step by `_syncComposerDraftIndicator`) as the signal that the terminal prompt is empty on purpose, and `_cleanupSessionData` discards the draft with the session. ⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`). ⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them. ⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast. ⚠️ `.prompt-composer-overlay` is a `.paste-overlay` with a gutter of its own (a `padding` shorthand whose bottom is 12px plus the safe area), and the unconditional `.paste-overlay` fold rule at the end of styles.css is a later longhand at the same specificity, so it ERASED that gutter (measured at 393x852: `padding-bottom: 0px` flat, and the hinge strip REPLACING the gutter with the fold variables set): the composer has its own restatement after the fold rules, the dialog's `max-height` subtracts `--fold-block-end`, and `test/foldable-layout.test.ts` lists the composer in `ELEMENTS` by hand, because its derived overlay list keys on rules that declare `position: fixed; inset: 0` themselves. Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not under `test/mobile/**`).
|
||||
|
||||
**Terminal resilience: replay clears, renderer liveness, fetch deadlines**: three rules that each close a way the terminal silently stops being correct, all of them measured rather than reasoned. ⚠️ **A replay clear MUST be in-stream, never `reset()`/`clear()`.** xterm's `write()` is asynchronously queued while `Terminal.reset()` is synchronous and, per upstream, "does not clear input buffers and does not reset the parser" — so 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: `write('p8'); reset(); write('rmissions')` renders `p8rmissions`. `_resetTerminalForReplay()` (app.js) is the ONE clear, a single queued `\x1bc` (RIS), and all three replay paths go through it; RIS rather than `\x1b[3J\x1b[H\x1b[2J` because the erase leaves modes, charsets, scroll regions and SGR state alone, so leftover bytes can park the terminal in alt-screen and survive it. Callers may still chunk the content — ordering in the queue is what matters, not writing it in one call. ⚠️ **The renderer watchdog reaches into xterm privates and CANNOT be covered by the gate.** iOS discards scheduled rAF callbacks when a PWA backgrounds, and xterm's `RenderDebouncer` only clears `_animationFrame` from inside that callback — one drop leaves it permanently set and every later `refresh()` early-returns, so the buffer keeps updating correctly while nothing paints. Codeman has exactly ONE xterm for the whole page load, so a single backgrounding wedges it until a reload. `_kickRenderer()` (terminal-ui.js) cancels the stale handle and forces a repaint; `_renderService` only exists after `open()`, which needs a real DOM, so `test/xterm-private-api.test.ts` pins the dependency RANGE instead and a major bump means re-verifying the field path by hand in a browser. Every access is optional-chained on purpose: a renamed field must degrade to a no-op, never throw on a 2s timer. ⚠️ **Every terminal capture carries a deadline** (`_fetchTerminalCapture`, app.js). A `?full=1` body can be megabytes and used to hang on the browser default with no retry; the budget scales with full-vs-tail and with captures already in flight, so several tabs resuming do not all expire together. It degrades to a plain fetch where `AbortController` is missing — the deadline is a safety net, not a dependency. Tests: `test/terminal-resilience.test.ts` (pure decisions), `test/sw-precache-manifest.test.ts`, `test/xterm-private-api.test.ts`.
|
||||
**Terminal resilience: replay clears, renderer liveness, fetch deadlines**: three rules that each close a way the terminal silently stops being correct. ⚠️ **A replay clear MUST be in-stream, never `reset()`/`clear()`.** xterm's `write()` is asynchronously queued while `Terminal.reset()` is synchronous and, per upstream, "does not clear input buffers and does not reset the parser" — so bytes queued just before a reset are parsed AFTER it and fuse into the snapshot written next. **Measured** against the real xterm in this repo: `write('p8'); reset(); write('rmissions')` renders `p8rmissions`; the queued `\x1bc` renders `rmissions` and clears scrollback. `_resetTerminalForReplay()` (app.js) is the ONE clear, a single queued `\x1bc` (RIS), and all three replay paths go through it; RIS rather than `\x1b[3J\x1b[H\x1b[2J` because the erase leaves modes, charsets, scroll regions and SGR state alone. Callers may still chunk the content — ordering in the queue is what matters, not writing it in one call. ⚠️ **The renderer watchdog reads xterm privates and CANNOT be covered by the gate.** `_kickRenderer()` (terminal-ui.js) cancels a stale `_core._renderService._renderDebouncer._animationFrame` and forces a repaint. **Verified against xterm 6.0.0** (jsdom, after `open()`): the field path resolves, a forced stale handle genuinely makes `refreshRows` a no-op, and the kick schedules a fresh frame. **Reasoned, not reproduced here**: the premise that iOS discards scheduled rAF callbacks when a PWA backgrounds, which is what leaves the handle stale — that half wants a real-device pass. Codeman has exactly ONE xterm for the whole page load, so one backgrounding would wedge it until a reload. `_renderService` only exists after `open()`, which needs a real DOM, and the gate runs in node — so `test/xterm-private-api.test.ts` pins the RESOLVED lockfile version (not the `^6.0.0` range, which a real upgrade slips through) and a bump means re-verifying by hand. Every access is optional-chained on purpose: a renamed field must degrade to a no-op, never throw on a 2s timer. ⚠️ **Every terminal capture carries a deadline, and the helper reads the BODY** (`_fetchTerminalCapture`, app.js). `await fetch()` settles on response HEADERS, so clearing the timer there leaves the body — the multi-megabyte `?full=1` capture this exists for — unbounded: **measured** at 4026ms under a 1000ms deadline before the fix. The helper therefore returns `{json, headers, headersAt}` rather than a `Response`, and `_terminalCaptureInflight` is scoped the same way so a body still streaming counts toward a capture starting beside it. It degrades to a plain fetch where `AbortController` is missing — the deadline is a safety net, not a dependency. Tests: `test/terminal-resilience.test.ts` (pure decisions), `test/xterm-private-api.test.ts`.
|
||||
|
||||
**WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): terminal OUTPUT frames carry no sequence number (input frames do — `seq`+`cid`, at-most-once, ACKed), so a dropped socket leaves a hole nothing replays. ⚠️ **The gap is narrower than "the device went offline"**: if the network drops, SSE drops with it and `handleInit`'s keepTerminal branch already calls `_onSessionNeedsRefresh`. The uncovered case is the WS dying while SSE stays up (half-open socket, proxy idle-timeout, ping timeout), because `_onSSETerminal` discards every SSE terminal frame while `_wsReady` is true and `_wsReady` only flips in `ws.onclose`. Reaching `onclose` at all means the drop was unintentional (`_disconnectWs` nulls the handler first), so the session is marked and the next successful open reconciles. ⚠️ **The marker must be cleared by EVERY path that repaints that session's buffer** — `_markTerminalBufferReconciled()` is called from `_onSessionNeedsRefresh`'s finally, from `selectSession` after its load, and from `_cleanupSessionData`. `selectSession` loads the buffer and only THEN calls `_connectWs`, so without that clear the socket opening afterwards replays the whole buffer a second time on top of the one just written. Sequencing the output frames is the real fix and is not done. This is reasoned from the code path, not observed on a device.
|
||||
|
||||
**Service worker: precache and cache key are BUILD-GENERATED** (`sw.js` + `scripts/build.mjs`): the build content-hashes assets and rewrites two exact declarations in `sw.js` — `const BUILD_ID = 'dev';` and `const HASHED_ASSETS = [];`. ⚠️ **Each must appear exactly once or the build THROWS**, which is deliberate: the list used to be hand-maintained with PRE-hash names, so every entry 404'd in production and `cache.add().catch(() => {})` hid it (15 of 23 verified failing against a running instance). The dev literals are valid on their own, so dev serves an unrewritten worker with an empty precache. ⚠️ **`caches.match` must pass `ignoreSearch: true`**: `renderIndexHtml` runs `cacheBustAssets`, which appends `?v=<mtime>` to every same-origin `.js`/`.css` reference INCLUDING content-hashed names, so the page requests `/app.<hash>.js?v=<mtime>` while the cache holds `/app.<hash>.js`. Without it no precached entry is reachable and the install downloads ~1.3MB that can never be served — once per deploy, since `CACHE_NAME` now carries the build id. That per-build key is what makes `activate`'s cleanup actually delete anything; it used to be the constant `'codeman-v1'`, so assets from every past release accumulated forever. Contract pinned by `test/sw-precache-manifest.test.ts`, which PARSES the `HASHABLE` list out of `build.mjs` rather than copying it.
|
||||
|
||||
**Dismissing the on-screen keyboard** (PRs #279/#280, `terminal-ui.js`): the terminal parks focus on a hidden textarea that nothing used to release, so TWO gestures now blur it, and they own different regions. **(1)** `_installMobileKeyboardDismiss()` — a document-level `touchend` that fires only while the terminal input actually holds focus, **never inside `#terminalContainer`** (tap classification owns that) and **never on a control** (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, matched with `closest()` so an icon inside a button counts). Session tabs are covered by the selector's `[tabindex]:not([tabindex="-1"])` arm, which is what stops a tab tap from blurring and then being re-focused by `selectSession()`. **(2)** In `_handleMobileTerminalTap`, a second tap on **inert `content`** (`startedWithTerminalFocus`) blurs instead of re-focusing. ⚠️ Scoped to `content` on purpose: the prompt row (`input`) keeps focus-then-position so a second tap still places the caret, and actionable rows blur earlier via `_isActionableMobileTerminalTap`. ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. ⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks.
|
||||
|
||||
|
||||
+77
-25
@@ -2584,13 +2584,28 @@ class CodemanApp {
|
||||
* uplink legitimately needs longer than a tail, and eight tabs resuming must
|
||||
* not all expire together because each assumed it had the link to itself.
|
||||
*
|
||||
* An abort surfaces as a rejected fetch, which every caller already handles —
|
||||
* An abort surfaces as a rejected promise, which every caller already handles —
|
||||
* they wrap these in try/catch and log. That is the point: a timeout becomes a
|
||||
* recoverable error instead of an indefinite hang.
|
||||
*
|
||||
* ⚠️ **The body is read HERE, and that is the whole point.** `await fetch()`
|
||||
* settles on response HEADERS, not the body, so clearing the deadline when it
|
||||
* resolves leaves the body — the multi-megabyte `?full=1` capture this exists
|
||||
* for — completely unbounded. Measured against a server that sends headers
|
||||
* immediately and stalls the body: `fetch()` resolved at 30ms, the timer was
|
||||
* cleared there, and the body completed at 4026ms unaborted under a 1000ms
|
||||
* deadline. Reading the body inside the helper is what makes the deadline
|
||||
* cover the transfer rather than just the handshake. `_terminalCaptureInflight`
|
||||
* is scoped the same way, so a body still streaming counts toward the budget
|
||||
* of a capture starting beside it.
|
||||
*
|
||||
* Returns the PARSED envelope plus the response headers, because two callers
|
||||
* read `server-timing`, and `headersAt` because those same callers measure
|
||||
* header-vs-body time and can no longer observe that moment themselves.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {{full?: boolean}} [opts]
|
||||
* @returns {Promise<Response>}
|
||||
* @returns {Promise<{json: unknown, headers: Headers|undefined, headersAt: number}>}
|
||||
*/
|
||||
async _fetchTerminalCapture(url, opts = {}) {
|
||||
const deadlineMs =
|
||||
@@ -2613,7 +2628,12 @@ class CodemanApp {
|
||||
const timer = controller ? setTimeout(() => controller.abort(), deadlineMs) : null;
|
||||
this._terminalCaptureInflight = (this._terminalCaptureInflight || 0) + 1;
|
||||
try {
|
||||
return await (controller ? fetch(url, { signal: controller.signal }) : fetch(url));
|
||||
const res = await (controller ? fetch(url, { signal: controller.signal }) : fetch(url));
|
||||
const headersAt = performance.now();
|
||||
// Still inside the deadline: an abort here rejects the body stream, which
|
||||
// is exactly the case a header-only timeout could not reach.
|
||||
const json = await res.json();
|
||||
return { json, headers: res.headers, headersAt };
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
_crashDiag.log(`TERMINAL FETCH TIMEOUT after ${deadlineMs}ms`);
|
||||
@@ -2643,18 +2663,18 @@ class CodemanApp {
|
||||
// TUI modes still recover the whole picture, with the downgrade guard for
|
||||
// repaint-mode panes whose tmux capture can be smaller than xterm's buffer.
|
||||
const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell';
|
||||
let res = await this._fetchTerminalCapture(
|
||||
let capture = await this._fetchTerminalCapture(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
let headersReceivedAt = performance.now();
|
||||
let data = (await res.json())?.data ?? {};
|
||||
let headersReceivedAt = capture.headersAt;
|
||||
let data = capture.json?.data ?? {};
|
||||
if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) {
|
||||
res = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = performance.now();
|
||||
data = (await res.json())?.data ?? {};
|
||||
capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
|
||||
headersReceivedAt = capture.headersAt;
|
||||
data = capture.json?.data ?? {};
|
||||
}
|
||||
// Bail on a tab switch mid-fetch: writing here would paint this session's
|
||||
// history into the terminal the user is now looking at. The window is two
|
||||
@@ -2703,9 +2723,25 @@ class CodemanApp {
|
||||
console.error('needsRefresh reload failed:', err);
|
||||
} finally {
|
||||
if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null;
|
||||
// Any completed reload for this session IS the reconcile, whoever asked
|
||||
// for it — handleInit's SSE-reconnect branch and selectSession both land
|
||||
// here or do the same work. Leaving the marker set would make the next
|
||||
// ws.onopen replay the whole buffer a second time.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the "this session lost output" marker.
|
||||
*
|
||||
* Called from every path that repaints a session's buffer from the server, so
|
||||
* the ws.onopen reconcile fires once and only when nothing else already did
|
||||
* the work. See the ws.onclose note for what the marker means.
|
||||
*/
|
||||
_markTerminalBufferReconciled(sessionId) {
|
||||
if (sessionId && this._wsOutputGapSession === sessionId) this._wsOutputGapSession = null;
|
||||
}
|
||||
|
||||
async _onSessionClearTerminal(data) {
|
||||
if (data.id === this.activeSessionId) {
|
||||
// Skip if selectSession is already loading the buffer — clearTerminal arriving
|
||||
@@ -2716,9 +2752,9 @@ class CodemanApp {
|
||||
|
||||
// Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed)
|
||||
try {
|
||||
const res = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`);
|
||||
const headersReceivedAt = performance.now();
|
||||
const termData = (await res.json())?.data ?? {};
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${data.id}/terminal`);
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const termData = capture.json?.data ?? {};
|
||||
|
||||
// Queued clear — see _resetTerminalForReplay for why clear()+reset()
|
||||
// cannot do this job.
|
||||
@@ -3106,11 +3142,19 @@ class CodemanApp {
|
||||
`WS CLOSE code=${event.code} reason=${event.reason || ''} action=${plan.action} attempts=${this._wsReconnectAttempts || 0}`
|
||||
);
|
||||
|
||||
// Output frames carry no sequence number, so a socket that dropped left a
|
||||
// hole in the terminal with nothing to replay it: ws.onopen re-sends dims
|
||||
// and flushes queued INPUT, and `needsRefresh` only fires on external-CLI
|
||||
// startup and on SSE backpressure drain — never here. Whatever the PTY
|
||||
// produced while the link was down is simply absent from the buffer.
|
||||
// Output frames carry no sequence number, so a dropped socket leaves a
|
||||
// hole with nothing to replay it. ws.onopen re-sends dims and flushes
|
||||
// queued INPUT; `needsRefresh` fires only on external-CLI startup and on
|
||||
// SSE backpressure drain, never here.
|
||||
//
|
||||
// ⚠️ The gap this closes is NARROWER than "the device went offline". If
|
||||
// the network drops, SSE drops with it and `handleInit`'s keepTerminal
|
||||
// branch already reconciles on reconnect. The uncovered case is the WS
|
||||
// dying while SSE stays up — a half-open socket, a proxy idle-timeout,
|
||||
// a ping timeout — because `_onSSETerminal` discards every SSE terminal
|
||||
// frame while `_wsReady` is true, and `_wsReady` only flips here, in
|
||||
// onclose. Detecting a half-open socket takes up to the ping+pong window,
|
||||
// and that whole span produces output nothing writes to the terminal.
|
||||
//
|
||||
// Reaching onclose at all means the drop was NOT intentional
|
||||
// (_disconnectWs nulls this handler first), so mark the gap and let the
|
||||
@@ -6055,9 +6099,9 @@ class CodemanApp {
|
||||
this._fullHistoryRepullInFlight = true;
|
||||
try {
|
||||
const requestStartedAt = performance.now();
|
||||
const res = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||
const headersReceivedAt = performance.now();
|
||||
const payload = (await res.json())?.data ?? {};
|
||||
const capture = await this._fetchTerminalCapture(`/api/sessions/${sessionId}/terminal?full=1`, { full: true });
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
const payload = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
const buffer = payload.terminalBuffer;
|
||||
const timing = {
|
||||
@@ -6070,7 +6114,7 @@ class CodemanApp {
|
||||
bodyAndJsonMs: bodyParsedAt - headersReceivedAt,
|
||||
resetAndParseMs: 0,
|
||||
totalMs: 0,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
serverTiming: capture.headers?.get?.('server-timing') || '',
|
||||
refused: false,
|
||||
};
|
||||
// Bail on a tab switch mid-fetch: writing here would paint another session's
|
||||
@@ -6568,18 +6612,18 @@ class CodemanApp {
|
||||
const useFullHistory = session?.mode !== 'shell' && !this._fullHistoryLoaded.has(sessionId);
|
||||
if (useFullHistory) this._fullHistoryLoaded.add(sessionId);
|
||||
const fetchStartedAt = performance.now();
|
||||
const res = await this._fetchTerminalCapture(
|
||||
const capture = await this._fetchTerminalCapture(
|
||||
useFullHistory
|
||||
? `/api/sessions/${sessionId}/terminal?full=1`
|
||||
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`,
|
||||
{ full: useFullHistory }
|
||||
);
|
||||
const headersReceivedAt = performance.now();
|
||||
const headersReceivedAt = capture.headersAt;
|
||||
if (this._isStaleSelect(selectGen)) {
|
||||
this._clearTerminalLoadState(sessionId, selectGen);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json())?.data ?? {};
|
||||
const data = capture.json?.data ?? {};
|
||||
const bodyParsedAt = performance.now();
|
||||
// How this load must end, decided here because `chunkedTerminalWrite` is
|
||||
// what actually ends it for a non-empty buffer. A tmux pane capture is a
|
||||
@@ -6659,7 +6703,7 @@ class CodemanApp {
|
||||
cacheResetAndParseMs,
|
||||
freshResetAndParseMs,
|
||||
selectToReplayCompleteMs: performance.now() - _selStart,
|
||||
serverTiming: res.headers?.get?.('server-timing') || '',
|
||||
serverTiming: capture.headers?.get?.('server-timing') || '',
|
||||
};
|
||||
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
|
||||
// _finishBufferLoad after ordering the fetched snapshot in xterm; if we skipped
|
||||
@@ -6676,6 +6720,11 @@ class CodemanApp {
|
||||
bufferWasEmpty ? { flushQueued: true, since: 0 } : finishOpts
|
||||
);
|
||||
}
|
||||
// This load repainted the session from the server, so any pending
|
||||
// output-gap marker is already satisfied. Selecting a session runs BEFORE
|
||||
// _connectWs, so without this the socket opening afterwards would replay
|
||||
// the whole buffer again on top of the one just written.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
// Drop the guard so user input clears state normally
|
||||
this._restoringFlushedState = false;
|
||||
|
||||
@@ -6955,6 +7004,9 @@ class CodemanApp {
|
||||
// Shared cleanup for all session data — called from both closeSession() and session:deleted handler
|
||||
_cleanupSessionData(sessionId) {
|
||||
this.closeTabRailActionMenu?.();
|
||||
// A dead session has no buffer to reconcile; leaving the marker set would
|
||||
// make a later socket for a REUSED id reconcile against nothing.
|
||||
this._markTerminalBufferReconciled(sessionId);
|
||||
// If the deleted session is currently being renamed, abort the rename
|
||||
// so the inline <input> doesn't ghost as a stale tab on screen.
|
||||
if (this._activeRename?.sessionId === sessionId) {
|
||||
|
||||
+11
-1
@@ -111,7 +111,17 @@ self.addEventListener('fetch', (event) => {
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match(request))
|
||||
// ignoreSearch, or the precache can never be hit. `renderIndexHtml` runs
|
||||
// `cacheBustAssets`, which appends `?v=<mtime>` to EVERY same-origin
|
||||
// `.js`/`.css` reference — content-hashed names included, so the page asks
|
||||
// for `/app.556be563.js?v=1789423735875` while the precache stored
|
||||
// `/app.556be563.js`. `caches.match` is query-sensitive by default, so
|
||||
// every precached entry was unreachable and only `/`, the icons and the
|
||||
// manifest could ever be served offline.
|
||||
//
|
||||
// It also makes runtime-cached entries survive an mtime change: the same
|
||||
// file re-requested under a new `?v=` still matches the copy already held.
|
||||
.catch(() => caches.match(request, { ignoreSearch: true }))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -53,29 +53,24 @@ describe('service worker precache contract', () => {
|
||||
expect(sw).toContain("...HASHED_ASSETS.map((p) => '/' + p)");
|
||||
});
|
||||
|
||||
// The regression itself. These are the pre-hash names the build renames, so
|
||||
// any of them appearing in the shell list means someone hand-added an entry
|
||||
// that will 404 in production.
|
||||
// The regression itself: any pre-hash filename hand-listed in APP_SHELL will
|
||||
// 404 in production, because the build renames it.
|
||||
//
|
||||
// The HASHABLE list is PARSED out of scripts/build.mjs rather than copied
|
||||
// here. A hand-kept duplicate would be the same drift this whole PR exists to
|
||||
// fix — it would go stale the first time someone adds an asset to the build,
|
||||
// and then silently stop covering it.
|
||||
it('never hand-lists a filename the build content-hashes', () => {
|
||||
const block = build.slice(
|
||||
build.indexOf('const HASHABLE = ['),
|
||||
build.indexOf('];', build.indexOf('const HASHABLE = ['))
|
||||
);
|
||||
const hashedByBuild = [...block.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
// Guard the parse itself: an empty list would make this test vacuously pass.
|
||||
expect(hashedByBuild.length, 'failed to parse HASHABLE out of scripts/build.mjs').toBeGreaterThan(10);
|
||||
expect(hashedByBuild).toContain('app.js');
|
||||
|
||||
const shell = sw.slice(sw.indexOf('const APP_SHELL'), sw.indexOf('].map(B);'));
|
||||
const hashedByBuild = [
|
||||
'app.js',
|
||||
'constants.js',
|
||||
'terminal-ui.js',
|
||||
'session-ui.js',
|
||||
'settings-ui.js',
|
||||
'panels-ui.js',
|
||||
'styles.css',
|
||||
'mobile.css',
|
||||
'i18n.js',
|
||||
'mobile-handlers.js',
|
||||
'keyboard-accessory.js',
|
||||
'notification-manager.js',
|
||||
'voice-input.js',
|
||||
'api-client.js',
|
||||
'vendor/xterm-zerolag-input.js',
|
||||
'vendor/xterm-predictive-echo.js',
|
||||
];
|
||||
for (const name of hashedByBuild) {
|
||||
expect(shell, `APP_SHELL must not hand-list ${name} — the build renames it`).not.toContain(`'/${name}'`);
|
||||
}
|
||||
@@ -86,4 +81,20 @@ describe('service worker precache contract', () => {
|
||||
it('is valid unrewritten, for dev', () => {
|
||||
expect(() => new Function(sw.replace(/self\./g, 'globalThis.'))).not.toThrow();
|
||||
});
|
||||
|
||||
// Without ignoreSearch the whole precache is unreachable, which is subtle
|
||||
// enough to be re-broken by anyone tidying this handler.
|
||||
//
|
||||
// `renderIndexHtml` runs `cacheBustAssets`, which appends `?v=<mtime>` to
|
||||
// EVERY same-origin `.js`/`.css` reference — content-hashed names included.
|
||||
// Observed on a running instance: `src="app.556be563.js?v=1789423735875"`.
|
||||
// `caches.match` is query-sensitive by default, so a precache keyed on
|
||||
// `/app.556be563.js` can never serve that request, and the install would be
|
||||
// downloading ~1.3MB per deploy that nothing can ever read back.
|
||||
it('falls back to the cache ignoring the cache-busting query string', () => {
|
||||
expect(sw).toContain('caches.match(request, { ignoreSearch: true })');
|
||||
expect(sw, 'a bare caches.match(request) cannot match the ?v=<mtime> URLs cacheBustAssets emits').not.toMatch(
|
||||
/caches\.match\(request\)\s*\)/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { createServer, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function loadConstants() {
|
||||
@@ -152,3 +154,93 @@ describe('sanitizeDiagEntry', () => {
|
||||
expect(sanitizeDiagEntry({ toString: () => 'obj' })).toBe('obj');
|
||||
});
|
||||
});
|
||||
|
||||
// ── The deadline must cover the BODY, not just the handshake ────────────────
|
||||
//
|
||||
// `await fetch()` settles on response HEADERS. Clearing the abort timer there
|
||||
// leaves the body — the multi-megabyte `?full=1` capture the deadline exists
|
||||
// for — completely unbounded; it only ever covered a server that accepts a
|
||||
// connection and never replies at all.
|
||||
//
|
||||
// Measured on the pre-fix shape against a server that sends headers immediately
|
||||
// and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, the timer
|
||||
// was cleared there, and the body completed at 4026ms unaborted.
|
||||
//
|
||||
// This exercises the real property with a real socket rather than asserting on
|
||||
// source text, because the bug was a lifetime mistake that reads correctly.
|
||||
describe('terminal capture deadline covers the response body', () => {
|
||||
// Mirrors _fetchTerminalCapture's lifetime: one timer spanning headers AND
|
||||
// body, cleared only once the body has been read.
|
||||
async function captureUnderDeadline(url: string, deadlineMs: number) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), deadlineMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
const headersAt = performance.now();
|
||||
const json = await res.json();
|
||||
return { json, headers: res.headers, headersAt };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function serve(handler: (res: ServerResponse) => void) {
|
||||
const srv = createServer((_req, res) => handler(res));
|
||||
await new Promise<void>((r) => srv.listen(0, '127.0.0.1', r));
|
||||
const { port } = srv.address() as AddressInfo;
|
||||
return { url: `http://127.0.0.1:${port}/`, close: () => srv.close() };
|
||||
}
|
||||
|
||||
it('aborts a stalled body instead of waiting on it forever', async () => {
|
||||
let finish: NodeJS.Timeout | undefined;
|
||||
const { url, close } = await serve((res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.write(' '); // headers out immediately, body never completes in time
|
||||
finish = setTimeout(() => res.end('{"data":{}}'), 5000);
|
||||
});
|
||||
try {
|
||||
await expect(captureUnderDeadline(url, 300)).rejects.toThrow(/abort/i);
|
||||
} finally {
|
||||
if (finish) clearTimeout(finish);
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
// The two tests above exercise the PATTERN against a real socket, using a
|
||||
// local mirror — so on their own they would still pass if the real helper
|
||||
// regressed to clearing its timer at headers. This pins the real one.
|
||||
it('_fetchTerminalCapture reads the body before releasing its deadline', () => {
|
||||
const app = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||
const start = app.indexOf('async _fetchTerminalCapture(');
|
||||
expect(start, 'helper not found — renamed?').toBeGreaterThan(-1);
|
||||
const body = app.slice(start, app.indexOf('\n }', start));
|
||||
const jsonAt = body.indexOf('await res.json()');
|
||||
const finallyAt = body.indexOf('} finally {');
|
||||
expect(jsonAt, 'the body must be read inside the helper, not by callers').toBeGreaterThan(-1);
|
||||
expect(finallyAt).toBeGreaterThan(-1);
|
||||
expect(
|
||||
jsonAt,
|
||||
'await res.json() must run BEFORE the finally that clears the abort timer — ' +
|
||||
'fetch() settles on headers, so a timer cleared there leaves the body unbounded'
|
||||
).toBeLessThan(finallyAt);
|
||||
// And the returned shape the five call sites destructure.
|
||||
expect(body).toContain('return { json, headers: res.headers, headersAt };');
|
||||
});
|
||||
|
||||
it('returns the parsed envelope and headers on a healthy response', async () => {
|
||||
const { url, close } = await serve((res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'server-timing': 'db;dur=12' });
|
||||
res.end('{"data":{"terminalBuffer":"hello"}}');
|
||||
});
|
||||
try {
|
||||
const out = await captureUnderDeadline(url, 5000);
|
||||
// Callers read `capture.json?.data`, `capture.headers.get(...)` and
|
||||
// `capture.headersAt` — all three must survive.
|
||||
expect((out.json as { data: { terminalBuffer: string } }).data.terminalBuffer).toBe('hello');
|
||||
expect(out.headers.get('server-timing')).toBe('db;dur=12');
|
||||
expect(typeof out.headersAt).toBe('number');
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,23 +28,29 @@ 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 lock = JSON.parse(readFileSync(resolve(root, 'package-lock.json'), 'utf8')) as {
|
||||
packages: Record<string, { version?: 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';
|
||||
// The exact version `_kickRenderer`'s field path was verified against.
|
||||
//
|
||||
// Read from the LOCKFILE, not package.json. The declared range is `^6.0.0`, so
|
||||
// asserting on that string is the wrong test in both directions: a real upgrade
|
||||
// to 6.4.0 — which can absolutely rename a private field — resolves inside the
|
||||
// range and slips through, while an innocuous range edit that changes nothing
|
||||
// about the installed code fails. The lockfile is what actually ships.
|
||||
const VERIFIED_XTERM_VERSION = '6.0.0';
|
||||
|
||||
describe('xterm private-API dependency guard', () => {
|
||||
it('pins the xterm range _kickRenderer was verified against', () => {
|
||||
it('pins the resolved xterm version _kickRenderer was verified against', () => {
|
||||
expect(
|
||||
pkg.dependencies['@xterm/xterm'],
|
||||
'xterm moved off the verified range — re-verify _kickRenderer in a real browser ' +
|
||||
lock.packages['node_modules/@xterm/xterm']?.version,
|
||||
'xterm moved off the verified version — 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 ' +
|
||||
'VERIFIED_XTERM_VERSION 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);
|
||||
).toBe(VERIFIED_XTERM_VERSION);
|
||||
});
|
||||
|
||||
// If someone deletes the watchdog, this guard is pointless noise — keep the
|
||||
|
||||
Reference in New Issue
Block a user