Recover hidden terminal output from main-owned state (#2848)

* fix: avoid renderer stalls on terminal backlogs

* Recover hidden terminal output from main state
This commit is contained in:
Neil 2026-05-26 16:01:56 -07:00 committed by GitHub
parent 0d758753e1
commit e896dd3eb7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1987 additions and 111 deletions

View File

@ -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.

View File

@ -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 })
})
})
})

View File

@ -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<string, string>()
type PendingPtyData = {
data: string
startSeq?: number
}
const pendingData = new Map<string, PendingPtyData>()
const trustedTerminalHandleEnv = new Set<string>()
let flushTimer: ReturnType<typeof setTimeout> | 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 (

View File

@ -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> | void }
}
const headless = (
runtime as unknown as { headlessTerminals: Map<string, HeadlessStateForTest> }
).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<void>((resolve) => {
headless!.emulator.write = async (data: string): Promise<void> => {
if (data === second) {
resolve()
await new Promise<void>((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<void>
}
const runtimePrivate = runtime as unknown as {
headlessTerminals: Map<string, HeadlessStateForTest>
}
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()

View File

@ -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<void>
}
@ -1038,6 +1041,7 @@ export class OrcaRuntimeService {
private notificationListeners = new Set<(event: MobileNotificationEvent) => void>()
private ptysById = new Map<string, RuntimePtyWorktreeRecord>()
private headlessTerminals = new Map<string, RuntimeHeadlessTerminal>()
private ptyOutputSequenceById = new Map<string, number>()
// 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

View File

@ -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) => {

View File

@ -772,7 +772,13 @@ export type PreloadApi = {
getForegroundProcess: (id: string) => Promise<string | null>
getCwd: (id: string) => Promise<string>
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: (

View File

@ -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<boolean> =>
@ -676,9 +682,13 @@ const api = {
* Returns `''` when the id is unknown or the platform cannot resolve one. */
getCwd: (id: string): Promise<string> => 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)
},

View File

@ -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<T>(): { promise: Promise<T>; 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<string, unknown>).__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'> }).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()

View File

@ -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<string, Promise<string | null>>()
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<typeof setTimeout> | null = null
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | 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<void> | 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()

View File

@ -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<string, (data: string) => void>()
export type PtyDataMeta = {
seq?: number
rawLength?: number
}
export const ptyDataHandlers = new Map<string, (data: string, meta?: PtyDataMeta) => 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

View File

@ -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({})

View File

@ -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<typeof setTimeout> | null = null
let sideEffectDrainTimer: ReturnType<typeof setTimeout> | null = null
const pendingSideEffects: {
data: string
payloads: ReturnType<typeof processAgentStatusChunk>['payloads']
suppressAttentionEvents: boolean
}[] = []
let pendingSideEffects: PendingPtySideEffect[] = []
const agentTracker =
onAgentBecameIdle || onAgentBecameWorking || onAgentExited
? createAgentStatusTracker(
@ -135,7 +145,41 @@ export function createPtyOutputProcessor({
payloads: ReturnType<typeof processAgentStatusChunk>['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
)
})
}

View File

@ -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)

View File

@ -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

View File

@ -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<typeof ReactModule>()
return {
...actual,
useCallback: <T extends (...args: never[]) => unknown>(callback: T) => callback,
useEffect: (effect: () => void | (() => void)) => {
effect()
},
useRef: <T>(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()
})
})

View File

@ -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) {

View File

@ -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()

View File

@ -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<TerminalOutputTarget, QueueEntry>()
const backlogRecoveryByTerminal = new WeakMap<
TerminalOutputTarget,
TerminalBacklogRecoveryRequest
>()
let drainTimer: ReturnType<typeof setTimeout> | 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)
}
}
}

View File

@ -1811,6 +1811,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
getForegroundProcess: () => Promise.resolve(null),
getCwd: () => Promise.resolve('~'),
listSessions: () => Promise.resolve([]),
getMainBufferSnapshot: () => Promise.resolve(null),
onData: () => noopUnsubscribe,
onReplay: () => noopUnsubscribe,
onExit: () => noopUnsubscribe,

View File

@ -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<string> {
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<boolean> {
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')
})
})