From e896dd3eb7fbfdeedf2279543a9bc137468a9402 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 26 May 2026 16:01:56 -0700 Subject: [PATCH] Recover hidden terminal output from main-owned state (#2848) * fix: avoid renderer stalls on terminal backlogs * Recover hidden terminal output from main state --- docs/terminal-main-owned-state.md | 88 +++ src/main/ipc/pty.test.ts | 26 + src/main/ipc/pty.ts | 100 +++- .../mobile-subscribe-integration.test.ts | 92 ++++ src/main/runtime/orca-runtime.ts | 45 +- src/main/ssh/ssh-relay-session.ts | 7 +- src/preload/api-types.ts | 8 +- src/preload/index.ts | 16 +- .../terminal-pane/pty-connection.test.ts | 515 +++++++++++++++++- .../terminal-pane/pty-connection.ts | 417 +++++++++++++- .../terminal-pane/pty-dispatcher.ts | 22 +- .../terminal-pane/pty-transport.test.ts | 31 ++ .../components/terminal-pane/pty-transport.ts | 109 +++- .../use-terminal-pane-global-effects.test.ts | 15 +- .../use-terminal-pane-global-effects.ts | 16 +- ...-terminal-scroll-visibility-memory.test.ts | 107 ++++ .../use-terminal-scroll-visibility-memory.ts | 7 +- .../pane-terminal-output-scheduler.test.ts | 136 +++++ .../pane-terminal-output-scheduler.ts | 268 +++++++-- src/renderer/src/web/web-preload-api.ts | 1 + tests/e2e/terminal-output-scheduler.spec.ts | 72 +++ 21 files changed, 1987 insertions(+), 111 deletions(-) create mode 100644 docs/terminal-main-owned-state.md create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md new file mode 100644 index 000000000..a9c3165e7 --- /dev/null +++ b/docs/terminal-main-owned-state.md @@ -0,0 +1,88 @@ +# Terminal Main-Owned State + +## Problem + +Hidden and background terminal panes cannot rely on renderer memory as the only +place that terminal output exists. Chromium may throttle a hidden Electron +document while the PTY continues producing bytes. If the renderer retains every +hidden byte until xterm can parse it, a noisy terminal can pin large strings in +renderer memory and stall or crash the app. + +The renderer must keep a hard memory bound, but the user-visible terminal state +should still be recoverable when the pane becomes visible again. + +## Reference Pattern + +Use a host-owned terminal model as the recovery source and treat the renderer as +a view: + +- The host process receives PTY bytes first and appends them to a bounded + headless terminal model. +- The renderer writes visible output directly for low latency. +- Hidden renderer queues are bounded. When they overflow, the renderer marks its + xterm as stale and drops further hidden bytes instead of retaining them. +- On visibility resume, the renderer asks the host for a serialized snapshot, + clears its xterm, replays that snapshot, then resumes live writes. +- Live output racing with restore carries a monotonic sequence number, so bytes + already included in the snapshot are not written twice. + +This gives the foreground path the same latency profile as today, bounds hidden +renderer memory, and preserves terminal state from the host-owned model instead +of depending on an unbounded renderer backlog. + +## Requirements + +- Renderer hidden-output memory is capped per terminal. +- A hidden flood must not grow the renderer by retaining strings or chunk arrays + past the cap. +- Restoring a stale renderer must use main/runtime state for local, daemon, and + SSH PTYs. +- Remote runtime PTYs that do not have local main-owned state must keep the + existing warning fallback rather than pretending recovery is available. +- Restore must avoid xterm query auto-replies reaching the shell. +- Clear, resize, exit, and pane disposal must clean up recovery state. +- The SSH path must participate in the same sequencing and snapshot behavior as + local PTYs. + +## Chosen Design + +The existing runtime headless terminal is the main-owned model. Every PTY byte +already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for +local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated +and can serialize it. + +The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: + +1. The scheduler replaces the queued backlog with a small warning fallback. +2. The terminal connection marks that pane as needing main-state recovery. +3. Further hidden bytes for that stale pane are not enqueued in the renderer. +4. When the pane/document becomes visible, the connection requests a main-owned + snapshot, clears xterm, replays the snapshot under the replay guard, and + sends the normal post-reattach reset. +5. Live foreground chunks that arrive while restore is in flight are retained in + a small bounded queue. After snapshot replay, sequence numbers decide which + chunks were already included and which still need to be written. + +If the main snapshot is unavailable, the small warning fallback remains the +visible behavior. That is the expected fallback for surfaces without local +main-owned terminal state. + +## Non-Goals + +- This does not add a durable full byte log. The host-owned model is bounded + terminal state, not an infinite transcript. +- This does not throttle the PTY producer. Producer backpressure can be added + later with ACKs if we need to reduce host-side work during extreme floods. +- This does not change foreground terminal write latency. + +## Verification + +- Unit-test scheduler overflow callback behavior and chunk-count bounding. +- Unit-test renderer recovery so hidden overflow drops renderer backlog, fetches + the main snapshot on visibility/foreground resume, and does not duplicate + sequenced live output. +- Unit-test main IPC snapshot sequencing. +- Run terminal scheduler and PTY connection tests. +- Run typecheck. +- Exercise the Electron hidden-flood repro and confirm renderer memory stays + bounded while the recovered terminal shows the host-owned terminal state. diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 22fed6c7c..3fc08a71d 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3734,4 +3734,30 @@ describe('registerPtyHandlers', () => { await expect(pending).resolves.toBeNull() }) }) + + describe('main buffer snapshot dispatch', () => { + it('returns a sequenced main-owned terminal snapshot with clamped scrollback', async () => { + const runtime = { + setPtyController: vi.fn(), + serializeMainTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot\r\n', + cols: 120, + rows: 40, + seq: 42 + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'pty-1', + opts: { scrollbackRows: 999_999 } + }) + + expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', { + scrollbackRows: 50_000 + }) + expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, seq: 42 }) + }) + }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 358b284a0..c48a5ff8e 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -811,6 +811,7 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:declarePendingPaneSerializer') ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') + ipcMain.removeHandler('pty:getMainBufferSnapshot') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') @@ -872,7 +873,12 @@ export function registerPtyHandlers( // reduces IPC round-trips from hundreds/sec to ~120/sec under high // throughput. Keystroke echo/redraws bypass this below because agent TUIs // already spend tens of ms producing their redraw. - const pendingData = new Map() + type PendingPtyData = { + data: string + startSeq?: number + } + + const pendingData = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null const PTY_BATCH_INTERVAL_MS = 8 @@ -884,6 +890,40 @@ export function registerPtyHandlers( const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 + function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined { + return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined + } + + function makePtyDataPayload( + id: string, + data: string, + startSeq: number | undefined + ): { id: string; data: string; seq?: number; rawLength?: number } { + const payload: { id: string; data: string; seq?: number; rawLength?: number } = { id, data } + if (typeof startSeq === 'number') { + payload.seq = startSeq + data.length + payload.rawLength = data.length + } + return payload + } + + function appendPendingPtyData( + existing: PendingPtyData | undefined, + data: string, + startSeq: number | undefined + ): PendingPtyData { + if (!existing) { + return typeof startSeq === 'number' ? { data, startSeq } : { data } + } + const next: PendingPtyData = { data: existing.data + data } + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } else if (typeof startSeq === 'number') { + next.startSeq = startSeq + } + return next + } + function schedulePendingDataFlush(delayMs: number): void { if (flushTimer) { return @@ -903,14 +943,19 @@ export function registerPtyHandlers( if (!next) { break } - const [id, data] = next + const [id, pending] = next pendingData.delete(id) + const { data } = pending const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) if (remaining) { - pendingData.set(id, remaining) + const nextPending: PendingPtyData = { data: remaining } + if (typeof pending.startSeq === 'number') { + nextPending.startSeq = pending.startSeq + chunk.length + } + pendingData.set(id, nextPending) } - mainWindow.webContents.send('pty:data', { id, data: chunk }) + mainWindow.webContents.send('pty:data', makePtyDataPayload(id, chunk, pending.startSeq)) writes++ } if (pendingData.size > 0) { @@ -945,9 +990,10 @@ export function registerPtyHandlers( const isLocalProvider = localProvider instanceof LocalPtyProvider localDataUnsub = localProvider.onData((payload) => { - if (!isLocalProvider) { - runtime?.onPtyData(payload.id, payload.data, Date.now()) - } + const outputSeq = isLocalProvider + ? runtime?.getPtyOutputSequence(payload.id) + : runtime?.onPtyData(payload.id, payload.data, Date.now()) + const startSeq = getChunkStartSeq(outputSeq, payload.data) if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window // is gone. Without this, macOS app re-activation leaks orphaned timers @@ -960,7 +1006,8 @@ export function registerPtyHandlers( return } const existing = pendingData.get(payload.id) - const nextData = existing ? existing + payload.data : payload.data + const pending = appendPendingPtyData(existing, payload.data, startSeq) + const nextData = pending.data const lastInputAt = lastInputAtByPty.get(payload.id) const isInteractiveOutput = nextData.length <= INTERACTIVE_OUTPUT_MAX_CHARS && @@ -973,11 +1020,14 @@ export function registerPtyHandlers( // Waiting for the throughput batch timer adds visible input latency. mainWindow.webContents.send('pty:data', { id: payload.id, - data: nextData + data: nextData, + ...(typeof pending.startSeq === 'number' + ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } + : {}) }) return } - pendingData.set(payload.id, nextData) + pendingData.set(payload.id, pending) if (!flushTimer) { schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) } @@ -995,7 +1045,10 @@ export function registerPtyHandlers( // tears down the terminal on pty:exit before the batch timer fires. const remaining = pendingData.get(payload.id) if (remaining) { - mainWindow.webContents.send('pty:data', { id: payload.id, data: remaining }) + mainWindow.webContents.send( + 'pty:data', + makePtyDataPayload(payload.id, remaining.data, remaining.startSeq) + ) pendingData.delete(payload.id) } lastInputAtByPty.delete(payload.id) @@ -1356,6 +1409,31 @@ export function registerPtyHandlers( // ─── IPC Handlers (thin dispatch layer) ───────────────────────── + function normalizeSnapshotScrollbackRows(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) + } + + ipcMain.handle( + 'pty:getMainBufferSnapshot', + async ( + _event, + args: { id?: unknown; opts?: { scrollbackRows?: unknown } } + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) + try { + return await runtime.serializeMainTerminalBuffer(args.id, { scrollbackRows }) + } catch { + return null + } + } + ) + ipcMain.handle( 'pty:spawn', async ( diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index ffb6d2e1c..c37a930ea 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -135,6 +135,98 @@ describe('mobile subscribe integration', () => { vi.useRealTimers() }) + it('serializeMainTerminalBuffer reports the seq included in the headless snapshot', async () => { + vi.useRealTimers() + const { runtime } = createRuntime() + const first = 'one\r\n' + const second = 'two\r\n' + const third = 'three\r\n' + + runtime.onPtyData('pty-1', first, Date.now()) + const initialSnapshot = await runtime.serializeMainTerminalBuffer('pty-1') + expect(initialSnapshot?.seq).toBe(first.length) + + type HeadlessStateForTest = { + emulator: { write: (data: string) => Promise | void } + } + const headless = ( + runtime as unknown as { headlessTerminals: Map } + ).headlessTerminals.get('pty-1') + expect(headless).toBeDefined() + const originalWrite = headless!.emulator.write.bind(headless!.emulator) + const secondWriteGate: { release: (() => void) | null } = { release: null } + const secondWriteStarted = new Promise((resolve) => { + headless!.emulator.write = async (data: string): Promise => { + if (data === second) { + resolve() + await new Promise((release) => { + secondWriteGate.release = release + }) + } + await originalWrite(data) + } + }) + + try { + runtime.onPtyData('pty-1', second, Date.now()) + await secondWriteStarted + const racedSnapshot = runtime.serializeMainTerminalBuffer('pty-1') + runtime.onPtyData('pty-1', third, Date.now()) + if (!secondWriteGate.release) { + throw new Error('second write did not block') + } + secondWriteGate.release() + + const snapshot = await racedSnapshot + expect(snapshot?.seq).toBe(first.length + second.length) + expect(runtime.getPtyOutputSequence('pty-1')).toBe( + first.length + second.length + third.length + ) + + const finalSnapshot = await runtime.serializeMainTerminalBuffer('pty-1') + expect(finalSnapshot?.seq).toBe(first.length + second.length + third.length) + } finally { + headless!.emulator.write = originalWrite + secondWriteGate.release?.() + } + }) + + it('serializeMainTerminalBuffer returns an empty snapshot for an empty headless buffer', async () => { + const { runtime } = createRuntime() + type HeadlessStateForTest = { + emulator: { + isAlternateScreen: boolean + getSnapshot: (opts: { scrollbackRows?: number }) => { + rehydrateSequences: string + snapshotAnsi: string + cols: number + rows: number + } + } + outputSequence: number + writeChain: Promise + } + const runtimePrivate = runtime as unknown as { + headlessTerminals: Map + } + runtimePrivate.headlessTerminals.set('pty-empty', { + emulator: { + isAlternateScreen: false, + getSnapshot: () => ({ rehydrateSequences: '', snapshotAnsi: '', cols: 90, rows: 30 }) + }, + outputSequence: 17, + writeChain: Promise.resolve() + }) + + await expect(runtime.serializeMainTerminalBuffer('pty-empty')).resolves.toEqual({ + data: '', + cols: 90, + rows: 30, + seq: 17 + }) + await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull() + }) + it('handleMobileSubscribe resizes PTY to phone dims', async () => { const { runtime, ptySizes, resizes, notifications } = createRuntime() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 46e243b5c..e52cea9be 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -537,6 +537,9 @@ type RuntimePtyWorktreeRecord = { type RuntimeHeadlessTerminal = { emulator: HeadlessEmulator + // Why: serialize can race with newer writes appended to writeChain; return + // the seq actually painted into this emulator, not the latest PTY seq. + outputSequence: number writeChain: Promise } @@ -1038,6 +1041,7 @@ export class OrcaRuntimeService { private notificationListeners = new Set<(event: MobileNotificationEvent) => void>() private ptysById = new Map() private headlessTerminals = new Map() + private ptyOutputSequenceById = new Map() // Why: per-PTY hydration state guards against double-hydration. Keys: // 'pending' → maybeHydrateHeadlessFromRenderer is in flight // 'done' → hydration completed (success or skip); never run again @@ -2151,7 +2155,9 @@ export class OrcaRuntimeService { this.recordPtyWorktree(ptyId, worktreeId, { connected: true }) } - onPtyData(ptyId: string, data: string, at: number): void { + onPtyData(ptyId: string, data: string, at: number): number { + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length + this.ptyOutputSequenceById.set(ptyId, outputSequence) this.recentPtyOutputById.set( ptyId, `${this.recentPtyOutputById.get(ptyId) ?? ''}${data}`.slice(-RECENT_PTY_OUTPUT_LIMIT) @@ -2171,7 +2177,7 @@ export class OrcaRuntimeService { // that the later seed-resolve would overwrite, dropping the live byte. // See docs/mobile-prefer-renderer-scrollback.md. this.maybeHydrateHeadlessFromRenderer(ptyId) - this.trackHeadlessTerminalData(ptyId, data) + this.trackHeadlessTerminalData(ptyId, data, outputSequence) // Why: extract OSC title from raw PTY data before tail-buffer processing // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) @@ -2300,6 +2306,11 @@ export class OrcaRuntimeService { listener(data) } } + return outputSequence + } + + getPtyOutputSequence(ptyId: string): number { + return this.ptyOutputSequenceById.get(ptyId) ?? 0 } subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void { @@ -2368,10 +2379,17 @@ export class OrcaRuntimeService { serializeTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number } | null> { + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } + serializeMainTerminalBuffer( + ptyId: string, + opts: { scrollbackRows?: number } = {} + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { + return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) + } + async clearTerminalBuffer(handle: string): Promise<{ handle: string; cleared: boolean }> { const leaf = this.resolveLeafForHandle(handle) if (!leaf?.ptyId) { @@ -2411,6 +2429,7 @@ export class OrcaRuntimeService { const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + outputSequence: 0, writeChain: Promise.resolve() } this.headlessTerminals.set(ptyId, state) @@ -2451,6 +2470,7 @@ export class OrcaRuntimeService { const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }), + outputSequence: 0, writeChain: Promise.resolve() } this.headlessTerminals.set(ptyId, state) @@ -2516,10 +2536,13 @@ export class OrcaRuntimeService { } } - private trackHeadlessTerminalData(ptyId: string, data: string): void { + private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void { const state = this.getOrCreateHeadlessTerminal(ptyId) state.writeChain = state.writeChain - .then(() => state.emulator.write(data)) + .then(async () => { + await state.emulator.write(data) + state.outputSequence = outputSequence + }) .catch(() => { // Best-effort state tracking; live streaming must continue even if // xterm rejects a malformed or raced write during shutdown. @@ -2534,6 +2557,7 @@ export class OrcaRuntimeService { const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } const state: RuntimeHeadlessTerminal = { emulator: new HeadlessEmulator({ cols: size.cols, rows: size.rows }), + outputSequence: 0, writeChain: Promise.resolve() } this.headlessTerminals.set(ptyId, state) @@ -2559,7 +2583,7 @@ export class OrcaRuntimeService { private async serializeTerminalBufferFromAvailableState( ptyId: string, opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number } | null> { + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { return headlessSnapshot @@ -2593,8 +2617,8 @@ export class OrcaRuntimeService { private async serializeHeadlessTerminalBuffer( ptyId: string, - opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number } | null> { + opts: { scrollbackRows?: number; includeEmpty?: boolean } = {} + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { const state = this.headlessTerminals.get(ptyId) if (!state) { return null @@ -2611,7 +2635,9 @@ export class OrcaRuntimeService { const scrollbackRows = state.emulator.isAlternateScreen ? 0 : requested const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi - return data.length > 0 ? { data, cols: snapshot.cols, rows: snapshot.rows } : null + return data.length > 0 || opts.includeEmpty === true + ? { data, cols: snapshot.cols, rows: snapshot.rows, seq: state.outputSequence } + : null } private disposeHeadlessTerminal(ptyId: string): void { @@ -3314,6 +3340,7 @@ export class OrcaRuntimeService { this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.ptyOutputSequenceById.delete(ptyId) // Layout state machine: clear `layouts` and `layoutQueues`. Any // already-queued applyLayout work for this ptyId will run, but every // applyLayout re-checks `layouts.has(ptyId)` (or fresh-subscribe) and diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 1282dfdce..9da2918e8 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -785,10 +785,13 @@ export class SshRelaySession { private wireUpPtyEvents(ptyProvider: SshPtyProvider): void { const getWin = this.getMainWindow ptyProvider.onData((payload) => { - this.runtime?.onPtyData(payload.id, payload.data, Date.now()) + const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const win = getWin() if (win && !win.isDestroyed()) { - win.webContents.send('pty:data', payload) + win.webContents.send('pty:data', { + ...payload, + ...(typeof seq === 'number' ? { seq, rawLength: payload.data.length } : {}) + }) } }) ptyProvider.onReplay((payload) => { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c0afcffa2..6d899a3dd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -772,7 +772,13 @@ export type PreloadApi = { getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> - onData: (callback: (data: { id: string; data: string }) => void) => () => void + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise<{ data: string; cols: number; rows: number; seq?: number } | null> + onData: ( + callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void + ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void onExit: (callback: (data: { id: string; code: number }) => void) => () => void onSerializeBufferRequest: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index ed99e91e8..d37ffd077 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -663,6 +663,12 @@ const api = { listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => ipcRenderer.invoke('pty:listSessions'), + getMainBufferSnapshot: ( + id: string, + opts?: { scrollbackRows?: number } + ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => + ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), + /** Check if a PTY's shell has child processes (e.g. a running command). * Returns false for an idle shell prompt. */ hasChildProcesses: (id: string): Promise => @@ -676,9 +682,13 @@ const api = { * Returns `''` when the id is unknown or the platform cannot resolve one. */ getCwd: (id: string): Promise => ipcRenderer.invoke('pty:getCwd', { id }), - onData: (callback: (data: { id: string; data: string }) => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent, data: { id: string; data: string }) => - callback(data) + onData: ( + callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: { id: string; data: string; seq?: number; rawLength?: number } + ) => callback(data) ipcRenderer.on('pty:data', listener) return () => ipcRenderer.removeListener('pty:data', listener) }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index bc5f0f95a..33825de60 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -70,7 +70,7 @@ type StoreState = { } type ConnectCallbacks = { - onData?: (data: string) => void + onData?: (data: string, meta?: { seq?: number; rawLength?: number }) => void onError?: (msg: string) => void } @@ -211,6 +211,12 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport function createPane(paneId: number) { const leafId = leafIdForPane(paneId) + const activeBuffer = { + type: 'normal' as const, + viewportY: 0, + baseY: 0, + cursorY: 0 + } return { id: paneId, leafId, @@ -218,6 +224,10 @@ function createPane(paneId: number) { terminal: { cols: 120, rows: 40, + element: {}, + buffer: { + active: activeBuffer + }, modes: { bracketedPasteMode: false }, @@ -225,6 +235,20 @@ function createPane(paneId: number) { ignoreBracketedPasteMode: false }, write: vi.fn(), + resize: vi.fn(), + clear: vi.fn(), + scrollToBottom: vi.fn(() => { + activeBuffer.viewportY = activeBuffer.baseY + }), + scrollToLine: vi.fn((line: number) => { + activeBuffer.viewportY = line + }), + scrollLines: vi.fn((amount: number) => { + activeBuffer.viewportY = Math.max( + 0, + Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) + ) + }), paste: vi.fn(), onData: vi.fn(() => ({ dispose: vi.fn() })), onResize: vi.fn(() => ({ dispose: vi.fn() })), @@ -346,6 +370,7 @@ function createDeferred(): { promise: Promise; resolve: (value: T) => void describe('connectPanePty', () => { const originalRequestAnimationFrame = globalThis.requestAnimationFrame const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + const originalDocument = globalThis.document beforeEach(() => { vi.resetModules() @@ -397,6 +422,7 @@ describe('connectPanePty', () => { }, pty: { signal: vi.fn(), + getMainBufferSnapshot: vi.fn().mockResolvedValue(null), getForegroundProcess: vi.fn().mockResolvedValue(null), hasChildProcesses: vi.fn().mockResolvedValue(false), ackColdRestore: vi.fn(), @@ -436,6 +462,11 @@ describe('connectPanePty', () => { delete (globalThis as { cancelAnimationFrame?: typeof cancelAnimationFrame }) .cancelAnimationFrame } + if (originalDocument) { + globalThis.document = originalDocument + } else { + delete (globalThis as { document?: Document }).document + } delete (globalThis as unknown as { window?: unknown }).window delete (globalThis as Record).__ptyConnectDiag }) @@ -2503,6 +2534,488 @@ describe('connectPanePty', () => { ) }) + it('routes visible pane PTY bytes through the background scheduler when the document is hidden', async () => { + ;(globalThis as { document?: Pick }).document = { + visibilityState: 'hidden' + } + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() + capturedDataCallback.current?.('backgrounded document output\r\n') + + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'backgrounded document output\r\n', + expect.any(Function) + ) + + vi.advanceTimersByTime(50) + expect(pane.terminal.write).toHaveBeenCalledWith('backgrounded document output\r\n') + }) + + it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalled() + + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.resize).toHaveBeenCalledWith(100, 30) + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('ignores an async hidden-backlog snapshot if the pane changes PTYs first', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('old-pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'old-pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'old-live-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(2) + transport.getPtyId.mockReturnValue('new-pty-id') + snapshot.resolve({ + data: 'old-snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('old-pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'old-snapshot-state\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('does not recover stale hidden backlog state after the pane switches PTYs', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('old-pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'old-pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const newPtyOutput = 'new-pty-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + transport.getPtyId.mockReturnValue('new-pty-id') + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(newPtyOutput, { + seq: newPtyOutput.length, + rawLength: newPtyOutput.length + }) + await flushAsyncTicks(10) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith(newPtyOutput, expect.any(Function)) + disposable.dispose() + }) + + it('does not replay pending hidden restore chunks after a terminal clear', async () => { + const clearBufferCallback: { + current: ((request: { ptyId: string }) => void) | null + } = { current: null } + window.api.pty.onClearBufferRequest = vi.fn((callback) => { + clearBufferCallback.current = callback as (request: { ptyId: string }) => void + return vi.fn() + }) + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const snapshot = createDeferred<{ data: string; cols: number; rows: number; seq: number }>() + getMainBufferSnapshot.mockReturnValue(snapshot.promise) + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'pre-clear-live-output\r\n' + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(2) + expect(clearBufferCallback.current).not.toBeNull() + + clearBufferCallback.current?.({ ptyId: 'pty-id' }) + snapshot.resolve({ + data: '', + cols: 120, + rows: 40, + seq: hidden.length + live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.clear).toHaveBeenCalled() + expect(pane.terminal.write).not.toHaveBeenCalledWith(live, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + '\x1b[2J\x1b[3J\x1b[H', + expect.any(Function) + ) + disposable.dispose() + }) + + it('keeps recovery pending when hidden output arrives during an in-flight snapshot', async () => { + let visibilityState: DocumentVisibilityState = 'visible' + const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + ;(globalThis as { document?: Document }).document = { + get visibilityState() { + return visibilityState + }, + addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'visibilitychange') { + visibilityChangeHandler.current = listener as () => void + } + }), + removeEventListener: vi.fn() + } as unknown as Document + + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const visibleLive = 'visible-before-hide\r\n' + const hiddenAgain = 'hidden-during-restore\r\n' + getMainBufferSnapshot.mockReturnValueOnce(firstSnapshot.promise).mockResolvedValueOnce({ + data: 'snapshot-after-hidden-again\r\n', + cols: 120, + rows: 40, + seq: hidden.length + visibleLive.length + hiddenAgain.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityState = 'visible' + capturedDataCallback.current?.(visibleLive, { + seq: hidden.length + visibleLive.length, + rawLength: visibleLive.length + }) + await flushAsyncTicks(2) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + ;(deps.isVisibleRef as { current: boolean }).current = false + visibilityState = 'hidden' + capturedDataCallback.current?.(hiddenAgain, { + seq: hidden.length + visibleLive.length + hiddenAgain.length, + rawLength: hiddenAgain.length + }) + firstSnapshot.resolve({ + data: 'snapshot-before-hidden-again\r\n', + cols: 120, + rows: 40, + seq: hidden.length + visibleLive.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-before-hidden-again\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenAgain, expect.any(Function)) + + ;(deps.isVisibleRef as { current: boolean }).current = true + visibilityState = 'visible' + visibilityChangeHandler.current?.() + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-after-hidden-again\r\n', + expect.any(Function) + ) + disposable.dispose() + }) + + it('preserves a scrolled-up viewport after hidden-backlog snapshot replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-state\r\n', expect.any(Function)) + expect(pane.terminal.scrollToLine).toHaveBeenCalledWith(42) + disposable.dispose() + }) + + it('writes foreground chunks that are newer than the restored main snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'new-live-output\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-before-live\r\n', + cols: 120, + rows: 40, + seq: hidden.length + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-before-live\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('re-snapshots instead of duplicating partially overlapped chunks with stripped OSC bytes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const cleanedLive = 'clean-visible-output\r\n' + const rawLiveLength = cleanedLive.length + 32 + getMainBufferSnapshot + .mockResolvedValueOnce({ + data: 'snapshot-splits-osc-live\r\n', + cols: 120, + rows: 40, + seq: hidden.length + 4 + }) + .mockResolvedValueOnce({ + data: 'snapshot-after-osc-live\r\n', + cols: 120, + rows: 40, + seq: hidden.length + rawLiveLength + }) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(cleanedLive, { + seq: hidden.length + rawLiveLength, + rawLength: rawLiveLength + }) + await flushAsyncTicks(30) + + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'snapshot-after-osc-live\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith(cleanedLive, expect.any(Function)) + disposable.dispose() + }) + it('marks panes that receive Arabic output for DOM rendering', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 699f131af..7886de227 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -26,12 +26,14 @@ import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspec import { discardTerminalOutput, flushTerminalOutput, + registerTerminalBacklogRecovery, suppressTerminalCursorUntilOutputSettles, waitForTerminalOutputParsed, writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { isLocalNativeWindowsPty } from '@/lib/pane-manager/windows-pty-compatibility' -import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll' +import type { ScrollState } from '@/lib/pane-manager/pane-manager-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' import { e2eConfig } from '@/lib/e2e-config' @@ -53,6 +55,7 @@ import { pasteTerminalText } from './terminal-bracketed-paste' import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import type { PtyDataMeta } from './pty-dispatcher' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -62,6 +65,12 @@ const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1000 const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 +const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 +const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 +// Why: this is only shown if renderer backlog overflowed and main-owned +// terminal state is unavailable, so the user has an explicit loss signal. +const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = + '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB and main recovery was unavailable.]\r\n' let codexRestartNoticePresenceSource: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -202,6 +211,19 @@ function isSessionOwnedByWorktree(sessionId: string, worktreeId: string): boolea return sessionId.slice(0, separatorIdx) === worktreeId } +function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { + if (!isPaneVisible) { + return false + } + if (typeof document === 'undefined') { + return true + } + // Why: Electron can keep visible panes mounted while the whole app is + // backgrounded. Treat hidden documents like background tabs so Chromium + // timer throttling cannot pin terminal writes on the renderer foreground path. + return document.visibilityState === 'visible' +} + export function connectPanePty( pane: ManagedPane, manager: PaneManager, @@ -209,6 +231,8 @@ export function connectPanePty( ): IDisposable { let disposed = false let connectFrame: number | null = null + let unregisterBacklogRecovery: (() => void) | null = null + let unregisterDocumentVisibilityRecovery: (() => void) | null = null let startupInjectTimer: ReturnType | null = null let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null @@ -1181,6 +1205,7 @@ export function connectPanePty( } }, () => { + clearHiddenOutputRestoreState() discardTerminalOutput(pane.terminal) pane.terminal.clear() } @@ -1279,25 +1304,381 @@ export function connectPanePty( writeReplayData(data) } - const dataCallback = (data: string): void => { + type PendingHiddenOutputRestoreChunk = { + data: string + seq?: number + rawLength?: number + } + + let hiddenOutputRestoreNeeded = false + let hiddenOutputRestoreInFlight: Promise | null = null + let hiddenOutputRestorePendingChunks: PendingHiddenOutputRestoreChunk[] = [] + let hiddenOutputRestorePendingChars = 0 + let hiddenOutputRestorePendingOverflow = false + let hiddenOutputRestoreFreshSnapshotNeeded = false + // Why: hidden recovery state belongs to one PTY stream. Reattach/restart + // can reuse the pane object for a different session before visibility. + let hiddenOutputRestorePtyId: string | null = null + let hiddenOutputRestoreGeneration = 0 + + function canUseMainBufferSnapshot(ptyId: string | null): ptyId is string { + return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) + } + + function beforeTerminalOutputWrite(chunk: string): void { + // Why: hidden tab output is coalesced by the scheduler. Run per-byte + // renderer checks at the xterm write boundary so background PTY bursts + // do not spend foreground event-loop time scanning bytes we will delay. + if (terminalOutputPrefersDomRenderer(chunk)) { + manager.markPaneHasComplexScriptOutput(pane.id) + } + recordTerminalOutput(pane.terminal) + } + + function writePtyOutputToXterm(data: string, foreground: boolean): void { + writeTerminalOutput(pane.terminal, data, { + foreground, + beforeWrite: beforeTerminalOutputWrite, + onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded + }) + } + + function markHiddenOutputRestoreNeeded(): void { + const ptyId = transport.getPtyId() + if (!canUseMainBufferSnapshot(ptyId)) { + return + } + if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { + clearHiddenOutputRestoreState() + } + hiddenOutputRestorePtyId = ptyId + hiddenOutputRestoreNeeded = true + if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { + requestHiddenOutputRestoreIfNeeded() + } + } + + function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void { + if (!data) { + return + } + const ptyId = transport.getPtyId() + if (!canUseMainBufferSnapshot(ptyId)) { + return + } + if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { + clearHiddenOutputRestoreState() + } + hiddenOutputRestorePtyId = ptyId + hiddenOutputRestoreNeeded = true + if (hiddenOutputRestorePendingChars + data.length > HIDDEN_OUTPUT_RESTORE_PENDING_CHARS) { + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + hiddenOutputRestorePendingOverflow = true + return + } + const pending: PendingHiddenOutputRestoreChunk = { data } + if (typeof meta?.seq === 'number') { + pending.seq = meta.seq + } + if (typeof meta?.rawLength === 'number') { + pending.rawLength = meta.rawLength + } + hiddenOutputRestorePendingChunks.push(pending) + hiddenOutputRestorePendingChars += data.length + } + + function getChunkDataAfterSnapshot( + chunk: PendingHiddenOutputRestoreChunk, + snapshotSeq: number | undefined + ): string | null { + if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') { + return chunk.data + } + const rawLength = chunk.rawLength ?? chunk.data.length + const startSeq = chunk.seq - rawLength + if (snapshotSeq >= chunk.seq) { + return '' + } + if (snapshotSeq <= startSeq) { + return chunk.data + } + const offset = snapshotSeq - startSeq + if (rawLength !== chunk.data.length) { + return null + } + return chunk.data.slice(offset) + } + + function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { + if (hiddenOutputRestorePendingOverflow) { + hiddenOutputRestorePendingOverflow = false + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + return false + } + while (hiddenOutputRestorePendingChunks.length > 0) { + const chunks = hiddenOutputRestorePendingChunks + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + for (const chunk of chunks) { + const data = getChunkDataAfterSnapshot(chunk, snapshotSeq) + if (data === null) { + // Why: renderer-only OSC stripping makes raw sequence offsets + // impossible to map onto cleaned text. Fetch a fresher main + // snapshot instead of risking duplicate visible output. + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + return false + } + if (data) { + writePtyOutputToXterm(data, true) + } + } + if (hiddenOutputRestorePendingOverflow) { + hiddenOutputRestorePendingOverflow = false + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + return false + } + } + return true + } + + function clearPendingLiveChunksDuringRestore(): void { + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + hiddenOutputRestorePendingOverflow = false + hiddenOutputRestoreFreshSnapshotNeeded = false + } + + function clearHiddenOutputRestoreState(): void { + clearPendingLiveChunksDuringRestore() + hiddenOutputRestoreNeeded = false + hiddenOutputRestorePtyId = null + hiddenOutputRestoreGeneration += 1 + } + + function resetHiddenOutputRestoreIfPtyChanged(): void { + if (hiddenOutputRestorePtyId === null) { + return + } + if (transport.getPtyId() !== hiddenOutputRestorePtyId) { + clearHiddenOutputRestoreState() + } + } + + function writeRestoreUnavailableWarning(): void { + if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { + return + } + writeTerminalOutput(pane.terminal, HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING, { + foreground: true, + beforeWrite: beforeTerminalOutputWrite + }) + } + + function captureScrollStateForSnapshotReplay(): ScrollState | null { + const buf = pane.terminal.buffer?.active + if (!buf) { + return null + } + const viewportY = buf.viewportY + const baseY = buf.baseY + if (!Number.isFinite(viewportY) || !Number.isFinite(baseY)) { + return null + } + return { + bufferType: buf.type, + wasAtBottom: viewportY >= baseY, + viewportY, + baseY + } + } + + function restoreScrollStateAfterSnapshotReplay(state: ScrollState | null): void { + if (!state || state.wasAtBottom) { + return + } + // Why: hidden-backlog replay clears xterm after visibility scroll restore; + // re-apply a scrolled-up viewport so recovery does not jump to bottom. + restoreScrollStateAfterLayout(pane.terminal, state) + } + + function applyMainBufferSnapshot(snapshot: { + data: string + cols: number + rows: number + seq?: number + }): void { + const scrollState = captureScrollStateForSnapshotReplay() + discardTerminalOutput(pane.terminal) + if ( + Number.isFinite(snapshot.cols) && + Number.isFinite(snapshot.rows) && + snapshot.cols > 0 && + snapshot.rows > 0 && + (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) + ) { + // Why: serialized terminal snapshots encode layout at their source + // dimensions. Replay at those dimensions first, then fit back below. + pane.terminal.resize(snapshot.cols, snapshot.rows) + } + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.data) + writeReplayData(POST_REPLAY_REATTACH_RESET) + recordTerminalOutput(pane.terminal) + const currentPtyId = transport.getPtyId() + if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { + safeFit(pane) + transport.resize(pane.terminal.cols, pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } + } + restoreScrollStateAfterSnapshotReplay(scrollState) + } + + function requestHiddenOutputRestoreIfNeeded(): boolean { + resetHiddenOutputRestoreIfPtyChanged() + const ptyId = hiddenOutputRestorePtyId ?? transport.getPtyId() + if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) { + return false + } + if (!canUseMainBufferSnapshot(ptyId)) { + return false + } + hiddenOutputRestorePtyId = ptyId + if (hiddenOutputRestoreInFlight) { + return true + } + + hiddenOutputRestoreInFlight = (async () => { + while (!disposed) { + const currentPtyId = hiddenOutputRestorePtyId + if (currentPtyId === null) { + clearHiddenOutputRestoreState() + return + } + if (!canUseMainBufferSnapshot(currentPtyId)) { + if (hiddenOutputRestorePtyId === currentPtyId) { + clearHiddenOutputRestoreState() + } + writeRestoreUnavailableWarning() + return + } + if (transport.getPtyId() !== currentPtyId) { + if (hiddenOutputRestorePtyId === currentPtyId) { + clearHiddenOutputRestoreState() + } + return + } + const restoreGeneration = hiddenOutputRestoreGeneration + hiddenOutputRestoreNeeded = false + let snapshot: { data: string; cols: number; rows: number; seq?: number } | null = null + try { + snapshot = await window.api.pty.getMainBufferSnapshot(currentPtyId, { + scrollbackRows: HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + }) + } catch { + snapshot = null + } + if (disposed) { + return + } + if ( + hiddenOutputRestoreGeneration !== restoreGeneration || + transport.getPtyId() !== currentPtyId || + hiddenOutputRestorePtyId !== currentPtyId + ) { + // Why: the snapshot belongs to the requested PTY; after reattach, + // replaying it would show stale/cleared output in the new terminal. + if (hiddenOutputRestorePtyId === currentPtyId) { + clearHiddenOutputRestoreState() + } + return + } + if (!snapshot) { + clearHiddenOutputRestoreState() + writeRestoreUnavailableWarning() + return + } + applyMainBufferSnapshot(snapshot) + const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded + hiddenOutputRestoreFreshSnapshotNeeded = false + if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) { + hiddenOutputRestoreNeeded = false + hiddenOutputRestorePtyId = null + return + } + if (!shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { + // Why: hidden bytes that arrived during the snapshot were not kept + // in renderer memory. Leave recovery pending for the next visible + // moment instead of looping hidden snapshots in a throttled tab. + hiddenOutputRestoreNeeded = true + return + } + hiddenOutputRestoreNeeded = true + } + })().finally(() => { + hiddenOutputRestoreInFlight = null + if (hiddenOutputRestorePendingChunks.length > 0 || hiddenOutputRestorePendingOverflow) { + hiddenOutputRestoreNeeded = true + } + if ( + hiddenOutputRestoreNeeded && + shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) { + requestHiddenOutputRestoreIfNeeded() + } + }) + return true + } + + unregisterBacklogRecovery = registerTerminalBacklogRecovery( + pane.terminal, + requestHiddenOutputRestoreIfNeeded + ) + if ( + typeof document !== 'undefined' && + typeof document.addEventListener === 'function' && + typeof document.removeEventListener === 'function' + ) { + const onDocumentVisibilityChange = (): void => { + if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { + requestHiddenOutputRestoreIfNeeded() + } + } + document.addEventListener('visibilitychange', onDocumentVisibilityChange) + unregisterDocumentVisibilityRecovery = () => + document.removeEventListener('visibilitychange', onDocumentVisibilityChange) + } + + const dataCallback = (data: string, meta?: PtyDataMeta): void => { + resetHiddenOutputRestoreIfPtyChanged() observeTerminalBracketedPasteModeOutput(pane.terminal, data) commandCodeOutputStatusDetector.observe(data) commandLifecycle.handlePtyData(data) - // Why: visibility is the right gate — split-pane layouts have multiple - // visible-but-inactive panes whose output the user is watching. Only - // hidden panes (background tabs) should be throttled. - writeTerminalOutput(pane.terminal, data, { - foreground: deps.isVisibleRef.current, - beforeWrite: (chunk) => { - // Why: hidden tab output is coalesced by the scheduler. Run per-byte - // renderer checks at the xterm write boundary so background PTY bursts - // do not spend foreground event-loop time scanning bytes we will delay. - if (terminalOutputPrefersDomRenderer(chunk)) { - manager.markPaneHasComplexScriptOutput(pane.id) - } - recordTerminalOutput(pane.terminal) + // Why: split-pane layouts have multiple visible-but-inactive panes whose + // output the user is watching. Throttle only when the pane or whole + // Electron document is hidden. + const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) + const restoreAppliesToCurrentPty = + hiddenOutputRestorePtyId !== null && transport.getPtyId() === hiddenOutputRestorePtyId + if ( + (hiddenOutputRestoreNeeded || hiddenOutputRestoreInFlight) && + restoreAppliesToCurrentPty + ) { + if (foreground) { + queueLiveChunkDuringRestore(data, meta) + requestHiddenOutputRestoreIfNeeded() + } else if (hiddenOutputRestoreInFlight) { + hiddenOutputRestoreNeeded = true + hiddenOutputRestoreFreshSnapshotNeeded = true } - }) + } else { + writePtyOutputToXterm(data, foreground) + } if (pendingStartupCommand) { if (startupInjectTimer !== null) { @@ -1934,6 +2315,10 @@ export function connectPanePty( clearPendingAgentTaskCompleteNotification() pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() + unregisterBacklogRecovery?.() + unregisterBacklogRecovery = null + unregisterDocumentVisibilityRecovery?.() + unregisterDocumentVisibilityRecovery = null discardTerminalOutput(pane.terminal) if (agentTaskCompleteSettingsUnsubscribe !== null) { agentTaskCompleteSettingsUnsubscribe() diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index cfb8a1772..1e3c444c8 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -13,7 +13,12 @@ import type { EventProps } from '../../../../shared/telemetry-events' // PTY ID. Eliminates the N-listener problem that triggers // MaxListenersExceededWarning with many panes/tabs. -export const ptyDataHandlers = new Map void>() +export type PtyDataMeta = { + seq?: number + rawLength?: number +} + +export const ptyDataHandlers = new Map void>() /** Sidecar subscriptions that observe PTY data without owning the primary * handler. Used by features that need to react to the live byte stream * (e.g. agent-paste-draft watching for DECSET 2004 / bracketed-paste- @@ -81,7 +86,16 @@ export function ensurePtyDispatcher(): void { } ptyDispatcherAttached = true window.api.pty.onData((payload) => { - ptyDataHandlers.get(payload.id)?.(payload.data) + let meta: PtyDataMeta | undefined + if (typeof payload.seq === 'number') { + meta ??= {} + meta.seq = payload.seq + } + if (typeof payload.rawLength === 'number') { + meta ??= {} + meta.rawLength = payload.rawLength + } + ptyDataHandlers.get(payload.id)?.(payload.data, meta) const sidecars = ptyDataSidecars.get(payload.id) if (sidecars && sidecars.size > 0) { // Why: snapshot the Set before iterating because watchers commonly @@ -230,7 +244,7 @@ export type PtyTransport = { callbacks: { onConnect?: () => void onDisconnect?: () => void - onData?: (data: string) => void + onData?: (data: string, meta?: PtyDataMeta) => void /** Replay bytes from a prior session (eager buffers, attach-time screen * clears). Routed separately from onData so the renderer can engage * the replay guard — otherwise xterm auto-replies to embedded query @@ -254,7 +268,7 @@ export type PtyTransport = { callbacks: { onConnect?: () => void onDisconnect?: () => void - onData?: (data: string) => void + onData?: (data: string, meta?: PtyDataMeta) => void /** See note on connect.callbacks.onReplayData. */ onReplayData?: (data: string) => void onStatus?: (shell: string) => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index b0e4d5320..4fa8ff492 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -93,6 +93,37 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('preserves stale-title detection after compacting deferred side effects', async () => { + vi.useFakeTimers() + try { + const { createPtyOutputProcessor } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onAgentBecameWorking = vi.fn() + const onAgentBecameIdle = vi.fn() + const processor = createPtyOutputProcessor({ + onTitleChange, + onAgentBecameWorking, + onAgentBecameIdle + }) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]0;. Claude working\x07', callbacks) + for (let i = 0; i < 20; i++) { + processor.processData(`plain output ${i}\r\n`, callbacks) + } + + expect(onAgentBecameWorking).not.toHaveBeenCalled() + vi.advanceTimersByTime(0) + + expect(onAgentBecameWorking).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(3_000) + + expect(onAgentBecameIdle).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + it('uses acknowledged writes only for local IPC PTYs', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const localTransport = createIpcPtyTransport({}) diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 2c0d08c24..b88a70107 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -17,9 +17,14 @@ import { ensurePtyDispatcher, getEagerPtyBufferHandle } from './pty-dispatcher' -import type { PtyTransport, IpcPtyTransportOptions, PtyConnectResult } from './pty-dispatcher' +import type { + PtyTransport, + IpcPtyTransportOptions, + PtyConnectResult, + PtyDataMeta +} from './pty-dispatcher' import { createBellDetector } from './bell-detector' -import { createAgentStatusOscProcessor } from './agent-status-osc' +import { createAgentStatusOscProcessor, type ProcessedAgentStatusChunk } from './agent-status-osc' import { extractIpcErrorMessage } from '@/lib/ipc-error' // Re-export public API so existing consumers keep working. @@ -61,6 +66,14 @@ type ProcessPtyOutputOptions = { suppressAttentionEvents?: boolean } +type PendingPtySideEffect = { + payloads: ProcessedAgentStatusChunk['payloads'] + titles: string[] + scannedForTitles: boolean + containsBell: boolean + suppressAttentionEvents: boolean +} + export function createPtyOutputProcessor({ onTitleChange, onBell, @@ -72,7 +85,8 @@ export function createPtyOutputProcessor({ processData: ( data: string, callbacks: PtyOutputCallbacks, - options?: ProcessPtyOutputOptions + options?: ProcessPtyOutputOptions, + meta?: PtyDataMeta ) => void clearAccumulatedState: () => void clearStaleTitleTimer: () => void @@ -84,11 +98,7 @@ export function createPtyOutputProcessor({ let lastEmittedTitle: string | null = null let staleTitleTimer: ReturnType | null = null let sideEffectDrainTimer: ReturnType | null = null - const pendingSideEffects: { - data: string - payloads: ReturnType['payloads'] - suppressAttentionEvents: boolean - }[] = [] + let pendingSideEffects: PendingPtySideEffect[] = [] const agentTracker = onAgentBecameIdle || onAgentBecameWorking || onAgentExited ? createAgentStatusTracker( @@ -135,7 +145,41 @@ export function createPtyOutputProcessor({ payloads: ReturnType['payloads'], suppressAttentionEvents: boolean ): void { - pendingSideEffects.push({ data, payloads, suppressAttentionEvents }) + const scannedForTitles = Boolean(onTitleChange && data.length > 0) + const titles = scannedForTitles ? extractAllOscTitles(data) : [] + const deliveredPayloads = + onAgentStatus && !suppressAttentionEvents && payloads.length > 0 ? payloads : [] + const containsBell = Boolean( + onBell && !suppressAttentionEvents && bellDetector.chunkContainsBell(data) + ) + if (!scannedForTitles && deliveredPayloads.length === 0 && !containsBell) { + return + } + + const prior = pendingSideEffects.at(-1) + if ( + prior && + prior.titles.length === 0 && + prior.payloads.length === 0 && + !prior.containsBell && + prior.suppressAttentionEvents === suppressAttentionEvents && + titles.length === 0 && + deliveredPayloads.length === 0 && + !containsBell + ) { + prior.scannedForTitles ||= scannedForTitles + } else { + // Why: keep only compact derived side-effect facts here. Retaining raw + // PTY chunks duplicates the terminal scheduler backlog while timers are + // throttled in a backgrounded Electron window. + pendingSideEffects.push({ + titles, + payloads: deliveredPayloads, + scannedForTitles, + containsBell, + suppressAttentionEvents + }) + } if (sideEffectDrainTimer !== null) { return } @@ -153,18 +197,16 @@ export function createPtyOutputProcessor({ function drainPtySideEffects(): void { sideEffectDrainTimer = null - while (pendingSideEffects.length > 0) { - const next = pendingSideEffects.shift() - if (!next) { - continue - } - if (onAgentStatus && !next.suppressAttentionEvents) { + const effects = pendingSideEffects + pendingSideEffects = [] + for (const next of effects) { + if (onAgentStatus) { for (const payload of next.payloads) { onAgentStatus(payload) } } - processObservedTitles(next.data, next.suppressAttentionEvents) - if (onBell && bellDetector.chunkContainsBell(next.data) && !next.suppressAttentionEvents) { + processObservedTitles(next.titles, next.scannedForTitles, next.suppressAttentionEvents) + if (onBell && next.containsBell) { onBell() } } @@ -175,7 +217,11 @@ export function createPtyOutputProcessor({ drainPtySideEffects() } - function processObservedTitles(data: string, suppressAgentTracker: boolean): void { + function processObservedTitles( + titles: string[], + scannedForTitles: boolean, + suppressAgentTracker: boolean + ): void { if (!onTitleChange) { return } @@ -183,13 +229,13 @@ export function createPtyOutputProcessor({ // the last one. node-pty + the main-process 8ms batch window commonly // coalesce multiple title updates into a single IPC payload; processing // titles in order preserves working-to-idle transitions. - const titles = extractAllOscTitles(data) if (titles.length > 0) { clearStaleTitleTimer() for (const title of titles) { applyObservedTerminalTitle(title, suppressAgentTracker) } } else if ( + scannedForTitles && !suppressAgentTracker && lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working' @@ -210,8 +256,10 @@ export function createPtyOutputProcessor({ function processData( data: string, callbacks: PtyOutputCallbacks, - options: ProcessPtyOutputOptions = {} + options: ProcessPtyOutputOptions = {}, + meta?: PtyDataMeta ): void { + const rawLength = meta?.rawLength ?? data.length const suppressAttentionEvents = options.suppressAttentionEvents === true // Why: OSC 9999 is a renderer-only control protocol. Parse it before // xterm sees the bytes, and keep parser state across chunks so partial @@ -225,7 +273,11 @@ export function createPtyOutputProcessor({ if (options.replayingBufferedData && callbacks.onReplayData) { callbacks.onReplayData(data) } else { - callbacks.onData?.(data) + if (meta) { + callbacks.onData?.(data, { ...meta, rawLength }) + } else { + callbacks.onData?.(data) + } } schedulePtySideEffects(data, processed.payloads, suppressAttentionEvents) } @@ -321,11 +373,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra storedCallbacks.onData?.(data) } }) - ptyDataHandlers.set(id, (data) => { - outputProcessor.processData(data, storedCallbacks, { - replayingBufferedData, - suppressAttentionEvents - }) + ptyDataHandlers.set(id, (data, meta) => { + outputProcessor.processData( + data, + storedCallbacks, + { + replayingBufferedData, + suppressAttentionEvents + }, + meta + ) }) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 9e556bead..b60d030b5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ flushTerminalOutput: vi.fn(), getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), + requestTerminalBacklogRecovery: vi.fn(), restoreScrollState: vi.fn(), restoreScrollStateAfterLayout: vi.fn() })) @@ -52,7 +53,8 @@ vi.mock('./pane-helpers', () => ({ })) vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ - flushTerminalOutput: mocks.flushTerminalOutput + flushTerminalOutput: mocks.flushTerminalOutput, + requestTerminalBacklogRecovery: mocks.requestTerminalBacklogRecovery })) vi.mock('@/lib/pane-manager/pane-scroll', () => ({ @@ -165,6 +167,9 @@ describe('useTerminalPaneGlobalEffects', () => { mocks.flushTerminalOutput.mockImplementation((terminal: { name: string }) => { order.push(`flush:${terminal.name}`) }) + mocks.requestTerminalBacklogRecovery.mockImplementation((terminal: { name: string }) => { + order.push(`recover:${terminal.name}`) + }) mocks.captureScrollState.mockImplementation((terminal: { name: string }) => { order.push(`capture:${terminal.name}`) return { terminalName: terminal.name } @@ -194,13 +199,21 @@ describe('useTerminalPaneGlobalEffects', () => { expect(order).toEqual([ 'capture:terminal-a', 'capture:terminal-b', + 'recover:terminal-a', 'flush:terminal-a', + 'recover:terminal-b', 'flush:terminal-b', 'resume', 'fit-focus', 'restore:terminal-a', 'restore:terminal-b' ]) + expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, { + maxChars: 256 * 1024 + }) + expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(2, terminalB, { + maxChars: 256 * 1024 + }) expect(mocks.fitPanes).not.toHaveBeenCalled() expect(isActiveRef.current).toBe(true) expect(isVisibleRef.current).toBe(true) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 0dd284152..d2bb906d9 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -10,7 +10,10 @@ import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { fitAndFocusPanes, fitPanes } from './pane-helpers' import type { PtyTransport } from './pty-transport' import { handleTerminalFileDrop } from './terminal-drop-handler' -import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' +import { + flushTerminalOutput, + requestTerminalBacklogRecovery +} from '@/lib/pane-manager/pane-terminal-output-scheduler' import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' import { surfaceStaleAgentRow } from './stale-agent-row' import { useAppStore } from '@/store' @@ -19,6 +22,8 @@ import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibil import { useTerminalContainerFitSync } from './use-terminal-container-fit-sync' import { pasteTerminalText } from './terminal-bracketed-paste' +const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 + type UseTerminalPaneGlobalEffectsArgs = { tabId: string worktreeId: string @@ -84,10 +89,13 @@ export function useTerminalPaneGlobalEffects({ // not jump to the wrong history entry. const viewportPositions = captureViewportPositions(!wasVisibleRef.current) withSuppressedScrollTracking(() => { - // Why: background PTY output is throttled while a pane is not focused; - // flush it before fitting so newly visible terminals paint current state. + // Why: hidden panes can accumulate large PTY bursts while Chromium is + // occluded. Drain a bounded slice before fitting; the scheduler keeps + // ordering and continues the rest asynchronously so return-to-app does + // not beachball behind an entire backlog. for (const pane of manager.getPanes()) { - flushTerminalOutput(pane.terminal) + requestTerminalBacklogRecovery(pane.terminal) + flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) } // Resume WebGL immediately so the terminal shows its last-known state // on the first painted frame. macOS context creation is ~5 ms; on diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts new file mode 100644 index 000000000..1341de078 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts @@ -0,0 +1,107 @@ +import type * as ReactModule from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useTerminalScrollVisibilityMemory } from './use-terminal-scroll-visibility-memory' + +const mocks = vi.hoisted(() => ({ + cancelDeferredScrollRestore: vi.fn(), + captureScrollState: vi.fn(() => ({ + bufferType: 'normal', + wasAtBottom: true, + viewportY: 0, + baseY: 0 + })), + flushTerminalOutput: vi.fn(), + getTerminalOutputEpoch: vi.fn(() => 1) +})) + +const reactRefState = vi.hoisted(() => ({ + slots: [] as { current: unknown }[], + index: 0 +})) + +function beginHookRender(): void { + reactRefState.index = 0 +} + +function resetHookRefs(): void { + reactRefState.slots = [] + reactRefState.index = 0 +} + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(callback: T) => callback, + useEffect: (effect: () => void | (() => void)) => { + effect() + }, + useRef: (value: T) => { + const index = reactRefState.index + reactRefState.index += 1 + if (!reactRefState.slots[index]) { + reactRefState.slots[index] = { current: value } + } + return reactRefState.slots[index] as { current: T } + } + } +}) + +vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ + flushTerminalOutput: mocks.flushTerminalOutput +})) + +vi.mock('@/lib/pane-manager/pane-scroll', () => ({ + cancelDeferredScrollRestore: mocks.cancelDeferredScrollRestore, + captureScrollState: mocks.captureScrollState, + getTerminalOutputEpoch: mocks.getTerminalOutputEpoch +})) + +describe('useTerminalScrollVisibilityMemory', () => { + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + + beforeEach(() => { + resetHookRefs() + vi.clearAllMocks() + }) + + afterEach(() => { + if (originalRequestAnimationFrame) { + globalThis.requestAnimationFrame = originalRequestAnimationFrame + } else { + delete (globalThis as unknown as { requestAnimationFrame?: unknown }).requestAnimationFrame + } + }) + + it('bounds follow-output flushes when applying pending requests', () => { + const terminal = { + onScroll: vi.fn(() => ({ dispose: vi.fn() })), + scrollToBottom: vi.fn() + } + const manager = { + getPanes: vi.fn(() => [{ id: 1, terminal }]) + } + const animationFrames: FrameRequestCallback[] = [] + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + animationFrames.push(callback) + return animationFrames.length + }) + + beginHookRender() + const visibilityMemory = useTerminalScrollVisibilityMemory({ + managerRef: { current: manager as never }, + isVisibleRef: { current: true }, + visibleResumeCompleteRef: { current: true }, + paneCount: 1 + }) + + visibilityMemory.scheduleFollowOutputIfNeeded(1) + animationFrames.shift()?.(16) + animationFrames.shift()?.(32) + + expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, { + maxChars: 256 * 1024 + }) + expect(terminal.scrollToBottom).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts index a6114b5b9..ce87c8cbf 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts @@ -28,6 +28,8 @@ type TerminalScrollVisibilityMemory = { scheduleFollowOutputIfNeeded: (paneId: number) => void } +const FOLLOW_OUTPUT_FLUSH_CHARS = 256 * 1024 + export function useTerminalScrollVisibilityMemory({ managerRef, isVisibleRef, @@ -107,7 +109,10 @@ export function useTerminalScrollVisibilityMemory({ continue } const previous = visibleScrollSnapshotsRef.current.get(pane.id) - flushTerminalOutput(pane.terminal) + // Why: focus/follow can run immediately after a hidden pane becomes + // visible. A bounded flush is enough to observe new output without + // putting the whole hidden PTY backlog back on the interaction path. + flushTerminalOutput(pane.terminal, { maxChars: FOLLOW_OUTPUT_FLUSH_CHARS }) const currentEpoch = getTerminalOutputEpoch(pane.terminal) const hasNewOutput = previous ? currentEpoch > previous.outputEpoch : currentEpoch > 0 if (hasNewOutput) { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 9b4f922d4..6301c35fd 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Why: the scheduler tests cover one queue state machine; keeping ordering and overflow cases together makes regressions easier to audit. */ import { afterEach, describe, expect, it, vi } from 'vitest' function createTerminal() { @@ -181,6 +182,23 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('hidden') }) + it('supports bounded explicit flushes for visibility resume', async () => { + vi.useFakeTimers() + const { flushTerminalOutput, writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + + for (let i = 0; i < 16; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + flushTerminalOutput(terminal, { maxChars: 64 * 1024 }) + + expect(terminal.write).toHaveBeenCalledTimes(4) + vi.advanceTimersByTime(50) + expect(terminal.write.mock.calls.length).toBeGreaterThan(4) + }) + it('limits how many background terminals begin xterm writes per drain tick', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -222,6 +240,83 @@ describe('pane terminal output scheduler', () => { expect(terminals[0].write).toHaveBeenCalledTimes(2) }) + it('promotes large background backlogs to high-priority drains', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + expect(terminal.write).not.toHaveBeenCalled() + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledTimes(16) + + vi.advanceTimersByTime(1) + expect(terminal.write).toHaveBeenCalledTimes(32) + }) + + it('caps hidden backlog memory and writes a warning instead of retaining all output', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + writeTerminalOutput(terminal, 'after-cap\r\n', { foreground: false }) + + vi.advanceTimersByTime(0) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped hidden terminal output') + expect(output).toContain('after-cap') + expect(output).not.toContain('x'.repeat(1024)) + }) + + it('caps hidden backlog chunk count even when each chunk is tiny', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + + for (let i = 0; i < 4097; i++) { + writeTerminalOutput(terminal, 'x', { foreground: false }) + } + + vi.advanceTimersByTime(0) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped hidden terminal output') + expect(output).not.toContain('x'.repeat(512)) + }) + + it('requests registered recovery instead of flushing a dropped hidden backlog', async () => { + vi.useFakeTimers() + const { flushTerminalOutput, registerTerminalBacklogRecovery, writeTerminalOutput } = + await loadScheduler() + const terminal = createTerminal() + const requestRecovery = vi.fn(() => true) + const unregister = registerTerminalBacklogRecovery(terminal, requestRecovery) + const chunk = 'x'.repeat(512 * 1024) + + try { + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + flushTerminalOutput(terminal) + + expect(requestRecovery).toHaveBeenCalledTimes(1) + expect(terminal.write).not.toHaveBeenCalled() + } finally { + unregister() + } + }) + it('flushes queued output before foreground output on the same terminal', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -233,6 +328,47 @@ describe('pane terminal output scheduler', () => { expect(terminal.write.mock.calls.map(([data]) => data)).toEqual(['old', 'new']) }) + it('yields instead of synchronously flushing a large hidden backlog on foreground output', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + writeTerminalOutput(terminal, 'visible', { foreground: true }) + + expect(terminal.write.mock.calls.length).toBeLessThan(64) + vi.advanceTimersByTime(50) + + expect(terminal.write.mock.calls.length).toBeGreaterThan(0) + }) + + it('preserves byte order when foreground output is queued behind a large hidden backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + + for (let i = 0; i < 64; i++) { + writeTerminalOutput(terminal, `${String(i).padStart(2, '0')}:${chunk}`, { + foreground: false + }) + } + + writeTerminalOutput(terminal, 'visible', { foreground: true }) + vi.runAllTimers() + + const expected = `${Array.from( + { length: 64 }, + (_, i) => `${String(i).padStart(2, '0')}:${chunk}` + ).join('')}visible` + expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe(expected) + expect(terminal.write).toHaveBeenLastCalledWith('visible', expect.any(Function)) + }) + it('discards queued output for disposed terminals', async () => { vi.useFakeTimers() const { discardTerminalOutput, writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 6232903b5..3a65d0311 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -1,3 +1,6 @@ +/* oxlint-disable max-lines -- Why: output ordering, foreground settle, queue +state, and e2e diagnostics share one state machine; splitting it would make the +backlog/resume guarantees harder to audit. */ import { e2eConfig } from '@/lib/e2e-config' import { discardForegroundRenderSettle, @@ -9,27 +12,63 @@ import { type TerminalOutputTarget = ForegroundTerminalOutputTarget type TerminalOutputBeforeWrite = (data: string) => void +type TerminalBacklogRecoveryRequest = () => boolean + +type WriteTerminalOutputOptions = { + foreground: boolean + beforeWrite?: TerminalOutputBeforeWrite + onBackgroundBacklogDropped?: () => void +} + +type QueueChunk = { + data: string + foreground: boolean +} + +type QueuedWrite = { + data: string + foreground: boolean +} type QueueEntry = { terminal: TerminalOutputTarget - chunks: string[] + chunks: QueueChunk[] + chunkIndex: number + queuedChars: number beforeWrite?: TerminalOutputBeforeWrite + onBackgroundBacklogDropped?: () => void + backgroundBacklogDropped: boolean + highPriority: boolean } const BACKGROUND_FLUSH_DELAY_MS = 50 const BACKGROUND_DRAIN_INTERVAL_MS = 16 +const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 1 const BACKGROUND_CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_DRAIN = 2 +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 16 +const LARGE_BACKLOG_CHARS = 512 * 1024 +const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 +const MAX_BACKGROUND_QUEUE_CHARS = 2 * 1024 * 1024 +const MAX_BACKGROUND_QUEUE_CHUNKS = 4096 const PARSE_SETTLE_TIMEOUT_MS = 250 +// Why: CAN aborts a partial escape sequence before resetting style and showing +// the lossy-backlog warning. +const BACKGROUND_BACKLOG_WARNING = + '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB.]\r\n' const queuedByTerminal = new Map() +const backlogRecoveryByTerminal = new WeakMap< + TerminalOutputTarget, + TerminalBacklogRecoveryRequest +>() let drainTimer: ReturnType | null = null +let drainTimerDelayMs: number | null = null const debugEnabled = e2eConfig.exposeStore -// Why no lossy queue cap: dropping raw terminal bytes can corrupt parser state -// (half an escape sequence, missed mode reset, wrong scrollback). A pathological -// background producer can still consume memory/CPU; preserving terminal -// correctness means that case needs adaptive/backpressure work, not truncation. +// Why the cap is lossy: a hidden/backgrounded Chromium document can throttle +// timers while PTYs keep writing. Preserving unlimited hidden scrollback would +// let renderer memory grow until the app stalls or crashes. type TerminalOutputSchedulerDebugSnapshot = { backgroundEnqueueCount: number @@ -83,48 +122,122 @@ function exposeDebugApi(): void { function scheduleDrain(delayMs: number): void { if (drainTimer !== null) { + if (drainTimerDelayMs !== null && drainTimerDelayMs <= delayMs) { + return + } + clearTimeout(drainTimer) + drainTimer = null + drainTimerDelayMs = null + } + if (queuedByTerminal.size === 0) { return } if (debugEnabled) { debugState.scheduledDrainCount++ } drainTimer = setTimeout(drainQueuedOutput, delayMs) + drainTimerDelayMs = delayMs } -function takeQueuedChunk(entry: QueueEntry, limit: number): string { +function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let remaining = limit let data = '' + let foreground: boolean | null = null - while (remaining > 0 && entry.chunks.length > 0) { - const chunk = entry.chunks[0] - if (chunk.length <= remaining) { - data += chunk - remaining -= chunk.length - entry.chunks.shift() + while (remaining > 0 && entry.chunkIndex < entry.chunks.length) { + const chunk = entry.chunks[entry.chunkIndex] + if (foreground !== null && chunk.foreground !== foreground) { + break + } + foreground ??= chunk.foreground + if (chunk.data.length <= remaining) { + data += chunk.data + remaining -= chunk.data.length + entry.queuedChars -= chunk.data.length + entry.chunkIndex += 1 continue } - data += chunk.slice(0, remaining) - entry.chunks[0] = chunk.slice(remaining) + data += chunk.data.slice(0, remaining) + entry.chunks[entry.chunkIndex] = { + ...chunk, + data: chunk.data.slice(remaining) + } + entry.queuedChars -= remaining remaining = 0 } - return data + compactConsumedChunks(entry) + if (entry.queuedChars < 0) { + entry.queuedChars = 0 + } + return data ? { data, foreground: foreground === true } : null +} + +function compactConsumedChunks(entry: QueueEntry): void { + if (entry.chunkIndex === 0) { + return + } + if (entry.chunkIndex === entry.chunks.length) { + entry.chunks.length = 0 + entry.chunkIndex = 0 + return + } + if (entry.chunkIndex >= 64) { + entry.chunks.splice(0, entry.chunkIndex) + entry.chunkIndex = 0 + } +} + +function enqueueChunk(entry: QueueEntry, data: string, options?: { foreground?: boolean }): void { + entry.chunks.push({ data, foreground: options?.foreground === true }) + entry.queuedChars += data.length +} + +function replaceBacklogWithWarning(entry: QueueEntry): void { + const shouldNotify = !entry.backgroundBacklogDropped + entry.chunks = [{ data: BACKGROUND_BACKLOG_WARNING, foreground: false }] + entry.chunkIndex = 0 + entry.queuedChars = BACKGROUND_BACKLOG_WARNING.length + entry.backgroundBacklogDropped = true + entry.highPriority = true + if (shouldNotify) { + entry.onBackgroundBacklogDropped?.() + } +} + +function hasQueuedChunks(entry: QueueEntry): boolean { + return entry.chunkIndex < entry.chunks.length +} + +function hasHighPriorityBacklog(): boolean { + for (const entry of queuedByTerminal.values()) { + if (entry.highPriority || entry.queuedChars > LARGE_BACKLOG_CHARS) { + return true + } + } + return false } function writeQueuedChunk(entry: QueueEntry): boolean { - const data = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) - if (!data) { + const queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) + if (!queuedWrite) { return false } try { - entry.beforeWrite?.(data) - entry.terminal.write(data) + entry.beforeWrite?.(queuedWrite.data) + if (queuedWrite.foreground) { + writeForegroundTerminalChunk(entry.terminal, queuedWrite.data) + } else { + entry.terminal.write(queuedWrite.data) + } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing // the scheduler for other panes still draining. entry.chunks.length = 0 + entry.chunkIndex = 0 + entry.queuedChars = 0 return false } return true @@ -132,9 +245,13 @@ function writeQueuedChunk(entry: QueueEntry): boolean { function drainQueuedOutput(): void { drainTimer = null + drainTimerDelayMs = null let writes = 0 + const maxWrites = hasHighPriorityBacklog() + ? HIGH_PRIORITY_MAX_WRITES_PER_DRAIN + : MAX_WRITES_PER_DRAIN - while (queuedByTerminal.size > 0 && writes < MAX_WRITES_PER_DRAIN) { + while (queuedByTerminal.size > 0 && writes < maxWrites) { const entry = queuedByTerminal.values().next().value if (!entry) { break @@ -147,8 +264,10 @@ function drainQueuedOutput(): void { debugState.backgroundWriteCount++ } } - if (entry.chunks.length > 0) { + if (hasQueuedChunks(entry)) { queuedByTerminal.set(entry.terminal, entry) + } else { + entry.highPriority = false } } @@ -156,14 +275,16 @@ function drainQueuedOutput(): void { debugState.drainWrites.push(writes) } if (queuedByTerminal.size > 0) { - scheduleDrain(BACKGROUND_DRAIN_INTERVAL_MS) + scheduleDrain( + hasHighPriorityBacklog() ? HIGH_PRIORITY_DRAIN_INTERVAL_MS : BACKGROUND_DRAIN_INTERVAL_MS + ) } } export function writeTerminalOutput( terminal: TerminalOutputTarget, data: string, - options: { foreground: boolean; beforeWrite?: TerminalOutputBeforeWrite } + options: WriteTerminalOutputOptions ): void { exposeDebugApi() if (!data) { @@ -171,6 +292,20 @@ export function writeTerminalOutput( } if (options.foreground) { + const entry = queuedByTerminal.get(terminal) + if (entry && entry.queuedChars > SYNC_FOREGROUND_FLUSH_CHARS) { + entry.beforeWrite = options.beforeWrite + entry.highPriority = true + enqueueChunk(entry, data, { foreground: true }) + if (debugEnabled) { + debugState.foregroundWriteCount++ + } + // Why: returning from a hidden window can have megabytes queued. Keep + // byte order, but drain it asynchronously so the first foreground frame + // is not pinned behind the entire backlog. + scheduleDrain(0) + return + } flushTerminalOutput(terminal) if (debugEnabled) { debugState.foregroundWriteCount++ @@ -182,44 +317,113 @@ export function writeTerminalOutput( let entry = queuedByTerminal.get(terminal) if (!entry) { - entry = { terminal, chunks: [], beforeWrite: options.beforeWrite } + entry = { + terminal, + chunks: [], + chunkIndex: 0, + queuedChars: 0, + beforeWrite: options.beforeWrite, + onBackgroundBacklogDropped: options.onBackgroundBacklogDropped, + backgroundBacklogDropped: false, + highPriority: false + } queuedByTerminal.set(terminal, entry) } else { entry.beforeWrite = options.beforeWrite + entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped + } + enqueueChunk(entry, data) + if ( + entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS || + entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS + ) { + replaceBacklogWithWarning(entry) } - entry.chunks.push(data) if (debugEnabled) { debugState.backgroundEnqueueCount++ } // Why: non-focused panes can produce output continuously. Letting every // pane call xterm.write immediately schedules one xterm WriteBuffer timer // per pane, which starves the focused terminal on the shared renderer thread. - scheduleDrain(BACKGROUND_FLUSH_DELAY_MS) + scheduleDrain( + entry.highPriority || entry.queuedChars > LARGE_BACKLOG_CHARS ? 0 : BACKGROUND_FLUSH_DELAY_MS + ) } -export function flushTerminalOutput(terminal: TerminalOutputTarget): void { +export function flushTerminalOutput( + terminal: TerminalOutputTarget, + options?: { maxChars?: number } +): void { exposeDebugApi() const entry = queuedByTerminal.get(terminal) if (!entry) { return } queuedByTerminal.delete(terminal) + if (entry.backgroundBacklogDropped && requestRegisteredTerminalBacklogRecovery(terminal)) { + entry.chunks.length = 0 + entry.chunkIndex = 0 + entry.queuedChars = 0 + entry.highPriority = false + return + } - let data = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) - while (data) { + let flushedChars = 0 + let queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) + while (queuedWrite) { + flushedChars += queuedWrite.data.length if (debugEnabled) { debugState.flushWriteCount++ } try { - entry.beforeWrite?.(data) - terminal.write(data) + entry.beforeWrite?.(queuedWrite.data) + if (queuedWrite.foreground) { + writeForegroundTerminalChunk(terminal, queuedWrite.data) + } else { + terminal.write(queuedWrite.data) + } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing // the scheduler for other panes still draining. return } - data = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) + if (options?.maxChars !== undefined && flushedChars >= options.maxChars) { + break + } + queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) + } + if (hasQueuedChunks(entry)) { + entry.highPriority = true + queuedByTerminal.set(terminal, entry) + scheduleDrain(0) + } else { + entry.highPriority = false + } +} + +function requestRegisteredTerminalBacklogRecovery(terminal: TerminalOutputTarget): boolean { + const requestRecovery = backlogRecoveryByTerminal.get(terminal) + if (!requestRecovery) { + return false + } + return requestRecovery() +} + +export function requestTerminalBacklogRecovery(terminal: TerminalOutputTarget): void { + exposeDebugApi() + requestRegisteredTerminalBacklogRecovery(terminal) +} + +export function registerTerminalBacklogRecovery( + terminal: TerminalOutputTarget, + requestRecovery: TerminalBacklogRecoveryRequest +): () => void { + backlogRecoveryByTerminal.set(terminal, requestRecovery) + return () => { + if (backlogRecoveryByTerminal.get(terminal) === requestRecovery) { + backlogRecoveryByTerminal.delete(terminal) + } } } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index fe7a3cd77..958b682a9 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1811,6 +1811,7 @@ function createPtyApi(): NonNullable['pty']> { getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), listSessions: () => Promise.resolve([]), + getMainBufferSnapshot: () => Promise.resolve(null), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, onExit: () => noopUnsubscribe, diff --git a/tests/e2e/terminal-output-scheduler.spec.ts b/tests/e2e/terminal-output-scheduler.spec.ts index df8974ae5..2fbab9808 100644 --- a/tests/e2e/terminal-output-scheduler.spec.ts +++ b/tests/e2e/terminal-output-scheduler.spec.ts @@ -55,6 +55,10 @@ function nodeConsoleCommand(expression: string): string { return `node -e "console.log(${expression})"` } +function nodeScriptCommand(script: string): string { + return `node -e "${script}"` +} + async function createTerminalTab(page: Page): Promise { const tabsBefore = await countRenderedTabs(page) const activeBefore = await getActiveTabId(page) @@ -148,6 +152,18 @@ async function sendPtyCommands( }, commands) } +async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise { + return page.evaluate( + async ({ targetPtyId, expectedText }) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 200 + }) + return snapshot?.data.includes(expectedText) ?? false + }, + { targetPtyId: ptyId, expectedText: text } + ) +} + test.describe('Terminal output scheduler', () => { test('background tab output bursts use the shared drain while the active tab renders', async ({ orcaPage @@ -249,4 +265,60 @@ test.describe('Terminal output scheduler', () => { }) .toBe(true) }) + + test('hidden overflow restores from main-owned terminal state when the tab becomes visible', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const foregroundTabId = await getActiveTabId(orcaPage) + if (!foregroundTabId) { + throw new Error('Expected an initial terminal tab') + } + const hiddenTabId = await createTerminalTab(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenPtyId = await waitForTabPtyId(orcaPage, hiddenTabId) + + await tabLocator(orcaPage, foregroundTabId).click() + await expect + .poll(() => getDomActiveTabId(orcaPage), { + timeout: 5_000, + message: 'Foreground terminal tab did not become active before hidden flood' + }) + .toBe(foregroundTabId) + + const marker = `HIDDEN_RECOVERY_${Date.now()}` + const floodCommand = nodeScriptCommand( + `for (let i = 0; i < 55000; i++) console.log('RECOVER_FILL_' + i + '_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'); console.log('${marker}')` + ) + + await sendPtyCommands(orcaPage, [{ ptyId: hiddenPtyId, command: floodCommand }]) + + await expect + .poll(async () => mainSnapshotContains(orcaPage, hiddenPtyId, marker), { + timeout: 30_000, + message: 'Main-owned terminal snapshot did not capture the hidden flood marker' + }) + .toBe(true) + + await tabLocator(orcaPage, hiddenTabId).click() + await expect + .poll(() => getDomActiveTabId(orcaPage), { + timeout: 5_000, + message: 'Hidden terminal tab did not become visible for recovery verification' + }) + .toBe(hiddenTabId) + + await expect + .poll(async () => (await getTerminalContent(orcaPage)).includes(marker), { + timeout: 10_000, + message: 'Hidden terminal did not restore the marker from main-owned state' + }) + .toBe(true) + + expect(await getTerminalContent(orcaPage)).not.toContain('Orca skipped hidden terminal output') + }) })