From 92dbe6413f30b66b635620f133329b105b747ee2 Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 12:10:04 +0300 Subject: [PATCH 1/2] fix(terminal): retain session replay ownership --- src/web/public/app.js | 6 +++--- src/web/public/terminal-ui.js | 13 ++++++++++--- test/terminal-flush-budget.test.ts | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..5278b8f3 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4359,9 +4359,9 @@ class CodemanApp { bufferWasEmpty = true; } - // Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls - // _finishBufferLoad internally (discarding queued events to prevent duplicate - // content); if we skipped the write (cache hit or empty), call it here. + // Buffer load complete — unblock live SSE writes. selectSession owns this + // gate across every cache/fetch/replay stage, so its chunked writes leave + // the transaction open and we finish it exactly once here. // COD-144: when the load painted nothing, FLUSH the queued events instead of // discarding — a new session's prompt arrives only as a queued SSE event. if (this._isLoadingBuffer) { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3a9de819..ef7ec1df 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -2376,11 +2376,18 @@ Object.assign(CodemanApp.prototype, { // Generation counter: if a newer chunkedTerminalWrite starts (tab switch), // older writes abort instead of continuing to push stale data into the terminal. const writeGen = ++this._chunkedWriteGen; - const bufferLoadOwner = this._beginBufferLoad(loadOwner); + // A caller-provided owner means a wider operation (selectSession) already + // owns the live-output gate. Do not close that transaction after this one + // replay; the caller still has resize/fetch/reconciliation work to finish. + const ownsBufferLoad = loadOwner == null; + const bufferLoadOwner = ownsBufferLoad ? this._beginBufferLoad() : loadOwner; + const finishOwnedBufferLoad = () => { + if (ownsBufferLoad) this._finishBufferLoad(bufferLoadOwner); + }; return new Promise((resolve) => { if (!buffer || buffer.length === 0) { - this._finishBufferLoad(bufferLoadOwner); + finishOwnedBufferLoad(); resolve(); return; } @@ -2392,7 +2399,7 @@ Object.assign(CodemanApp.prototype, { const finish = () => { // Only finish if we're still the active write — a newer write owns buffer load state if (this._chunkedWriteGen === writeGen) { - this._finishBufferLoad(bufferLoadOwner); + finishOwnedBufferLoad(); } resolve(); }; diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index 1af721ab..b1695fc7 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -98,6 +98,20 @@ describe('terminal flush budget', () => { expect(finishBufferLoad).toHaveBeenCalledOnce(); }); + it('leaves a caller-owned buffer load open after replaying a chunk', async () => { + const { app } = loadTerminalUiHarness('codex'); + const beginBufferLoad = vi.fn(() => 'nested-owner'); + const finishBufferLoad = vi.fn(); + app._beginBufferLoad = beginBufferLoad; + app._finishBufferLoad = finishBufferLoad; + app.terminal.write = (_data: string, callback?: () => void) => callback?.(); + + await app.chunkedTerminalWrite('cached frame', 32 * 1024, 'select-owner'); + + expect(beginBufferLoad).not.toHaveBeenCalled(); + expect(finishBufferLoad).not.toHaveBeenCalled(); + }); + it('keeps stale buffer load owners from finishing a newer load', () => { const { app } = loadTerminalUiHarness('codex'); From 8d9e00cf1144d24d734ad83bcf81ad26c2d1ca2e Mon Sep 17 00:00:00 2001 From: lior Date: Wed, 29 Jul 2026 12:49:30 +0300 Subject: [PATCH 2/2] feat(terminal): keep recent sessions warm --- src/web/public/app.js | 307 ++++++++++++++++++++++------- src/web/public/constants.js | 163 +++++++++++++++ test/codex-snapshot-replay.test.ts | 18 +- test/warm-terminal-cache.test.ts | 201 +++++++++++++++++++ 4 files changed, 610 insertions(+), 79 deletions(-) create mode 100644 test/warm-terminal-cache.test.ts diff --git a/src/web/public/app.js b/src/web/public/app.js index 5278b8f3..3e699058 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -547,6 +547,9 @@ class CodemanApp { this.teammateTerminals = new Map(); // Map this.terminalBufferCache = new Map(); // Map — client-side cache for instant tab re-visits (max 20) + this._warmTerminalCache = new WarmTerminalCache(); + this._warmTerminalExpiryTimer = null; + this._warmTerminalSubscriptionUpdate = Promise.resolve(); this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel @@ -1071,28 +1074,87 @@ class CodemanApp { // SSE Connection // ═══════════════════════════════════════════════════════════════ - /** - * POST a live subscription update so the server filters terminal events - * to the given session(s) for this client. Fire-and-forget — failures - * are non-fatal because we'll still get every event we don't want - * (just at higher cost), and the next reconnect carries the filter via - * the SSE query string. - */ + _scheduleWarmTerminalExpiry() { + this._clearTimer('_warmTerminalExpiryTimer'); + const delay = this._warmTerminalCache.nextExpiryDelay(); + if (delay === null) return; + this._warmTerminalExpiryTimer = setTimeout(() => { + this._warmTerminalExpiryTimer = null; + this._updateSseSubscription(); + }, Math.max(1, delay)); + } + + /** Keep the active terminal plus a short, bounded recent-session range live. */ + _terminalSseSubscriptionIds() { + return this._warmTerminalCache.ids(); + } + + _terminalStreamClientId() { + return this._clientId ? `${this._clientId}:${this._wsTabNonce}` : ''; + } + _updateSseSubscription(sessionId) { try { + if (sessionId) this._warmTerminalCache.activate(sessionId); + const sessions = this._terminalSseSubscriptionIds(); const body = JSON.stringify({ - clientId: this._clientId, - sessions: sessionId ? [sessionId] : null, + clientId: this._terminalStreamClientId(), + sessions, }); - fetch('/api/events/subscribe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - keepalive: true, - }).catch(() => { /* non-fatal */ }); + // Preserve server filter order across rapid tab switches. A successful + // response proves that every listed session has continuous SSE coverage; + // failed updates leave new entries ineligible for warm replay. + this._warmTerminalSubscriptionUpdate = this._warmTerminalSubscriptionUpdate + .catch(() => {}) + .then(async () => { + try { + const response = await fetch('/api/events/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }); + if (response.ok) this._warmTerminalCache.confirmSubscriptions(sessions); + } catch { + /* non-fatal: canonical fetch remains the fallback */ + } + }); + this._scheduleWarmTerminalExpiry(); } catch { /* non-fatal */ } } + _removeWarmTerminalState(sessionId, discardSnapshot = false) { + if (!sessionId) return; + this._warmTerminalCache.remove(sessionId); + if (discardSnapshot) { + this._xtermSnapshots?.delete(sessionId); + this.terminalBufferCache.delete(sessionId); + try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {} + } + } + + _invalidateWarmTerminalReplay(sessionId) { + if (!sessionId) return; + this._warmTerminalCache.invalidateReplay(sessionId); + this._xtermSnapshots?.delete(sessionId); + this.terminalBufferCache.delete(sessionId); + try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {} + } + + _dropWarmTerminalSession(sessionId, discardSnapshot = false) { + this._removeWarmTerminalState(sessionId, discardSnapshot); + this._updateSseSubscription(); + } + + _invalidateInactiveWarmTerminalSessions() { + const inactiveIds = this._warmTerminalCache.ids().filter((id) => id !== this.activeSessionId); + if (inactiveIds.length === 0) return; + for (const sessionId of inactiveIds) { + this._removeWarmTerminalState(sessionId, true); + } + this._updateSseSubscription(); + } + // ══════════════════════════════════════════════════════════════════════ // Session detach / undock (beta/session-detach) // @@ -1373,12 +1435,12 @@ class CodemanApp { this.setConnectionStatus('reconnecting'); } - // Build URL with stable client ID and (if known) the active-session - // filter so the server only streams session:terminal events for the - // session we're rendering. Lifecycle/metadata events are sent globally - // regardless of filter (server side). - const _sseParams = new URLSearchParams({ clientId: this._clientId }); - if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId); + // Build the URL with a per-tab client ID and an explicit terminal filter. + // An empty value means no terminal events; lifecycle/metadata events still + // arrive globally. + const _sseParams = new URLSearchParams({ clientId: this._terminalStreamClientId() }); + const terminalSessions = this._terminalSseSubscriptionIds(); + _sseParams.set('sessions', terminalSessions.join(',')); this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`); // Store all event listeners for cleanup on reconnect @@ -1401,6 +1463,7 @@ class CodemanApp { this.eventSource.onopen = () => { this.reconnectAttempts = 0; this.setConnectionStatus('connected'); + this._warmTerminalCache.confirmSubscriptions(terminalSessions); }; this.eventSource.onerror = () => { this.reconnectAttempts++; @@ -1632,6 +1695,7 @@ class CodemanApp { } _onSessionTerminal(data) { + if (!data?.id || typeof data.data !== 'string') return; if (data.id === this.activeSessionId) { if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`); @@ -1655,6 +1719,11 @@ class CodemanApp { } this.batchTerminalWrite(data.data); + } else if (this._warmTerminalCache.isWarm(data.id)) { + this._warmTerminalCache.confirmDelivery(data.id); + if (!this._warmTerminalCache.append(data.id, data.data)) { + this._dropWarmTerminalSession(data.id, true); + } } } @@ -2006,27 +2075,38 @@ class CodemanApp { } } - async _onSessionNeedsRefresh() { + async _onSessionNeedsRefresh(data) { // Server sends this after SSE backpressure clears — terminal data was dropped, // so reload the buffer to recover from any display corruption. + if (data?.id && data.id !== this.activeSessionId) { + this._dropWarmTerminalSession(data.id, true); + return; + } + // An unscoped SSE backpressure event means any subscribed inactive stream + // may have lost bytes. Targeted WebSocket recovery leaves other warm tabs valid. + if (!data?.id) this._invalidateInactiveWarmTerminalSessions(); if (!this.activeSessionId || !this.terminal) return; + const sessionId = this.activeSessionId; + this._invalidateWarmTerminalReplay(sessionId); // Skip if buffer load already in progress — avoids competing clear+rewrite cycles if (this._isLoadingBuffer) return; try { - const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); - const data = (await res.json())?.data ?? {}; - if (data.terminalBuffer) { + const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); + if (!res.ok) return; + const terminalData = (await res.json())?.data ?? {}; + if (sessionId !== this.activeSessionId) return; + if (terminalData.terminalBuffer) { this.terminal.clear(); this.terminal.reset(); - await this.chunkedTerminalWrite(data.terminalBuffer); + await this.chunkedTerminalWrite(terminalData.terminalBuffer); + if (sessionId !== this.activeSessionId) return; + this._warmTerminalCache.markCanonical(sessionId); this.terminal.scrollToBottom(); // Re-position local echo overlay at new prompt location this._localEchoOverlay?.rerender(); // Resize PTY to match actual browser dimensions (critical for OpenCode // TUI sessions that render at fixed 120x40 until told the real size) - if (this.activeSessionId) { - this.sendResize(this.activeSessionId); - } + this.sendResize(sessionId); } } catch (err) { console.error('needsRefresh reload failed:', err); @@ -2034,7 +2114,15 @@ class CodemanApp { } async _onSessionClearTerminal(data) { + if (!data?.id) return; + if (data.id !== this.activeSessionId) { + // Clear events are global even when terminal bytes are subscription-filtered. + // Never apply post-clear deltas to a pre-clear snapshot. + this._dropWarmTerminalSession(data.id, true); + return; + } if (data.id === this.activeSessionId) { + this._invalidateWarmTerminalReplay(data.id); // Skip if selectSession is already loading the buffer — clearTerminal arriving // during buffer load would clear the terminal mid-write, causing visible flicker // and a race between two concurrent chunkedTerminalWrite calls (especially on mobile @@ -2044,7 +2132,9 @@ class CodemanApp { // Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed) try { const res = await fetch(`/api/sessions/${data.id}/terminal`); + if (!res.ok) return; const termData = (await res.json())?.data ?? {}; + if (data.id !== this.activeSessionId) return; this.terminal.clear(); this.terminal.reset(); @@ -2054,8 +2144,10 @@ class CodemanApp { const cleanBuffer = termData.terminalBuffer.replace(DEC_SYNC_STRIP_RE, ''); // Use chunked write to avoid UI freeze with large buffers (can be 1-2MB) await this.chunkedTerminalWrite(cleanBuffer); + if (data.id !== this.activeSessionId) return; } + this._warmTerminalCache.markCanonical(data.id); // Fire-and-forget resize — don't block on it this.sendResize(data.id); // Re-position local echo overlay at new prompt location @@ -2939,6 +3031,8 @@ class CodemanApp { this.terminalBuffers.clear(); this.terminalBufferCache.clear(); this._xtermSnapshots?.clear(); + this._warmTerminalCache.clear(); + this._clearTimer('_warmTerminalExpiryTimer'); this.projectInsights.clear(); this.teams.clear(); this.teamTasks.clear(); @@ -3904,6 +3998,9 @@ class CodemanApp { } _cleanupPreviousSession(newSessionId) { + const outgoingSessionId = this.activeSessionId; + const deferredOutput = + (this.pendingWrites?.length ? this.pendingWrites.join('') : '') + (this.flickerFilterBuffer || ''); // Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback + // colors/attrs) before the terminal gets cleared/reset. Lets us restore the // exact view on switch-back rather than replaying codex's byte stream, which @@ -3949,6 +4046,21 @@ class CodemanApp { } } + // Output still waiting for the browser render budget is not part of the + // serialized xterm snapshot. Preserve it as the first warm delta. + if (outgoingSession?.mode === 'shell') { + // Shell sessions never restore xterm snapshots, so retaining their + // inactive byte stream cannot produce a warm replay. + this._removeWarmTerminalState(outgoingSessionId); + } else if ( + outgoingSessionId && + deferredOutput && + this._warmTerminalCache.isWarm(outgoingSessionId) && + !this._warmTerminalCache.append(outgoingSessionId, deferredOutput) + ) { + this._dropWarmTerminalSession(outgoingSessionId, true); + } + // Close WebSocket for previous session (new one opens after buffer load) this._disconnectWs(); @@ -4056,6 +4168,7 @@ class CodemanApp { if (this.activeSessionId === sessionId && forceReload) { this.terminalBufferCache?.delete(sessionId); this._xtermSnapshots?.delete(sessionId); + this._warmTerminalCache.remove(sessionId); try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {} this._clearTimer('syncWaitTimeout'); this.pendingWrites = []; @@ -4081,6 +4194,8 @@ class CodemanApp { const selectGen = ++this._selectGeneration; this._setTerminalLoadState(sessionId, selectGen, 'resizing'); + const targetWasReplayable = this._warmTerminalCache.isReplayable(sessionId); + const targetWarmGeneration = this._warmTerminalCache.generation(sessionId); if (selectGen !== this._selectGeneration) { this._clearTerminalLoadState(sessionId, selectGen); @@ -4092,12 +4207,9 @@ class CodemanApp { this._cleanupPreviousSession(sessionId); this.activeSessionId = sessionId; + const bufferLoadOwner = this._beginBufferLoad(selectGen); try { localStorage.setItem('codeman-active-session', sessionId); } catch {} - // Narrow SSE filter to the active session — server stops streaming - // session:terminal events for other sessions to this client. Cuts - // SSE traffic ~Nx for N concurrent sessions. Fire-and-forget; on the - // rare race where server doesn't know our clientId yet, the next - // selectSession or reconnect catches up. + // Subscribe to the active session plus the bounded warm range. this._updateSseSubscription(sessionId); this.hideWelcome(); // Clear idle hooks on view, but keep action hooks until user interacts @@ -4175,7 +4287,7 @@ class CodemanApp { // Load terminal buffer for this session // Show cached content instantly while fetching fresh data in background. - // Use tail mode for faster initial load (128KB is enough for recent visible content). + // Warm revisits restore from an xterm snapshot plus the bounded live delta. // // Protect flushed state during buffer load: terminal.write() can trigger // xterm.js onData responses (DA, OSC, etc.) that would otherwise clear @@ -4186,11 +4298,11 @@ class CodemanApp { // Gate live SSE terminal writes for the ENTIRE buffer load sequence. // Without this, SSE events arriving during the fetch() gap compete with // the buffer write, causing 70KB+ single-frame flushes that stall WebGL. - // chunkedTerminalWrite also sets this, but we need it before the fetch too. - const bufferLoadOwner = this._beginBufferLoad(selectGen); + // chunkedTerminalWrite shares this caller-owned transaction. // COD-144: track whether the load painted nothing (empty fetch + no cache). // For that just-created-session case we flush (not discard) queued SSE events. let bufferWasEmpty = false; + let canonicalStateReady = false; try { // Fit terminal to container BEFORE writing any buffer data. // If the browser was resized while viewing another session, the terminal @@ -4222,9 +4334,11 @@ class CodemanApp { // Try in-memory first (fast); fall back to localStorage so snapshots // survive tab discards / browser reloads. let snapshot = this._xtermSnapshots?.get(sessionId); + let snapshotWasInMemory = !!snapshot; if (snapshot && !this._isUsableXtermSnapshot(snapshot)) { this._xtermSnapshots?.delete(sessionId); snapshot = null; + snapshotWasInMemory = false; } if (!snapshot) { try { @@ -4243,8 +4357,10 @@ class CodemanApp { } } const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working'); + const canWarmRestore = targetWasReplayable && snapshotWasInMemory; let restoredSnapshot = false; - if (snapshot && !sessionIsBusy && session?.mode !== 'shell') { + let usedWarmRestore = false; + if (snapshot && (canWarmRestore || !sessionIsBusy) && session?.mode !== 'shell') { _crashDiag.log(`SNAPSHOT_RESTORE: ${(snapshot.length/1024).toFixed(0)}KB`); this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._resetTerminalForReplay(); @@ -4253,13 +4369,42 @@ class CodemanApp { this._clearTerminalLoadState(sessionId, selectGen); return; } - this.scrollToLastNonEmptyLine(); + this.terminal.scrollToBottom(); _crashDiag.log('SNAPSHOT_RESTORE_DONE'); - // Snapshot restore is only first paint. Inactive tabs intentionally - // unsubscribe from high-volume terminal output, so they can miss bytes - // emitted while away. Keep going and replace the snapshot with the - // canonical live tmux pane frame from /terminal. restoredSnapshot = true; + usedWarmRestore = canWarmRestore; + } + + if ( + usedWarmRestore && + this._warmTerminalCache.generation(sessionId) !== targetWarmGeneration + ) { + usedWarmRestore = false; + } + if (usedWarmRestore) { + const warmDelta = this._warmTerminalCache.consume(sessionId); + if (warmDelta === null) { + usedWarmRestore = false; + } else if (warmDelta.length > 0) { + _crashDiag.log(`WARM_DELTA: ${(warmDelta.length / 1024).toFixed(0)}KB`); + await this.chunkedTerminalWrite(warmDelta, TERMINAL_CHUNK_SIZE, bufferLoadOwner); + if (this._isStaleSelect(selectGen)) { + this._clearTerminalLoadState(sessionId, selectGen); + return; + } + this.terminal.scrollToBottom(); + } + } + if ( + usedWarmRestore && + this._warmTerminalCache.generation(sessionId) !== targetWarmGeneration + ) { + usedWarmRestore = false; + } + if (usedWarmRestore) canonicalStateReady = true; + if (!usedWarmRestore) { + // A canonical load supersedes every delta collected before it. + this._warmTerminalCache.consume(sessionId); } // Instant cache restore for IDLE sessions only. @@ -4269,7 +4414,9 @@ class CodemanApp { // buffer once for a single clean transition. const cachedBuffer = this.terminalBufferCache.get(sessionId); let clearedForBusy = false; - if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) { + if (usedWarmRestore) { + _crashDiag.log('WARM_RESTORE_DONE'); + } else if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) { _crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`); this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._resetTerminalForReplay(); @@ -4292,7 +4439,7 @@ class CodemanApp { // dimensions (a real SIGWINCH → Ink redraw); a same-size tab switch sent // no resize, so waiting would just add latency. Shell sessions never need // it, so terminal content can appear immediately when switching shells. - if (session?.mode !== 'shell' && dimsChanged) { + if (!usedWarmRestore && session?.mode !== 'shell' && dimsChanged) { await new Promise((resolve) => setTimeout(resolve, TUI_REDRAW_SETTLE_MS)); if (this._isStaleSelect(selectGen)) { this._clearTerminalLoadState(sessionId, selectGen); @@ -4300,35 +4447,39 @@ class CodemanApp { } } - this._setTerminalLoadState(sessionId, selectGen, 'fetching'); - _crashDiag.log('FETCH_START'); + if (!usedWarmRestore) { + this._setTerminalLoadState(sessionId, selectGen, 'fetching'); + _crashDiag.log('FETCH_START'); // The FIRST buffer load after a page load requests the full tmux scrollback // (?full=1, COD-47) so history that scrolled off the server's byte buffer // comes back after a reload. Tab switches keep the fast ?tail= frame path. - const useFullHistory = this._initialFullBufferLoad === true; - this._initialFullBufferLoad = false; - const res = await fetch( - useFullHistory - ? `/api/sessions/${sessionId}/terminal?full=1` - : `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}` - ); - if (this._isStaleSelect(selectGen)) { - this._clearTerminalLoadState(sessionId, selectGen); - return; - } - const data = (await res.json())?.data ?? {}; - _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`); + const useFullHistory = this._initialFullBufferLoad === true; + this._initialFullBufferLoad = false; + const res = await fetch( + useFullHistory + ? `/api/sessions/${sessionId}/terminal?full=1` + : `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}` + ); + if (this._isStaleSelect(selectGen)) { + this._clearTerminalLoadState(sessionId, selectGen); + return; + } + const data = (await res.json())?.data ?? {}; + canonicalStateReady = + res.ok && Object.prototype.hasOwnProperty.call(data, 'terminalBuffer'); + _crashDiag.log( + `FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length / 1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}` + ); - if (data.terminalBuffer) { + if (data.terminalBuffer) { // Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash. // On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is // very visible, causing the terminal to flash blank then repaint. // A snapshot restore or a busy-clear leaves the terminal showing // something other than the cache, so the fetched buffer must be // replayed even when it byte-matches the cache. - const needsRewrite = - restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer; - if (needsRewrite) { + const needsRewrite = restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer; + if (needsRewrite) { _crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`); this._setTerminalLoadState(sessionId, selectGen, 'replaying'); this._resetTerminalForReplay(); @@ -4344,28 +4495,28 @@ class CodemanApp { } // Ensure terminal is scrolled to bottom after buffer load this.terminal.scrollToBottom(); - } + } - // Update cache (cap at 20 entries) - this.terminalBufferCache.set(sessionId, data.terminalBuffer); - if (this.terminalBufferCache.size > 20) { - // Evict oldest entry (first key in Map iteration order) - const oldest = this.terminalBufferCache.keys().next().value; - this.terminalBufferCache.delete(oldest); + // Update cache (cap at 20 entries) + this.terminalBufferCache.set(sessionId, data.terminalBuffer); + if (this.terminalBufferCache.size > 20) { + const oldest = this.terminalBufferCache.keys().next().value; + this.terminalBufferCache.delete(oldest); + } + } else if (!cachedBuffer) { + this._resetTerminalForReplay(); + bufferWasEmpty = true; } - } else if (!cachedBuffer) { - // No fresh buffer and no cache — clear any stale content - this._resetTerminalForReplay(); - bufferWasEmpty = true; } + if (canonicalStateReady) this._warmTerminalCache.markCanonical(sessionId); // Buffer load complete — unblock live SSE writes. selectSession owns this // gate across every cache/fetch/replay stage, so its chunked writes leave // the transaction open and we finish it exactly once here. // COD-144: when the load painted nothing, FLUSH the queued events instead of // discarding — a new session's prompt arrives only as a queued SSE event. if (this._isLoadingBuffer) { - this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty }); + this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty || usedWarmRestore }); } // Drop the guard so user input clears state normally this._restoringFlushedState = false; @@ -4518,6 +4669,8 @@ class CodemanApp { this.terminalBuffers.delete(sessionId); this.terminalBufferCache.delete(sessionId); this._xtermSnapshots?.delete(sessionId); + this._warmTerminalCache.remove(sessionId); + this._updateSseSubscription(); try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {} this._flushedOffsets?.delete(sessionId); @@ -4706,6 +4859,8 @@ class CodemanApp { this.terminalBufferCache.clear(); this.terminalLoadStates.clear(); this._xtermSnapshots?.clear(); + this._warmTerminalCache.clear(); + this._clearTimer('_warmTerminalExpiryTimer'); try { for (const k of Object.keys(localStorage)) { if (k.startsWith('codeman-xs-')) localStorage.removeItem(k); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 1db36064..5c9e54e5 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -60,6 +60,169 @@ const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling const TUI_REDRAW_SETTLE_MS = 400; // Grace for a TUI to redraw after a real resize, before fetching its buffer +/** + * Bounded cache for recently viewed terminal sessions. + * + * The active session never expires. Sessions demoted by a tab switch remain + * warm briefly so their live terminal deltas can be applied to the serialized + * xterm snapshot instead of downloading and replaying the full terminal tail. + */ +class WarmTerminalCache { + constructor({ limit = 3, ttlMs = 30_000, maxDeltaChars = 256 * 1024 } = {}) { + this.limit = limit; + this.ttlMs = ttlMs; + this.maxDeltaChars = maxDeltaChars; + this.entries = new Map(); + } + + activate(sessionId, now = Date.now()) { + if (!sessionId) return; + + for (const [id, entry] of this.entries) { + if (id !== sessionId && entry.expiresAt === null) { + entry.replayEligible = entry.subscriptionConfirmed && !entry.canonicalPending; + entry.expiresAt = now + this.ttlMs; + } + } + + const existing = this.entries.get(sessionId); + const entry = + existing?.valid === true + ? existing + : { + expiresAt: null, + chunks: [], + canonicalPending: true, + deltaChars: 0, + generation: 0, + replayEligible: false, + subscriptionConfirmed: false, + valid: true, + }; + entry.expiresAt = null; + entry.canonicalPending = true; + entry.replayEligible = false; + this.entries.delete(sessionId); + this.entries.set(sessionId, entry); + this._prune(now); + } + + append(sessionId, data, now = Date.now()) { + this._prune(now); + const entry = this.entries.get(sessionId); + if (!entry || !entry.valid || typeof data !== 'string' || data.length === 0) { + return entry?.valid === true; + } + + if (entry.deltaChars + data.length > this.maxDeltaChars) { + entry.valid = false; + entry.chunks = []; + entry.deltaChars = 0; + return false; + } + + entry.chunks.push(data); + entry.deltaChars += data.length; + return true; + } + + consume(sessionId, now = Date.now()) { + this._prune(now); + const entry = this.entries.get(sessionId); + if (!entry?.valid) return null; + + const data = entry.chunks.join(''); + entry.chunks = []; + entry.deltaChars = 0; + return data; + } + + isWarm(sessionId, now = Date.now()) { + this._prune(now); + return this.entries.get(sessionId)?.valid === true; + } + + isReplayable(sessionId, now = Date.now()) { + this._prune(now); + const entry = this.entries.get(sessionId); + return entry?.valid === true && entry.replayEligible === true; + } + + confirmDelivery(sessionId, now = Date.now()) { + this._prune(now); + const entry = this.entries.get(sessionId); + if (entry?.valid) entry.subscriptionConfirmed = true; + } + + generation(sessionId, now = Date.now()) { + this._prune(now); + return this.entries.get(sessionId)?.generation ?? null; + } + + invalidateReplay(sessionId, now = Date.now()) { + this._prune(now); + const entry = this.entries.get(sessionId); + if (!entry) return; + entry.generation += 1; + entry.canonicalPending = true; + entry.replayEligible = false; + entry.chunks = []; + entry.deltaChars = 0; + } + + markCanonical(sessionId, now = Date.now()) { + this._prune(now); + const entry = this.entries.get(sessionId); + if (entry?.valid) entry.canonicalPending = false; + } + + confirmSubscriptions(sessionIds, now = Date.now()) { + this._prune(now); + const confirmed = new Set(sessionIds); + for (const [id, entry] of this.entries) { + entry.subscriptionConfirmed = confirmed.has(id); + } + } + + ids(now = Date.now()) { + this._prune(now); + return Array.from(this.entries.keys()); + } + + nextExpiryDelay(now = Date.now()) { + this._prune(now); + let earliest = Infinity; + for (const entry of this.entries.values()) { + if (entry.expiresAt !== null) earliest = Math.min(earliest, entry.expiresAt); + } + return Number.isFinite(earliest) ? Math.max(0, earliest - now) : null; + } + + remove(sessionId) { + this.entries.delete(sessionId); + } + + clear() { + this.entries.clear(); + } + + _prune(now) { + for (const [id, entry] of this.entries) { + if (entry.expiresAt !== null && entry.expiresAt <= now) { + this.entries.delete(id); + } + } + + while (this.entries.size > this.limit) { + const oldestInactive = Array.from(this.entries).find(([, entry]) => entry.expiresAt !== null); + if (!oldestInactive) break; + this.entries.delete(oldestInactive[0]); + } + } +} + +globalThis.WarmTerminalCache = WarmTerminalCache; + // Z-index base values for layered floating windows const ZINDEX_SUBAGENT_BASE = 1000; const ZINDEX_PLAN_SUBAGENT_BASE = 1100; diff --git a/test/codex-snapshot-replay.test.ts b/test/codex-snapshot-replay.test.ts index 4eade8e9..917d1c65 100644 --- a/test/codex-snapshot-replay.test.ts +++ b/test/codex-snapshot-replay.test.ts @@ -29,33 +29,45 @@ describe('xterm snapshot/replay (codex tab-switch)', () => { it('declares the snapshot-restore flag before selectSession uses it', () => { const source = appSource(); const selectStart = source.indexOf('async selectSession(sessionId, options = {})'); + const warmDeclaration = source.indexOf( + 'const canWarmRestore = targetWasReplayable && snapshotWasInMemory;', + selectStart + ); const declaration = source.indexOf('let restoredSnapshot = false;', selectStart); - const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart); + const snapshotBranch = source.indexOf( + "if (snapshot && (canWarmRestore || !sessionIsBusy) && session?.mode !== 'shell')", + selectStart + ); const rewriteDecision = source.indexOf( 'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer', selectStart ); expect(selectStart).toBeGreaterThan(-1); + expect(warmDeclaration).toBeGreaterThan(selectStart); + expect(warmDeclaration).toBeLessThan(snapshotBranch); expect(declaration).toBeGreaterThan(selectStart); expect(declaration).toBeLessThan(snapshotBranch); expect(declaration).toBeLessThan(rewriteDecision); }); - it('uses xterm snapshots as first paint but still fetches the canonical terminal frame', () => { + it('uses cold xterm snapshots as first paint before fetching the canonical terminal frame', () => { const source = appSource(); const snapshotRestore = source.indexOf('SNAPSHOT_RESTORE:'); const cacheRestore = source.indexOf('Instant cache restore', snapshotRestore); const fetchStart = source.indexOf("FETCH_START'", snapshotRestore); + const fetchGate = source.lastIndexOf('if (!usedWarmRestore)', fetchStart); const needsRewrite = source.indexOf('const needsRewrite', fetchStart); const snapshotBlock = source.slice(snapshotRestore, cacheRestore); const postSnapshotRestore = source.slice(snapshotRestore, needsRewrite + 160); expect(snapshotRestore).toBeGreaterThan(-1); expect(cacheRestore).toBeGreaterThan(snapshotRestore); + expect(fetchGate).toBeGreaterThan(cacheRestore); expect(fetchStart).toBeGreaterThan(cacheRestore); expect(needsRewrite).toBeGreaterThan(fetchStart); - // Snapshot restore must NOT short-circuit the canonical fetch. + // A cold snapshot restore must not finish the load before the guarded + // canonical fetch. Valid warm replay is covered by warm-terminal-cache.test. expect(snapshotBlock).not.toContain('this._finishBufferLoad();'); expect(postSnapshotRestore).toContain('restoredSnapshot'); expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer'); diff --git a/test/warm-terminal-cache.test.ts b/test/warm-terminal-cache.test.ts new file mode 100644 index 00000000..18fcaf1f --- /dev/null +++ b/test/warm-terminal-cache.test.ts @@ -0,0 +1,201 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +type WarmTerminalCache = { + activate: (sessionId: string, now?: number) => void; + append: (sessionId: string, data: string, now?: number) => boolean; + consume: (sessionId: string, now?: number) => string | null; + confirmDelivery: (sessionId: string, now?: number) => void; + confirmSubscriptions: (sessionIds: string[], now?: number) => void; + generation: (sessionId: string, now?: number) => number | null; + ids: (now?: number) => string[]; + invalidateReplay: (sessionId: string, now?: number) => void; + isReplayable: (sessionId: string, now?: number) => boolean; + isWarm: (sessionId: string, now?: number) => boolean; + markCanonical: (sessionId: string, now?: number) => void; + nextExpiryDelay: (now?: number) => number | null; + remove: (sessionId: string) => void; +}; + +type WarmTerminalCacheConstructor = new (options?: { + limit?: number; + ttlMs?: number; + maxDeltaChars?: number; +}) => WarmTerminalCache; + +function loadWarmTerminalCache(): WarmTerminalCacheConstructor { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + const context = vm.createContext({ console, setTimeout, clearTimeout }); + vm.runInContext(source, context, { filename: 'constants.js' }); + return (context as unknown as { WarmTerminalCache: WarmTerminalCacheConstructor }).WarmTerminalCache; +} + +describe('WarmTerminalCache', () => { + it('keeps the active session and a bounded recent-session range', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ limit: 3, ttlMs: 30_000 }); + + cache.activate('a', 0); + cache.activate('b', 1_000); + cache.activate('c', 2_000); + cache.activate('d', 3_000); + + expect(cache.ids(3_000)).toEqual(['b', 'c', 'd']); + expect(cache.isWarm('a', 3_000)).toBe(false); + expect(cache.isWarm('d', 60_000)).toBe(true); + }); + + it('expires inactive sessions without expiring the active session', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ ttlMs: 30_000 }); + + cache.activate('a', 10_000); + cache.activate('b', 20_000); + + expect(cache.nextExpiryDelay(20_000)).toBe(30_000); + expect(cache.ids(49_999)).toEqual(['a', 'b']); + expect(cache.ids(50_000)).toEqual(['b']); + expect(cache.nextExpiryDelay(50_000)).toBeNull(); + }); + + it('replays inactive deltas once and in arrival order', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ maxDeltaChars: 32 }); + + cache.activate('a', 0); + cache.confirmSubscriptions(['a'], 0); + cache.markCanonical('a', 0); + cache.activate('b', 1); + + cache.confirmSubscriptions(['a', 'b'], 1); + expect(cache.isReplayable('a', 1)).toBe(true); + expect(cache.append('a', 'first-', 2)).toBe(true); + expect(cache.append('a', 'second', 3)).toBe(true); + expect(cache.consume('a', 4)).toBe('first-second'); + expect(cache.consume('a', 5)).toBe(''); + }); + + it('does not retroactively validate a snapshot from a subscription gap', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache(); + + cache.activate('a', 0); + cache.activate('b', 1); + cache.confirmSubscriptions(['a', 'b'], 2); + cache.confirmDelivery('a', 3); + + expect(cache.isReplayable('a', 3)).toBe(false); + + cache.activate('a', 4); + cache.markCanonical('a', 4); + cache.activate('b', 5); + expect(cache.isReplayable('a', 5)).toBe(true); + }); + + it('invalidates an in-flight replay without dropping subscription coverage', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache(); + + cache.activate('a', 0); + cache.confirmSubscriptions(['a'], 0); + cache.markCanonical('a', 0); + cache.activate('b', 1); + const generation = cache.generation('a', 1); + + cache.append('a', 'stale', 2); + cache.invalidateReplay('a', 3); + + expect(cache.generation('a', 3)).not.toBe(generation); + expect(cache.isReplayable('a', 3)).toBe(false); + expect(cache.consume('a', 3)).toBe(''); + + cache.activate('a', 4); + cache.markCanonical('a', 4); + cache.activate('b', 5); + expect(cache.isReplayable('a', 5)).toBe(true); + }); + + it('does not snapshot a session while canonical state is pending', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache(); + + cache.activate('a', 0); + cache.confirmSubscriptions(['a'], 0); + cache.activate('b', 1); + expect(cache.isReplayable('a', 1)).toBe(false); + + cache.activate('a', 2); + cache.markCanonical('a', 3); + cache.activate('b', 4); + expect(cache.isReplayable('a', 4)).toBe(true); + }); + + it('invalidates an overflowing delta and recovers on explicit activation', () => { + const WarmTerminalCache = loadWarmTerminalCache(); + const cache = new WarmTerminalCache({ maxDeltaChars: 8 }); + + cache.activate('a', 0); + cache.activate('b', 1); + expect(cache.append('a', '1234', 2)).toBe(true); + expect(cache.append('a', '56789', 3)).toBe(false); + expect(cache.isWarm('a', 3)).toBe(false); + expect(cache.consume('a', 3)).toBeNull(); + + cache.activate('a', 4); + expect(cache.isWarm('a', 4)).toBe(true); + expect(cache.isReplayable('a', 4)).toBe(false); + expect(cache.consume('a', 4)).toBe(''); + }); + + it('is wired into inactive delivery and skips the canonical fetch only for a valid warm restore', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const inactiveDelivery = source.indexOf('else if (this._warmTerminalCache.isWarm(data.id))'); + const perTabTransport = source.indexOf('`${this._clientId}:${this._wsTabNonce}`'); + const explicitSubscription = source.indexOf('const body = JSON.stringify({'); + const explicitSubscriptionBody = source.slice(explicitSubscription, explicitSubscription + 180); + const explicitQuery = source.indexOf("_sseParams.set('sessions', terminalSessions.join(','));"); + const subscriptionAck = source.indexOf('this._warmTerminalCache.confirmSubscriptions(sessions);'); + const targetCheck = source.indexOf('const targetWasReplayable = this._warmTerminalCache.isReplayable(sessionId);'); + const deltaReplay = source.indexOf('const warmDelta = this._warmTerminalCache.consume(sessionId);', targetCheck); + const replayGenerationCheck = source.indexOf( + 'this._warmTerminalCache.generation(sessionId) !== targetWarmGeneration', + targetCheck + ); + const fetchStart = source.indexOf("FETCH_START'", deltaReplay); + const fetchGate = source.lastIndexOf('if (!usedWarmRestore)', fetchStart); + const canonicalResponse = source.indexOf( + "Object.prototype.hasOwnProperty.call(data, 'terminalBuffer')", + fetchStart + ); + const canonicalMark = source.indexOf( + 'if (canonicalStateReady) this._warmTerminalCache.markCanonical(sessionId);', + canonicalResponse + ); + const recoveryInvalidation = source.indexOf('this._invalidateInactiveWarmTerminalSessions();'); + const targetedRecovery = source.lastIndexOf('this._dropWarmTerminalSession(data.id, true);', recoveryInvalidation); + const inactiveClear = source.indexOf('if (data.id !== this.activeSessionId)', recoveryInvalidation); + const inactiveDrop = source.indexOf('this._dropWarmTerminalSession(data.id, true);', inactiveClear); + + expect(inactiveDelivery).toBeGreaterThan(-1); + expect(perTabTransport).toBeGreaterThan(-1); + expect(explicitSubscription).toBeGreaterThan(-1); + expect(explicitSubscriptionBody).toContain('sessions,'); + expect(explicitQuery).toBeGreaterThan(-1); + expect(subscriptionAck).toBeGreaterThan(-1); + expect(targetCheck).toBeGreaterThan(-1); + expect(deltaReplay).toBeGreaterThan(targetCheck); + expect(replayGenerationCheck).toBeGreaterThan(targetCheck); + expect(replayGenerationCheck).toBeLessThan(deltaReplay); + expect(fetchGate).toBeGreaterThan(deltaReplay); + expect(fetchStart).toBeGreaterThan(fetchGate); + expect(canonicalResponse).toBeGreaterThan(fetchStart); + expect(canonicalMark).toBeGreaterThan(canonicalResponse); + expect(recoveryInvalidation).toBeGreaterThan(-1); + expect(targetedRecovery).toBeGreaterThan(-1); + expect(targetedRecovery).toBeLessThan(recoveryInvalidation); + expect(inactiveClear).toBeGreaterThan(recoveryInvalidation); + expect(inactiveDrop).toBeGreaterThan(inactiveClear); + }); +});