diff --git a/CLAUDE.md b/CLAUDE.md index 35e0addc9..c272efde6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit) -**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection always starts from a bounded 1 MiB `?tail=` window and loads the rest only when **Load full history** is pressed. Ordinary Shell scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set); Shell selection and automatic drop recovery always use a bounded 1 MiB `?tail=` window. Shell loads the rest only when **Load full history** is pressed; ordinary scrolling must not trigger a multi-megabyte reset+replay on xterm's main thread. Other modes may re-pull at the TOP (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). Live writes are one-chunk-in-flight, released by xterm's parse callback, so xterm's private queue cannot bypass the browser's 128 KiB render cap. While WebSocket owns terminal I/O, duplicate SSE terminal events are dropped before JSON parsing, and recovery is single-flight per active session. ⚠️ A full re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2cf6c08cd..492673730 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -92,7 +92,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### Full-scrollback replay -**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is therefore explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`. +**Full-scrollback replay** (COD-164/#148, reworked for #205): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). The first load of each non-shell TUI session per page requests `full=1` (`_fullHistoryLoaded` Set in app.js — the old one-shot `_initialFullBufferLoad` flag was consumed by whichever tab auto-selected, leaving every other TUI tab one frame of history). Shell sessions instead load a bounded 1 MiB `?tail=` window on every selection and automatic drop recovery: a 100k-line shell capture can be tens of MiB, and automatically parsing it makes tab-switch latency scale with the entire session. Shell full history is explicit-button-only; reaching the top during an ordinary wheel/touch gesture must not reset xterm and replay the multi-megabyte capture on its main thread. Other modes may still re-pull `full=1` at the TOP, and pressing **Load full history** forces the request for any recoverably truncated session (`_maybeRefetchFullHistory`, 4s per-session gesture cooldown, in-flight + tab-switch guards, viewport position held across the replay); Shell full pulls are not retained in the tab cache, so the next switch stays bounded. Chunked replay enqueues 32 KiB pieces across safe yields, appends an xterm parse marker, then releases the live-output gate; output arriving after that release stays ordered behind the snapshot, while the marker callback supplies accurate parse timing without extending the pre-existing queued-event discard window. Live output is separately one-chunk-in-flight: xterm's callback releases each 32/64 KiB write before the next is submitted, keeping the remainder in the app queue where the 128 KiB cap can observe it instead of hiding an unbounded backlog in xterm's private WriteBuffer. While WebSocket owns terminal I/O, parallel SSE terminal/output-recovery events are discarded before JSON parsing; fallback recovery is single-flight per active session so backpressure cannot start overlapping reset+replay cycles. The route exposes capture/prepare totals in `Server-Timing`, while `[TERMINAL-PERF]` separates TTFB, body/JSON, reset+parse and total time for both selection and on-demand full pulls; parse completion is not a browser compositor/GPU paint measurement. The re-pull exists because xterm's buffer is only a WINDOW onto tmux's history and two things shrink it: tmux coalesces bursty output into pane REPAINTS that overwrite rows instead of emitting linefeeds (measured: a 60-line burst added 1 row of browser scrollback and destroyed 34), and a tab switch replays only the visible frame. tmux's own history is intact throughout — the browser just has to ask for it again. On-demand rather than automatic because at a 100k history limit the capture can be megabytes. ⚠️ **The re-pull must never DOWNGRADE the buffer** (#205 round 2): the same reasoning that makes it a win for a shell pane makes it destructive for a repaint-mode CLI pane, where tmux keeps no history of its own (`history_size≈0` measured for a Claude pane) and the capture is roughly ONE frame while xterm may hold hundreds of rows of replayed frames — `_resetTerminalForReplay()` + rewrite then deletes history mid-scroll ("goes back a bit, repeats blocks, gets worse the further up I go"; measured A/B on a live pane: 341 rows → 42 with the guard off). `_replayWouldShrinkBuffer()` (terminal-ui.js) estimates the capture's rendered rows — escape sequences stripped, `capture-pane -J` re-wrapping accounted for — and the pull is skipped when that is more than one screen short of `buffer.active.length`. The one-screen tolerance matters: both sides are estimates (the buffer length counts trailing blank rows), so only a clear downgrade is refused. A refused session joins `_fullHistoryRepullUseless`, raising its cooldown from 4s to 60s so a hollow pane stops re-fetching megabytes on every scroll-up. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-scroll-routing.test.ts`, `test/terminal-flush-budget.test.ts`. ### Terminal scrollback: strip flavors and wheel/touch forwarding diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 614c9521c..d51308690 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -136,7 +136,7 @@ Worth knowing: - **Scrollback.** Agent/TUI sessions pull their entire tmux scrollback on first open. Shell sessions open from a bounded recent tail so a large transcript cannot stall tab switching; press **Load full history** to pull the rest explicitly. Ordinary Shell scrolling - stays within the bounded browser buffer so dragging upward remains responsive. + and automatic output recovery stay within the bounded browser buffer. - **Wheel and touch scrolling** are forwarded into Claude's own transcript on recent Claude versions, so the wheel scrolls the conversation rather than the terminal. `Shift+Wheel` is always local scrollback. Other CLIs scroll locally. diff --git a/src/web/public/app.js b/src/web/public/app.js index 52e97b149..2a5ce4700 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -678,12 +678,19 @@ class CodemanApp { // Terminal write batching with DEC 2026 sync support this.pendingWrites = []; this.writeFrameScheduled = false; + // xterm.write() parses asynchronously. Keep at most one live-output chunk + // inside xterm so its private WriteBuffer cannot bypass our 128KB cap. + this._terminalWriteInFlight = false; + this._terminalWriteInFlightBytes = 0; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll this.syncWaitTimeout = null; // Timeout for incomplete sync blocks this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes this._loadBufferQueue = null; // queued SSE events during buffer load this._bufferLoadSeq = 0; this._bufferLoadOwner = null; + // Single-flight token for terminal buffer recovery. The identity check also + // lets a session switch invalidate an older fetch without blocking the new tab. + this._terminalRefreshOwner = null; // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; @@ -1605,7 +1612,15 @@ class CodemanApp { this._sseHandlerWrappers = new Map(); for (const [event, method] of _SSE_HANDLER_MAP) { const fn = this[method]; + const wsOwnsTerminal = + method === '_onSSETerminal' || + method === '_onSSENeedsRefresh' || + method === '_onSSEClearTerminal'; this._sseHandlerWrappers.set(event, (e) => { + // While WS owns terminal I/O, the parallel SSE stream is redundant. + // Drop it before JSON.parse so a busy terminal cannot turn duplicate + // SSE traffic/backpressure into another expensive buffer replay. + if (wsOwnsTerminal && this._wsReady) return; try { fn.call(this, e.data ? JSON.parse(e.data) : {}); } catch (err) { @@ -1852,18 +1867,18 @@ class CodemanApp { if (this.sessions.size === 0) this.stopSystemStatsPolling(); } - // SSE wrappers — skip terminal events when WebSocket is delivering for this session. + // SSE wrappers — skip terminal events while WebSocket owns active terminal I/O. // WS handler calls the underlying _onSession* methods directly. _onSSETerminal(data) { - if (this._wsReady && this._wsSessionId === data.id) return; + if (this._wsReady) return; this._onSessionTerminal(data); } _onSSENeedsRefresh(data) { - if (this._wsReady && this._wsSessionId === data?.id) return; + if (this._wsReady) return; this._onSessionNeedsRefresh(data); } _onSSEClearTerminal(data) { - if (this._wsReady && this._wsSessionId === data?.id) return; + if (this._wsReady) return; this._onSessionClearTerminal(data); } @@ -1871,15 +1886,15 @@ class CodemanApp { if (data.id === this.activeSessionId) { if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`); - // Hard cap: track total bytes queued in render buffers (pendingWrites + - // flickerFilterBuffer). When rAF is throttled (tab - // backgrounded, GPU busy), data accumulates with no flush, reaching - // 889KB+ and freezing Chrome for minutes. Drop data beyond 128KB and - // schedule a buffer reload to recover the display once the burst subsides. + // Hard cap all app-owned render queues plus the one xterm chunk currently + // parsing. Check the incoming frame too; otherwise a single large frame can + // jump over the cap. Dropped data is recovered from the canonical buffer. const queued = (this.pendingWrites?.reduce((s, w) => s + w.length, 0) || 0) - + (this.flickerFilterBuffer?.length || 0); - if (queued > 131072) { // 128KB — drop to prevent accumulation - // Schedule a self-recovery: reload the full terminal buffer once the + + (this.flickerFilterBuffer?.length || 0) + + (this._loadBufferQueue?.reduce((s, w) => s + w.length, 0) || 0) + + (this._terminalWriteInFlightBytes || 0); + if (queued + data.data.length > 131072) { // 128KB — drop to prevent accumulation + // Schedule a self-recovery once the // queue drains (debounced to avoid hammering the API during sustained bursts). if (!this._clientDropRecoveryTimer) { this._clientDropRecoveryTimer = setTimeout(() => { @@ -2354,33 +2369,38 @@ class CodemanApp { } } - async _onSessionNeedsRefresh() { + async _onSessionNeedsRefresh(event = {}) { // Server sends this after SSE backpressure clears — terminal data was dropped, // so reload the buffer to recover from any display corruption. - if (!this.activeSessionId || !this.terminal) return; + const sessionId = this.activeSessionId; + if (event?.id && event.id !== sessionId) return; + if (!sessionId || !this.terminal) return; // Skip if buffer load already in progress — avoids competing clear+rewrite cycles if (this._isLoadingBuffer) return; - const sessionId = this.activeSessionId; + if (this._terminalRefreshOwner?.sessionId === sessionId) return; + const refreshOwner = { sessionId }; + this._terminalRefreshOwner = refreshOwner; try { - // Recovery should restore the WHOLE picture, so ask for full history - // rather than a tail. Measured on a 900-line shell pane: the tail rewrite - // replaced an 869-row buffer with 158 rows, so every backpressure refresh - // silently destroyed most of the scrollback it was meant to repair. - // - // A repaint-mode pane is the opposite case (tmux keeps ~one frame for it), - // so the full capture can be SMALLER than what xterm already holds. Reuse - // the same downgrade guard as the scroll-to-top re-pull and fall back to - // the historical tail there, leaving that case exactly as it was. - let res = await fetch(`/api/sessions/${sessionId}/terminal?full=1`); + // A shell can retain a multi-megabyte/100k-line tmux history. Automatic + // recovery stays bounded just like normal shell selection; only the + // explicit "Load full history" action is allowed to pay for a full replay. + // 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 fetch( + useFullHistory + ? `/api/sessions/${sessionId}/terminal?full=1` + : `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}` + ); let data = (await res.json())?.data ?? {}; - if (data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) { + if (useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer(data.terminalBuffer)) { res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); data = (await res.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 // fetches wide in the fallback case, so this guard is not optional. - if (this.activeSessionId !== sessionId) return; + if (this.activeSessionId !== sessionId || this._terminalRefreshOwner !== refreshOwner) return; if (data.terminalBuffer) { // This refresh is SERVER-triggered, so a user quietly reading scrollback // did not ask for it and must not be dragged to the bottom by it (#259). @@ -2410,6 +2430,8 @@ class CodemanApp { } } catch (err) { console.error('needsRefresh reload failed:', err); + } finally { + if (this._terminalRefreshOwner === refreshOwner) this._terminalRefreshOwner = null; } } @@ -3463,11 +3485,13 @@ class CodemanApp { this.flickerFilterActive = false; // Clear pending terminal writes this._clearTimer('syncWaitTimeout'); + this._clearTimer('_clientDropRecoveryTimer'); this.pendingWrites = []; this.writeFrameScheduled = false; this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; + this._terminalRefreshOwner = null; // Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers) this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1; // Preserve local echo overlay text across SSE reconnect — just hide until @@ -5283,6 +5307,7 @@ class CodemanApp { this._tabCompletionBaseText = null; this._clearTimer('_tabCompletionFallback'); this._clearTimer('_clientDropRecoveryTimer'); + this._terminalRefreshOwner = null; // Clean up pending terminal writes to prevent old session data from appearing in new session this._clearTimer('syncWaitTimeout'); @@ -5603,6 +5628,7 @@ class CodemanApp { this.writeFrameScheduled = false; this._isLoadingBuffer = false; this._loadBufferQueue = null; + this._terminalRefreshOwner = null; this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1; this.activeSessionId = null; } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 79a593490..bdc433592 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -3167,7 +3167,7 @@ Object.assign(CodemanApp.prototype, { * arrived, which looked like truncated responses and idle shell commands. */ _scheduleTerminalWriteFlush() { - if (this.writeFrameScheduled || this.pendingWrites.length === 0) return; + if (this._terminalWriteInFlight || this.writeFrameScheduled || this.pendingWrites.length === 0) return; this.writeFrameScheduled = true; this._safeYield(() => { this.writeFrameScheduled = false; @@ -3358,7 +3358,7 @@ Object.assign(CodemanApp.prototype, { * Strips markers and writes content atomically within a single frame. */ flushPendingWrites() { - if (this.pendingWrites.length === 0 || !this.terminal) return; + if (this._terminalWriteInFlight || this.pendingWrites.length === 0 || !this.terminal) return; const _t0 = performance.now(); // xterm.js 6.0+ natively handles DEC 2026 synchronized output markers. @@ -3389,14 +3389,25 @@ Object.assign(CodemanApp.prototype, { const preserveViewportY = this.terminal.buffer?.active && !this.isTerminalAtBottom() ? this.terminal.buffer.active.viewportY : null; - if (_joinedLen <= MAX_FRAME_BYTES) { - this.terminal.write(joined); - } else { - // Write first chunk now, defer rest to next frame - this.terminal.write(joined.slice(0, MAX_FRAME_BYTES)); + const writeChunk = joined.slice(0, MAX_FRAME_BYTES); + if (_joinedLen > MAX_FRAME_BYTES) { + // Keep the remainder app-side where the 128KB cap can see it. The next + // chunk is scheduled only after xterm confirms this one was parsed. this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES)); deferred = true; - this._scheduleTerminalWriteFlush(); + } + this._terminalWriteInFlight = true; + this._terminalWriteInFlightBytes = writeChunk.length; + try { + this.terminal.write(writeChunk, () => { + this._terminalWriteInFlight = false; + this._terminalWriteInFlightBytes = 0; + this._scheduleTerminalWriteFlush(); + }); + } catch (err) { + this._terminalWriteInFlight = false; + this._terminalWriteInFlightBytes = 0; + throw err; } if ( preserveViewportY !== null && diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index af330777c..8704d683e 100644 --- a/test/opencode-resize.test.ts +++ b/test/opencode-resize.test.ts @@ -225,10 +225,11 @@ describe('OpenCode session initial resize', () => { await route.continue(); }); - // Dispatch the needsRefresh event directly on the EventSource - // (this is how the server sends SSE events — as named events) + // Exercise the SSE fallback path. While WebSocket owns terminal I/O these + // duplicate SSE terminal events are intentionally ignored. await page.evaluate((sid: string) => { - const app = (window as unknown as { app: { eventSource: EventSource } }).app; + const app = (window as unknown as { app: { eventSource: EventSource; _disconnectWs: () => void } }).app; + app._disconnectWs(); if (app.eventSource) { const event = new MessageEvent('session:needsRefresh', { data: JSON.stringify({ id: sid }), diff --git a/test/terminal-flush-budget.test.ts b/test/terminal-flush-budget.test.ts index 115682c4f..514e93da5 100644 --- a/test/terminal-flush-budget.test.ts +++ b/test/terminal-flush-budget.test.ts @@ -38,7 +38,10 @@ function loadTerminalUiHarness(mode: string) { app._workerYield = () => {}; app._chunkedWriteGen = 0; app.terminal = { - write: (data: string) => writes.push(data), + write: (data: string, callback?: () => void) => { + writes.push(data); + callback?.(); + }, scrollToBottom: () => {}, scrollToLine: () => {}, }; @@ -46,7 +49,90 @@ function loadTerminalUiHarness(mode: string) { return { app, writes }; } +function loadAppHarness() { + const dir = resolve(import.meta.dirname, '../src/web/public'); + const fetchMock = vi.fn(); + const context = vm.createContext({ + console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() }, + performance: { now: () => 0 }, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + WebSocket: { OPEN: 1 }, + fetch: fetchMock, + document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + MobileDetection: { isTouchDevice: () => false }, + }); + const constants = readFileSync(resolve(dir, 'constants.js'), 'utf8'); + const appSource = readFileSync(resolve(dir, 'app.js'), 'utf8'); + vm.runInContext(`${constants}\n${appSource}\nglobalThis.__CodemanApp = CodemanApp;`, context); + const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp; + return { CodemanApp, fetchMock }; +} + describe('terminal flush budget', () => { + it('counts incoming, loading, and xterm in-flight bytes before accepting live output', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8'); + const start = source.indexOf('_onSessionTerminal(data)'); + const body = source.slice(start, source.indexOf('\n // ═', start)); + + expect(body).toContain('this._loadBufferQueue?.reduce'); + expect(body).toContain('this._terminalWriteInFlightBytes || 0'); + expect(body).toContain('queued + data.data.length > 131072'); + }); + + it('drops redundant SSE terminal events whenever WebSocket owns terminal I/O', () => { + const { CodemanApp } = loadAppHarness(); + const app = Object.create(CodemanApp.prototype) as any; + app._wsReady = true; + app._onSessionTerminal = vi.fn(); + app._onSessionNeedsRefresh = vi.fn(); + app._onSessionClearTerminal = vi.fn(); + + app._onSSETerminal({ id: 'session-1', data: 'duplicate' }); + app._onSSENeedsRefresh({}); + app._onSSEClearTerminal({ id: 'session-1' }); + + expect(app._onSessionTerminal).not.toHaveBeenCalled(); + expect(app._onSessionNeedsRefresh).not.toHaveBeenCalled(); + expect(app._onSessionClearTerminal).not.toHaveBeenCalled(); + }); + + it('runs at most one buffer recovery per session and ignores stale-session events', async () => { + const { CodemanApp, fetchMock } = loadAppHarness(); + const app = Object.create(CodemanApp.prototype) as any; + app.activeSessionId = 'session-1'; + app.sessions = new Map([['session-1', { mode: 'shell' }]]); + app.terminal = {}; + app._isLoadingBuffer = false; + app._terminalRefreshOwner = null; + + let releaseFetch!: () => void; + fetchMock.mockImplementation( + () => + new Promise((resolveFetch) => { + releaseFetch = () => resolveFetch({ json: async () => ({ data: { terminalBuffer: '' } }) }); + }) + ); + + await app._onSessionNeedsRefresh({ id: 'stale-session' }); + expect(fetchMock).not.toHaveBeenCalled(); + + const first = app._onSessionNeedsRefresh({ id: 'session-1' }); + const duplicate = app._onSessionNeedsRefresh({ id: 'session-1' }); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledWith('/api/sessions/session-1/terminal?tail=1048576'); + + releaseFetch(); + await Promise.all([first, duplicate]); + expect(app._terminalRefreshOwner).toBe(null); + }); + it('drains a large final batch without waiting for unrelated terminal output', () => { const { app, writes } = loadTerminalUiHarness('codex'); const scheduled: Array<() => void> = []; @@ -89,6 +175,32 @@ describe('terminal flush budget', () => { expect(app.pendingWrites.join('')).toHaveLength(32 * 1024); }); + it('waits for xterm to parse a live chunk before submitting the next one', () => { + const { app, writes } = loadTerminalUiHarness('shell'); + const scheduled: Array<() => void> = []; + let parsed: (() => void) | undefined; + app._safeYield = (callback: () => void) => scheduled.push(callback); + app.isTerminalAtBottom = () => true; + app.terminal.write = (data: string, callback?: () => void) => { + writes.push(data); + parsed = callback; + }; + + app.batchTerminalWrite('x'.repeat(96 * 1024)); + scheduled.shift()?.(); + + expect(writes.map((write) => write.length)).toEqual([64 * 1024]); + expect(app.pendingWrites.join('')).toHaveLength(32 * 1024); + expect(scheduled).toHaveLength(0); + expect(app._terminalWriteInFlightBytes).toBe(64 * 1024); + + parsed?.(); + + expect(scheduled).toHaveLength(1); + scheduled.shift()?.(); + expect(writes.map((write) => write.length)).toEqual([64 * 1024, 32 * 1024]); + }); + it('releases the live-output gate but waits for xterm to parse a small replay', async () => { const { app, writes } = loadTerminalUiHarness('codex'); let writeDone: (() => void) | undefined; diff --git a/test/terminal-scroll-intent.test.ts b/test/terminal-scroll-intent.test.ts index 717e34d63..293605eea 100644 --- a/test/terminal-scroll-intent.test.ts +++ b/test/terminal-scroll-intent.test.ts @@ -191,7 +191,7 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => { it('is wired into the refresh path instead of an unconditional scrollToBottom', () => { const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); - const start = app.indexOf('async _onSessionNeedsRefresh()'); + const start = app.indexOf('async _onSessionNeedsRefresh('); expect(start).toBeGreaterThan(-1); const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start)); expect(body).toContain('computeRewriteScrollLine'); @@ -199,18 +199,16 @@ describe('backpressure refresh keeps a reader in place (issue #259)', () => { expect(body).toContain('this.terminal.scrollToLine(target)'); }); - it('recovers FULL history, guarded against a repaint-pane downgrade', () => { - // Measured before the fix: this path rewrote an 869-row buffer from a 1MB - // tail and left 158 rows, so the refresh meant to REPAIR the terminal was - // destroying most of its scrollback. It asks for full history now, and - // falls back to the tail only when the full capture would shrink the buffer - // (a repaint-mode pane keeps roughly one frame in tmux). + it('keeps shell recovery bounded and full TUI recovery downgrade-safe', () => { + // A shell's automatic recovery must not reset+replay a multi-megabyte tmux + // history on xterm's main thread. TUI modes still recover full history and + // fall back when a repaint-mode pane would shrink the browser buffer. const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); - const start = app.indexOf('async _onSessionNeedsRefresh()'); + const start = app.indexOf('async _onSessionNeedsRefresh('); const body = app.slice(start, app.indexOf('\n async _onSessionClearTerminal', start)); + expect(body).toContain("const useFullHistory = this.sessions.get(sessionId)?.mode !== 'shell'"); expect(body).toContain('terminal?full=1'); - expect(body).toContain('this._replayWouldShrinkBuffer(data.terminalBuffer)'); - // The tail must survive as the fallback, not vanish. expect(body).toContain('tail=${TERMINAL_TAIL_SIZE}'); + expect(body).toContain('useFullHistory && data.terminalBuffer && this._replayWouldShrinkBuffer'); }); });