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
+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 }))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user