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:
parent
0d758753e1
commit
e896dd3eb7
|
|
@ -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.
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue