diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 3e0a5c69e..2da7757f0 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -4920,7 +4920,8 @@ describe('registerPtyHandlers', () => { data: 'snapshot\r\n', cols: 120, rows: 40, - seq: 42 + seq: 42, + source: 'headless' }) } handlers.clear() @@ -4934,7 +4935,13 @@ describe('registerPtyHandlers', () => { expect(runtime.serializeMainTerminalBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 50_000 }) - expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, seq: 42 }) + expect(result).toEqual({ + data: 'snapshot\r\n', + cols: 120, + rows: 40, + seq: 42, + source: 'headless' + }) }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index f3b58d620..38bee37b4 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1715,7 +1715,13 @@ export function registerPtyHandlers( async ( _event, args: { id?: unknown; opts?: { scrollbackRows?: unknown } } - ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => { + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { return null } diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index c37a930ea..ba607fc27 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -145,6 +145,7 @@ describe('mobile subscribe integration', () => { runtime.onPtyData('pty-1', first, Date.now()) const initialSnapshot = await runtime.serializeMainTerminalBuffer('pty-1') expect(initialSnapshot?.seq).toBe(first.length) + expect(initialSnapshot?.source).toBe('headless') type HeadlessStateForTest = { emulator: { write: (data: string) => Promise | void } @@ -179,12 +180,14 @@ describe('mobile subscribe integration', () => { const snapshot = await racedSnapshot expect(snapshot?.seq).toBe(first.length + second.length) + expect(snapshot?.source).toBe('headless') 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) + expect(finalSnapshot?.source).toBe('headless') } finally { headless!.emulator.write = originalWrite secondWriteGate.release?.() @@ -222,7 +225,8 @@ describe('mobile subscribe integration', () => { data: '', cols: 90, rows: 30, - seq: 17 + seq: 17, + source: 'headless' }) await expect(runtime.serializeTerminalBuffer('pty-empty')).resolves.toBeNull() }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2444fe05a..5e2dd5a3a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1289,7 +1289,10 @@ export class OrcaRuntimeService { // Why: mobile clients subscribe to terminal output via terminal.subscribe. // These listeners fire on every onPtyData call, enabling real-time streaming // without polling. Keyed by ptyId for O(1) lookup per data event. - private dataListeners = new Map void>>() + private dataListeners = new Map< + string, + Set<(data: string, meta?: { seq?: number; rawLength?: number }) => void> + >() // Why: startup draft paste can subscribe after the agent already emitted its // ready marker. Keep a bounded raw buffer so fast startup output is replayed. private recentPtyOutputById = new Map() @@ -3106,8 +3109,9 @@ export class OrcaRuntimeService { const listeners = this.dataListeners.get(ptyId) if (listeners) { + const meta = { seq: outputSequence, rawLength: data.length } for (const listener of listeners) { - listener(data) + listener(data, meta) } } return outputSequence @@ -3117,7 +3121,10 @@ export class OrcaRuntimeService { return this.ptyOutputSequenceById.get(ptyId) ?? 0 } - subscribeToTerminalData(ptyId: string, listener: (data: string) => void): () => void { + subscribeToTerminalData( + ptyId: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ): () => void { let listeners = this.dataListeners.get(ptyId) if (!listeners) { listeners = new Set() @@ -3183,14 +3190,26 @@ export class OrcaRuntimeService { serializeTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) } serializeMainTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -3385,7 +3404,13 @@ export class OrcaRuntimeService { private async serializeTerminalBufferFromAvailableState( ptyId: string, opts: { scrollbackRows?: number } = {} - ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, opts) if (headlessSnapshot) { return headlessSnapshot @@ -3412,15 +3437,21 @@ export class OrcaRuntimeService { // below can still preserve colored terminal state. } if (rendererSnapshot && rendererSnapshot.data.length > 0) { - return rendererSnapshot + return { ...rendererSnapshot, source: 'renderer' } } - return rendererSnapshot + return rendererSnapshot ? { ...rendererSnapshot, source: 'renderer' } : null } private async serializeHeadlessTerminalBuffer( ptyId: string, opts: { scrollbackRows?: number; includeEmpty?: boolean } = {} - ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> { + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' + } | null> { const state = this.headlessTerminals.get(ptyId) if (!state) { return null @@ -3438,7 +3469,13 @@ export class OrcaRuntimeService { const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true - ? { data, cols: snapshot.cols, rows: snapshot.rows, seq: state.outputSequence } + ? { + data, + cols: snapshot.cols, + rows: snapshot.rows, + seq: state.outputSequence, + source: 'headless' + } : null } diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index 3440f1763..104c0f3b4 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -22,6 +22,7 @@ import { TERMINAL_PANE_SPLIT_SOURCES } from '../../../../shared/feature-educatio // WebView's xterm has a 5000-row buffer so this fits comfortably. const MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000 const MOBILE_SNAPSHOT_BYTE_BUDGET = 512 * 1024 +const REQUESTED_SNAPSHOT_BYTE_BUDGET = 2 * 1024 * 1024 const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 const TERMINAL_OUTPUT_BATCH_MAX_CHARS = 64 * 1024 @@ -33,17 +34,21 @@ type SnapshotFrameOptions = { cols: number rows: number data: string + requestId?: number displayMode?: string reason?: string seq?: number truncated?: boolean truncatedByByteBudget?: boolean + source?: 'headless' | 'renderer' } type SerializedSnapshot = { data: string cols: number rows: number + seq?: number + source?: 'headless' | 'renderer' scrollbackRows: number truncatedByByteBudget: boolean } | null @@ -60,8 +65,9 @@ type TerminalMultiplexStream = { client: TerminalViewportClient | undefined isMobile: boolean buffering: boolean - pendingOutput: string[] + pendingOutput: TerminalOutputChunk[] pendingOutputChars: number + pendingOutputOverflowed: boolean outputBatcher: ReturnType unsubscribeData: () => void unsubscribeResize: () => void @@ -70,13 +76,21 @@ type TerminalMultiplexStream = { unregisterBinaryHandler: () => void } -function createTerminalOutputBatcher(onFlush: (data: string) => void): { - push: (data: string) => void +type TerminalOutputChunk = { + data: string + meta?: { seq?: number; rawLength?: number } +} + +function createTerminalOutputBatcher( + onFlush: (data: string, meta?: { seq?: number; rawLength?: number }) => void +): { + push: (data: string, meta?: { seq?: number; rawLength?: number }) => void flush: () => void dispose: () => void } { let chunks: string[] = [] let chars = 0 + let lastSeq: number | undefined let timer: ReturnType | null = null const clearTimer = (): void => { @@ -93,18 +107,23 @@ function createTerminalOutputBatcher(onFlush: (data: string) => void): { return } const data = chunks.length === 1 ? chunks[0]! : chunks.join('') + const meta = typeof lastSeq === 'number' ? { seq: lastSeq, rawLength: data.length } : undefined chunks = [] chars = 0 - onFlush(data) + lastSeq = undefined + onFlush(data, meta) } return { - push(data: string): void { + push(data: string, meta?: { seq?: number; rawLength?: number }): void { if (!data) { return } chunks.push(data) chars += data.length + if (typeof meta?.seq === 'number') { + lastSeq = meta.seq + } if (chars >= TERMINAL_OUTPUT_BATCH_MAX_CHARS) { flush() return @@ -157,28 +176,35 @@ function resolveMobileFloorClientId( return null } -function appendPendingMultiplexOutput(stream: TerminalMultiplexStream, data: string): void { - stream.pendingOutput.push(data) +function appendPendingMultiplexOutput( + stream: TerminalMultiplexStream, + data: string, + meta?: { seq?: number; rawLength?: number } +): void { + stream.pendingOutput.push({ data, meta }) stream.pendingOutputChars += data.length - stream.pendingOutputChars = trimPendingOutputToBudget( - stream.pendingOutput, - stream.pendingOutputChars - ) + const trimmed = trimPendingOutputToBudget(stream.pendingOutput, stream.pendingOutputChars) + stream.pendingOutputChars = trimmed.chars + stream.pendingOutputOverflowed ||= trimmed.overflowed } -function trimPendingOutputToBudget(pendingOutput: string[], pendingOutputChars: number): number { +function trimPendingOutputToBudget( + pendingOutput: (string | TerminalOutputChunk)[], + pendingOutputChars: number +): { chars: number; overflowed: boolean } { let omittedChunkCount = 0 while ( pendingOutputChars > TERMINAL_MULTIPLEX_PENDING_MAX_CHARS && omittedChunkCount < pendingOutput.length ) { - pendingOutputChars -= pendingOutput[omittedChunkCount].length + const chunk = pendingOutput[omittedChunkCount] + pendingOutputChars -= typeof chunk === 'string' ? chunk.length : chunk.data.length omittedChunkCount += 1 } if (omittedChunkCount > 0) { pendingOutput.splice(0, omittedChunkCount) } - return pendingOutputChars + return { chars: pendingOutputChars, overflowed: omittedChunkCount > 0 } } function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: boolean }): boolean { @@ -187,6 +213,43 @@ function isTerminalReadPayloadIncomplete(read: { truncated: boolean; limited?: b return read.truncated || read.limited === true } +function normalizeMultiplexSnapshotScrollbackRows(value: number | undefined): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return undefined + } + return Math.max(0, Math.min(50_000, Math.floor(value))) +} + +function requestedSnapshotScrollbackCandidates(requestedRows: number | undefined): number[] { + const candidates = [requestedRows ?? 0, 1000, 500, 250, 100, 25, 0] + .filter((rows): rows is number => typeof rows === 'number') + .map((rows) => Math.max(0, Math.min(50_000, Math.floor(rows)))) + return [...new Set(candidates)] +} + +async function serializeBudgetedRequestedSnapshot( + runtime: OrcaRuntimeService, + ptyId: string, + scrollbackRows: number | undefined +): Promise { + const requestedRows = scrollbackRows ?? 0 + for (const rows of requestedSnapshotScrollbackCandidates(scrollbackRows)) { + const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: rows }) + if (!serialized) { + return null + } + const bytes = new TextEncoder().encode(serialized.data).byteLength + if (bytes <= REQUESTED_SNAPSHOT_BYTE_BUDGET || rows === 0) { + return { + ...serialized, + scrollbackRows: rows, + truncatedByByteBudget: rows < requestedRows || bytes > REQUESTED_SNAPSHOT_BYTE_BUDGET + } + } + } + return null +} + function sendSnapshotFrames( sendFrame: (opcode: TerminalStreamOpcode, payload?: Uint8Array) => void, options: SnapshotFrameOptions @@ -197,9 +260,11 @@ function sendSnapshotFrames( kind: options.kind, cols: options.cols, rows: options.rows, + requestId: options.requestId, displayMode: options.displayMode, reason: options.reason, seq: options.seq, + source: options.source, truncated: options.truncated === true, truncatedByByteBudget: options.truncatedByByteBudget === true }) @@ -404,6 +469,11 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ viewport: TerminalViewport.optional() }) +const TerminalMultiplexSnapshotRequestFrame = z.object({ + requestId: z.number().int().positive().optional(), + scrollbackRows: z.number().finite().optional() +}) + const TerminalSetDisplayMode = TerminalHandle.extend({ // Why: 'phone' was previously a "stay at phone dims after unsubscribe" // mode that the toggle UI never produced and nothing in product @@ -732,12 +802,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const sendFrame = ( streamId: number, opcode: TerminalStreamOpcode, - payload: Uint8Array = new Uint8Array() + payload: Uint8Array = new Uint8Array(), + seq?: number ): void => { if (closed) { return } - sendBinary(encodeTerminalStreamFrame({ opcode, streamId, seq: cursor++, payload })) + sendBinary( + encodeTerminalStreamFrame({ + opcode, + streamId, + seq: typeof seq === 'number' ? seq : cursor++, + payload + }) + ) } const sendStreamError = (streamId: number, message: string): void => { sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) @@ -817,6 +895,99 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ { cols: viewport.cols, rows: viewport.rows }, stream.isMobile ? 'mobile' : 'desktop' ).catch(() => {}) + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { + const payload = TerminalMultiplexSnapshotRequestFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + void sendRequestedSnapshot(stream, payload.success ? payload.data : {}) + } + } + const sendRequestedSnapshot = async ( + stream: TerminalMultiplexStream, + request: z.infer + ): Promise => { + if (closed || streams.get(stream.streamId) !== stream) { + return + } + stream.outputBatcher.flush() + stream.pendingOutputOverflowed = false + stream.buffering = true + const requestId = request.requestId + try { + const scrollbackRows = normalizeMultiplexSnapshotScrollbackRows(request.scrollbackRows) + let serialized = await serializeBudgetedRequestedSnapshot( + runtime, + stream.ptyId, + scrollbackRows + ) + if (closed || streams.get(stream.streamId) !== stream) { + return + } + let size = runtime.getTerminalSize(stream.ptyId) + let displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + // Why: the overflowed tail is newer than the first snapshot. Retry + // so hidden restore receives a current terminal image instead of null. + stream.pendingOutput.splice(0) + stream.pendingOutputChars = 0 + stream.pendingOutputOverflowed = false + serialized = await serializeBudgetedRequestedSnapshot( + runtime, + stream.ptyId, + scrollbackRows + ) + if (closed || streams.get(stream.streamId) !== stream) { + return + } + size = runtime.getTerminalSize(stream.ptyId) + displayMode = runtime.getMobileDisplayMode(stream.ptyId) + if (stream.pendingOutputOverflowed) { + sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: size?.cols ?? 80, + rows: size?.rows ?? 24, + requestId, + displayMode, + truncated: true, + truncatedByByteBudget: false, + data: '' + }) + return + } + } + sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + requestId, + displayMode, + seq: serialized?.seq, + source: serialized?.source, + truncated: false, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? '' + }) + } catch (error) { + sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + const shouldFlushPendingOutput = !stream.pendingOutputOverflowed + stream.buffering = false + const pendingOutput = stream.pendingOutput.splice(0) + if (shouldFlushPendingOutput) { + for (const chunk of pendingOutput) { + stream.outputBatcher.push(chunk.data, chunk.meta) + } + } + stream.pendingOutputChars = 0 + stream.pendingOutputOverflowed = false + stream.outputBatcher.flush() + } } } const handleSubscribeFrame = async (payload: Uint8Array): Promise => { @@ -857,8 +1028,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ buffering: true, pendingOutput: [], pendingOutputChars: 0, - outputBatcher: createTerminalOutputBatcher((data) => { - sendFrame(request.streamId, TerminalStreamOpcode.Output, encodeTerminalStreamText(data)) + pendingOutputOverflowed: false, + outputBatcher: createTerminalOutputBatcher((data, meta) => { + sendFrame( + request.streamId, + TerminalStreamOpcode.Output, + encodeTerminalStreamText(data), + meta?.seq + ) }), unsubscribeData: () => {}, unsubscribeResize: () => {}, @@ -872,15 +1049,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) try { - stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed || streams.get(request.streamId) !== stream) { return } if (stream.buffering) { - appendPendingMultiplexOutput(stream, data) + appendPendingMultiplexOutput(stream, data, meta) return } - stream.outputBatcher.push(data) + stream.outputBatcher.push(data, meta) }) if (isMobile && request.client?.id) { @@ -924,7 +1101,8 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } const size = runtime.getTerminalSize(ptyId) const displayMode = runtime.getMobileDisplayMode(ptyId) - const seq = runtime.getLayout(ptyId)?.seq + const layoutSeq = runtime.getLayout(ptyId)?.seq + const snapshotSeq = serialized?.seq ?? layoutSeq if (!isMobile) { const fitOverride = runtime.getTerminalFitOverride(ptyId) emit({ @@ -947,7 +1125,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols, rows: serialized?.rows ?? size?.rows, displayMode, - seq, + seq: layoutSeq, truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read) }) sendSnapshotFrames((opcode, payload) => sendFrame(request.streamId, opcode, payload), { @@ -955,16 +1133,18 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ cols: serialized?.cols ?? size?.cols ?? 80, rows: serialized?.rows ?? size?.rows ?? 24, displayMode, - seq, + seq: snapshotSeq, truncated: serialized ? read.truncated : isTerminalReadPayloadIncomplete(read), truncatedByByteBudget: serialized?.truncatedByByteBudget, + source: serialized?.source, data: serialized?.data ?? (read.tail.length > 0 ? `${read.tail.join('\r\n')}\r\n` : '') }) stream.buffering = false - for (const data of stream.pendingOutput.splice(0)) { - stream.outputBatcher.push(data) + for (const chunk of stream.pendingOutput.splice(0)) { + stream.outputBatcher.push(chunk.data, chunk.meta) } stream.pendingOutputChars = 0 + stream.pendingOutputOverflowed = false stream.outputBatcher.flush() stream.unsubscribeResize = runtime.subscribeToTerminalResize(ptyId, (event) => { @@ -1000,10 +1180,9 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } } const unregisterControlHandler = registerBinaryStreamHandler(0, (frame) => { - if (frame.opcode !== TerminalStreamOpcode.Subscribe) { - return + if (frame.opcode === TerminalStreamOpcode.Subscribe) { + void handleSubscribeFrame(frame.payload) } - void handleSubscribeFrame(frame.payload) }) runtime.registerSubscriptionCleanup( @@ -1238,7 +1417,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ if (buffering) { pendingOutput.push(data) pendingOutputChars += data.length - pendingOutputChars = trimPendingOutputToBudget(pendingOutput, pendingOutputChars) + pendingOutputChars = trimPendingOutputToBudget(pendingOutput, pendingOutputChars).chars return } outputBatcher?.push(data) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index a8260b0e2..0578b6516 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -185,6 +185,46 @@ describe('terminal multiplex RPC', () => { rows: 40 }) + const frameCountBeforeSnapshotRequest = binaryFrames.length + handlers.get(5)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotRequest, + streamId: 5, + seq: 4, + payload: encodeTerminalStreamJson({ requestId: 7, scrollbackRows: 5000 }) + }) + )! + ) + await vi.waitFor(() => + expect( + binaryFrames + .slice(frameCountBeforeSnapshotRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd) + ).toBe(true) + ) + const requestedSnapshotFrames = binaryFrames + .slice(frameCountBeforeSnapshotRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + const requestedSnapshotStart = requestedSnapshotFrames.find( + (frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart + ) + expect( + requestedSnapshotStart && decodeTerminalStreamJson(requestedSnapshotStart.payload) + ).toMatchObject({ + requestId: 7 + }) + expect(runtime.serializeTerminalBuffer).toHaveBeenLastCalledWith('pty-1', { + scrollbackRows: 5000 + }) + expect( + requestedSnapshotFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('snapshot') + runtime.cleanupSubscription('terminal-multiplex:conn-1') await dispatchPromise } finally { @@ -288,6 +328,115 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('falls back to smaller requested snapshots when serialized data exceeds the send budget', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 }) + .mockResolvedValueOnce({ data: 'x'.repeat(2 * 1024 * 1024 + 1), cols: 120, rows: 40 }) + .mockResolvedValueOnce({ data: 'budgeted snapshot', cols: 120, rows: 40 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-budgeted-request', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 14, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + const frameCountBeforeSnapshotRequest = binaryFrames.length + + handlers.get(14)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotRequest, + streamId: 14, + seq: 2, + payload: encodeTerminalStreamJson({ requestId: 55, scrollbackRows: 5000 }) + }) + )! + ) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(3)) + + const requestedFrames = binaryFrames + .slice(frameCountBeforeSnapshotRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + const requestedStart = requestedFrames.find( + (frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart + ) + expect(requestedStart && decodeTerminalStreamJson(requestedStart.payload)).toMatchObject({ + requestId: 55, + truncatedByByteBudget: true + }) + expect(runtime.serializeTerminalBuffer).toHaveBeenNthCalledWith(2, 'pty-1', { + scrollbackRows: 5000 + }) + expect(runtime.serializeTerminalBuffer).toHaveBeenNthCalledWith(3, 'pty-1', { + scrollbackRows: 1000 + }) + expect( + requestedFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('budgeted snapshot') + + cleanups.get('terminal-multiplex:conn-budgeted-request')?.() + await dispatchPromise + }) + it('drops desktop multiplex input while a mobile client owns the terminal floor', async () => { const messages: string[] = [] const handlers = new Map< @@ -732,4 +881,155 @@ describe('terminal multiplex RPC', () => { vi.useRealTimers() } }) + + it('retries requested snapshots after live output overflows during serialization', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { current?: (data: string) => void } = {} + let resolveRequestedSnapshot: (value: { + data: string + cols: number + rows: number + }) => void = () => {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 }) + .mockImplementationOnce( + () => + new Promise<{ data: string; cols: number; rows: number }>((resolve) => { + resolveRequestedSnapshot = resolve + }) + ) + .mockResolvedValueOnce({ data: 'retry snapshot', cols: 120, rows: 40 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-request-overflow', + sendBinary: (bytes) => binaryFrames.push(bytes), + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 12, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + const frameCountBeforeSnapshotRequest = binaryFrames.length + + handlers.get(12)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotRequest, + streamId: 12, + seq: 2, + payload: encodeTerminalStreamJson({ requestId: 44, scrollbackRows: 5000 }) + }) + )! + ) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + for (let index = 0; index < 400; index += 1) { + dataListenerRef.current?.(String(index).padStart(3, '0') + 'x'.repeat(1021)) + } + resolveRequestedSnapshot({ data: 'requested', cols: 120, rows: 40 }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(3)) + await vi.waitFor(() => + expect( + binaryFrames + .slice(frameCountBeforeSnapshotRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd) + ).toBe(true) + ) + + const requestedFrames = binaryFrames + .slice(frameCountBeforeSnapshotRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + const requestedStart = requestedFrames.find( + (frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart + ) + expect(requestedStart && decodeTerminalStreamJson(requestedStart.payload)).toMatchObject({ + requestId: 44, + truncated: false + }) + expect( + requestedFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('retry snapshot') + expect( + requestedFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('') + + dataListenerRef.current?.('live-after-overflow') + await vi.runOnlyPendingTimersAsync() + const outputAfterOverflow = binaryFrames + .slice(frameCountBeforeSnapshotRequest) + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(outputAfterOverflow).toBe('live-after-overflow') + + cleanups.get('terminal-multiplex:conn-request-overflow')?.() + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5d67fda2c..61eb67635 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -908,7 +908,13 @@ export type PreloadApi = { getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } - ) => Promise<{ data: string; cols: number; rows: number; seq?: number } | null> + ) => Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null> onData: ( callback: (data: { id: string; data: string; seq?: number; rawLength?: number }) => void ) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index a77c9ecbf..f048724cd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -693,8 +693,13 @@ const api = { getMainBufferSnapshot: ( id: string, opts?: { scrollbackRows?: number } - ): Promise<{ data: string; cols: number; rows: number; seq?: number } | null> => - ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | 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. */ diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 9f73dd947..213a73aef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -104,6 +104,7 @@ type MockTransport = { sendInputAccepted?: ReturnType resize: ReturnType getPtyId: ReturnType + serializeBuffer?: ReturnType } const scheduleRuntimeGraphSync = vi.fn() @@ -221,7 +222,8 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport }), sendInput: vi.fn(() => true), resize: vi.fn(() => true), - getPtyId: vi.fn(() => ptyId) + getPtyId: vi.fn(() => ptyId), + serializeBuffer: undefined } as MockTransport const sendInput = transport.sendInput as unknown as (data: string) => boolean transport.sendInputAccepted = vi.fn(async (data: string) => sendInput(data)) @@ -3169,6 +3171,110 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('restores skipped hidden remote runtime output from the transport snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.serializeBuffer = vi.fn().mockResolvedValue({ + data: 'remote snapshot\r\n', + cols: 120, + rows: 40, + seq: 40, + source: 'headless' + }) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'remote:env-1@@terminal-1' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + 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) + + const hidden = 'hidden remote output\r\n' + const live = 'visible remote output\r\n' + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) + + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: 40 + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith('remote snapshot\r\n', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + disposable.dispose() + }) + + it('retries hidden remote runtime restore after a null transport snapshot', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.serializeBuffer = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ + data: 'remote recovered snapshot\r\n', + cols: 120, + rows: 40, + seq: 80, + source: 'headless' + }) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'remote:env-1@@terminal-1' + }) + transportFactoryQueue.push(transport) + + 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) + + const hidden = 'hidden remote output\r\n' + const firstLive = 'first visible output\r\n' + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(firstLive, { + seq: hidden.length + firstLive.length, + rawLength: firstLive.length + }) + await flushAsyncTicks(20) + + expect(transport.serializeBuffer).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + expect.stringContaining('Orca skipped hidden terminal output'), + expect.any(Function) + ) + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'remote recovered snapshot\r\n', + expect.any(Function) + ) + + await new Promise((resolve) => setTimeout(resolve, 80)) + await flushAsyncTicks(20) + + expect(transport.serializeBuffer).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + 'remote recovered snapshot\r\n', + expect.any(Function) + ) + disposable.dispose() + }) + 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') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 2ee573353..55177f2ac 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -9,7 +9,7 @@ import { import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' -import type { PtyConnectResult } from './pty-transport' +import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' import { createRemoteRuntimePtyTransport } from './remote-runtime-pty-transport' import { shouldSeedCacheTimerOnInitialTitle } from './cache-timer-seeding' @@ -88,6 +88,8 @@ 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 +const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 +const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_STARTUP_RENDERER_QUERY_WINDOW_MS = 10_000 const STARTUP_COMMAND_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 @@ -108,15 +110,29 @@ const HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING = '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because main recovery was unavailable.]\r\n' type E2eTerminalPtyDataInjectionApi = { - inject: (paneKey: string, data: string) => boolean + inject: (paneKey: string, data: string, meta?: PtyDataMeta) => boolean keys: () => string[] } type E2eTerminalPtyDataInjectionWindow = Window & { __terminalPtyDataInjection?: E2eTerminalPtyDataInjectionApi + __terminalHiddenSnapshotOverride?: E2eTerminalHiddenSnapshotOverrideApi } -const e2eTerminalPtyDataInjectors = new Map void>() +const e2eTerminalPtyDataInjectors = new Map void>() + +type E2eTerminalHiddenSnapshotOverrideApi = { + setPending: (ptyId: string, snapshot: PtyBufferSnapshot) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void +} + +type E2eTerminalHiddenSnapshotOverride = { + promise: Promise + resolve: () => void +} + +const e2eTerminalHiddenSnapshotOverrides = new Map() type E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: number @@ -181,21 +197,39 @@ function exposeE2eTerminalPtyDataInjection(): void { // e2e-only seam lets tests replay the renderer-side data callback exactly. const target = window as E2eTerminalPtyDataInjectionWindow target.__terminalPtyDataInjection ??= { - inject: (paneKey, data) => { + inject: (paneKey, data, meta) => { const inject = e2eTerminalPtyDataInjectors.get(paneKey) if (!inject) { return false } - inject(data) + inject(data, meta) return true }, keys: () => [...e2eTerminalPtyDataInjectors.keys()] } + target.__terminalHiddenSnapshotOverride ??= { + setPending: (ptyId, snapshot) => { + let resolve = (): void => {} + const wait = new Promise((nextResolve) => { + resolve = nextResolve + }) + e2eTerminalHiddenSnapshotOverrides.set(ptyId, { + promise: wait.then(() => snapshot), + resolve + }) + }, + resolve: (ptyId) => { + e2eTerminalHiddenSnapshotOverrides.get(ptyId)?.resolve() + }, + clear: (ptyId) => { + e2eTerminalHiddenSnapshotOverrides.delete(ptyId) + } + } } function registerE2eTerminalPtyDataInjection( paneKey: string, - inject: (data: string) => void + inject: (data: string, meta?: PtyDataMeta) => void ): () => void { if (!e2eConfig.exposeStore) { return () => {} @@ -209,6 +243,23 @@ function registerE2eTerminalPtyDataInjection( } } +function readE2eHiddenSnapshotOverride(ptyId: string): Promise | null { + if (!e2eConfig.exposeStore) { + return null + } + const override = e2eTerminalHiddenSnapshotOverrides.get(ptyId) + if (!override) { + return null + } + // Why: visual E2E needs to hold a hidden restore snapshot in flight so a + // newer live TUI frame can race it deterministically. + return override.promise.finally(() => { + if (e2eTerminalHiddenSnapshotOverrides.get(ptyId) === override) { + e2eTerminalHiddenSnapshotOverrides.delete(ptyId) + } + }) +} + function firstStartupCommandToken(command: string): string { const trimmed = command.trim() const quote = trimmed[0] @@ -546,6 +597,7 @@ export function connectPanePty( let connectFrame: number | null = null let unregisterBacklogRecovery: (() => void) | null = null let unregisterDocumentVisibilityRecovery: (() => void) | null = null + let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} let startupInjectTimer: ReturnType | null = null let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null @@ -1791,6 +1843,9 @@ export function connectPanePty( let hiddenOutputRestorePendingChars = 0 let hiddenOutputRestorePendingOverflow = false let hiddenOutputRestoreFreshSnapshotNeeded = false + let hiddenOutputRestoreRetryDeferred = false + let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null + let hiddenOutputRestoreDeferredRetryAttempts = 0 // 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 @@ -1806,6 +1861,33 @@ export function connectPanePty( return Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) } + function canUseHiddenOutputSnapshot(ptyId: string | null): ptyId is string { + if (!ptyId) { + return false + } + if (canUseMainBufferSnapshot(ptyId)) { + return true + } + return transport.getPtyId() === ptyId && typeof transport.serializeBuffer === 'function' + } + + async function serializeHiddenOutputSnapshot( + ptyId: string, + opts: { scrollbackRows?: number } + ): Promise { + const e2eSnapshot = readE2eHiddenSnapshotOverride(ptyId) + if (e2eSnapshot) { + return e2eSnapshot + } + if (canUseMainBufferSnapshot(ptyId)) { + return window.api.pty.getMainBufferSnapshot(ptyId, opts) + } + if (transport.getPtyId() !== ptyId || typeof transport.serializeBuffer !== 'function') { + return null + } + return transport.serializeBuffer(opts) + } + function isHiddenStartupRendererQueryWindowActive(): boolean { return ( paneStartup !== null && @@ -1936,7 +2018,7 @@ export function connectPanePty( } const parseHiddenStartupOutput = !foreground && - canUseMainBufferSnapshot(transport.getPtyId()) && + canUseHiddenOutputSnapshot(transport.getPtyId()) && isHiddenStartupRendererQueryWindowActive() const synchronizedOutputStarted = shouldProtectNativeWindowsSynchronizedOutput && @@ -1992,7 +2074,7 @@ export function connectPanePty( function markHiddenOutputRestoreNeeded(): void { const ptyId = transport.getPtyId() - if (!canUseMainBufferSnapshot(ptyId)) { + if (!canUseHiddenOutputSnapshot(ptyId)) { return } if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { @@ -2009,7 +2091,7 @@ export function connectPanePty( return ( !foreground && !deps.isVisibleRef.current && - canUseMainBufferSnapshot(transport.getPtyId()) && + canUseHiddenOutputSnapshot(transport.getPtyId()) && !isHiddenStartupRendererQueryWindowActive() ) } @@ -2028,7 +2110,7 @@ export function connectPanePty( return } const ptyId = transport.getPtyId() - if (!canUseMainBufferSnapshot(ptyId)) { + if (!canUseHiddenOutputSnapshot(ptyId)) { return } if (hiddenOutputRestorePtyId !== null && hiddenOutputRestorePtyId !== ptyId) { @@ -2115,6 +2197,44 @@ export function connectPanePty( hiddenOutputRestorePendingChars = 0 hiddenOutputRestorePendingOverflow = false hiddenOutputRestoreFreshSnapshotNeeded = false + hiddenOutputRestoreRetryDeferred = false + clearHiddenOutputRestoreDeferredRetryTimer() + hiddenOutputRestoreDeferredRetryAttempts = 0 + } + + function clearHiddenOutputRestoreDeferredRetryTimer(): void { + if (hiddenOutputRestoreDeferredRetryTimer === null) { + return + } + clearTimeout(hiddenOutputRestoreDeferredRetryTimer) + hiddenOutputRestoreDeferredRetryTimer = null + } + cleanupHiddenOutputRestoreDeferredRetry = clearHiddenOutputRestoreDeferredRetryTimer + + function scheduleHiddenOutputRestoreDeferredRetry(): void { + if ( + disposed || + hiddenOutputRestoreDeferredRetryTimer !== null || + !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) { + return + } + if (hiddenOutputRestoreDeferredRetryAttempts >= HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX) { + clearHiddenOutputRestoreState() + writeRestoreUnavailableWarning() + return + } + hiddenOutputRestoreDeferredRetryAttempts += 1 + // Why: null requested snapshots usually mean remote output was still + // mutating. Retry after one quiet tick instead of spinning synchronously. + hiddenOutputRestoreDeferredRetryTimer = setTimeout(() => { + hiddenOutputRestoreDeferredRetryTimer = null + if (disposed || !hiddenOutputRestoreNeeded) { + return + } + hiddenOutputRestoreRetryDeferred = false + requestHiddenOutputRestoreIfNeeded() + }, HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS) } function clearHiddenOutputRestoreState(): void { @@ -2214,13 +2334,15 @@ export function connectPanePty( if (!hiddenOutputRestoreNeeded && hiddenOutputRestorePendingChunks.length === 0) { return false } - if (!canUseMainBufferSnapshot(ptyId)) { + if (!canUseHiddenOutputSnapshot(ptyId)) { return false } hiddenOutputRestorePtyId = ptyId if (hiddenOutputRestoreInFlight) { return true } + clearHiddenOutputRestoreDeferredRetryTimer() + hiddenOutputRestoreRetryDeferred = false hiddenOutputRestoreInFlight = (async () => { while (!disposed) { @@ -2229,7 +2351,7 @@ export function connectPanePty( clearHiddenOutputRestoreState() return } - if (!canUseMainBufferSnapshot(currentPtyId)) { + if (!canUseHiddenOutputSnapshot(currentPtyId)) { if (hiddenOutputRestorePtyId === currentPtyId) { clearHiddenOutputRestoreState() } @@ -2244,9 +2366,9 @@ export function connectPanePty( } const restoreGeneration = hiddenOutputRestoreGeneration hiddenOutputRestoreNeeded = false - let snapshot: { data: string; cols: number; rows: number; seq?: number } | null = null + let snapshot: PtyBufferSnapshot | null = null try { - snapshot = await window.api.pty.getMainBufferSnapshot(currentPtyId, { + snapshot = await serializeHiddenOutputSnapshot(currentPtyId, { scrollbackRows: HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS }) } catch { @@ -2268,10 +2390,13 @@ export function connectPanePty( return } if (!snapshot) { - clearHiddenOutputRestoreState() - writeRestoreUnavailableWarning() + hiddenOutputRestoreNeeded = true + hiddenOutputRestoreFreshSnapshotNeeded = false + hiddenOutputRestoreRetryDeferred = true + scheduleHiddenOutputRestoreDeferredRetry() return } + hiddenOutputRestoreDeferredRetryAttempts = 0 applyMainBufferSnapshot(snapshot) const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded hiddenOutputRestoreFreshSnapshotNeeded = false @@ -2295,6 +2420,7 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true } if ( + !hiddenOutputRestoreRetryDeferred && hiddenOutputRestoreNeeded && shouldWritePtyOutputForeground(deps.isVisibleRef.current) ) { @@ -2384,9 +2510,9 @@ export function connectPanePty( }, 50) } } - unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data) => { + unregisterE2ePtyDataInjection = registerE2eTerminalPtyDataInjection(cacheKey, (data, meta) => { if (!disposed) { - dataCallback(data) + dataCallback(data, meta) } }) @@ -2998,6 +3124,7 @@ export function connectPanePty( pendingTerminalBellNotification = false clearTerminalBellNotificationTimer() clearReattachIdleAgentCursorResetTimer() + cleanupHiddenOutputRestoreDeferredRetry() unregisterBacklogRecovery?.() unregisterBacklogRecovery = null unregisterDocumentVisibilityRecovery?.() diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 03fe661f9..0673a7a5d 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -18,6 +18,14 @@ export type PtyDataMeta = { rawLength?: number } +export type PtyBufferSnapshot = { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' +} + export const ptyDataHandlers = new Map void>() /** Sidecar subscriptions that observe PTY data without owning the primary * handler. Used by features that need to react to the live byte stream @@ -323,6 +331,7 @@ export type PtyTransport = { ) => boolean isConnected: () => boolean getPtyId: () => string | null + serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void /** Unregister PTY handlers without killing the process, so a remounted * pane can reattach to the same running shell. */ diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index a448be954..c9452dc8a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1104,7 +1104,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(onReplayData).toHaveBeenCalledWith('hello') expect(onConnect).toHaveBeenCalled() - expect(onData).toHaveBeenCalledWith(' world') + expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 })) }) it('forwards input and cleanup through runtime RPC', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index ed840e968..c2827c4c4 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -38,6 +38,7 @@ export { export type { EagerPtyHandle, PtyTransport, + PtyBufferSnapshot, PtyConnectResult, IpcPtyTransportOptions } from './pty-dispatcher' diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 6d15c6925..e558e82a3 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -91,6 +91,13 @@ describe('createRemoteRuntimePtyTransport', () => { ) } + function latestFrameForOpcode(opcode: TerminalStreamOpcode) { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === opcode) + .at(-1) + } + function emitSnapshotFrame( streamId: number, opcode: @@ -788,7 +795,10 @@ describe('createRemoteRuntimePtyTransport', () => { 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after\x1b]0;. Claude working\x07\x07' ) - expect(onData).toHaveBeenCalledWith('beforeafter\x1b]0;. Claude working\x07\x07') + expect(onData).toHaveBeenCalledWith( + 'beforeafter\x1b]0;. Claude working\x07\x07', + expect.objectContaining({ seq: 1 }) + ) await vi.waitFor(() => expect(onAgentStatus).toHaveBeenCalledWith({ state: 'working', @@ -821,7 +831,7 @@ describe('createRemoteRuntimePtyTransport', () => { 'before\x1b]9999;{"state":"working","prompt":"ship it","agentType":"codex"}\x07after' ) - expect(onData).toHaveBeenCalledWith('beforeafter') + expect(onData).toHaveBeenCalledWith('beforeafter', expect.objectContaining({ seq: 1 })) await vi.waitFor(() => expect(onAgentStatus).toHaveBeenCalledWith({ state: 'working', @@ -1164,6 +1174,112 @@ describe('createRemoteRuntimePtyTransport', () => { expect(onConnect).toHaveBeenCalled() }) + it('resolves explicit binary snapshot requests without replaying into xterm', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onData = vi.fn() + const onConnect = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1' + }) + + await transport.connect({ url: '', callbacks: { onReplayData, onData, onConnect } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitSnapshot(streamId, 'initial') + expect(onReplayData).toHaveBeenCalledWith('initial') + expect(onConnect).toHaveBeenCalled() + + const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 }) + const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest) + const snapshotRequestPayload = snapshotRequestFrame + ? decodeTerminalStreamJson<{ requestId?: number; scrollbackRows?: number }>( + snapshotRequestFrame.payload + ) + : null + expect(snapshotRequestFrame?.streamId).toBe(streamId) + expect(snapshotRequestPayload).toMatchObject({ requestId: 1, scrollbackRows: 5000 }) + + emitSnapshotFrame( + streamId, + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ + kind: 'scrollback', + requestId: snapshotRequestPayload?.requestId, + cols: 132, + rows: 43, + seq: 17, + source: 'headless' + }) + ) + emitSnapshotFrame( + streamId, + TerminalStreamOpcode.SnapshotChunk, + encodeTerminalStreamText('requested snapshot') + ) + emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array()) + + await expect(snapshotPromise).resolves.toEqual({ + data: 'requested snapshot', + cols: 132, + rows: 43, + seq: 17, + source: 'headless' + }) + expect(onReplayData).toHaveBeenCalledTimes(1) + expect(onData).not.toHaveBeenCalledWith('requested snapshot', expect.anything()) + }) + + it('keeps initial replay separate from in-flight explicit binary snapshot requests', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const onConnect = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1' + }) + + await transport.connect({ url: '', callbacks: { onReplayData, onConnect } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + + const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 }) + const snapshotRequestFrame = latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest) + const snapshotRequestPayload = snapshotRequestFrame + ? decodeTerminalStreamJson<{ requestId?: number }>(snapshotRequestFrame.payload) + : null + expect(snapshotRequestPayload?.requestId).toBe(1) + + emitSnapshot(streamId, 'initial replay') + expect(onReplayData).toHaveBeenCalledWith('initial replay') + expect(onConnect).toHaveBeenCalled() + + emitSnapshotFrame( + streamId, + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ + kind: 'scrollback', + requestId: snapshotRequestPayload?.requestId, + cols: 100, + rows: 20 + }) + ) + emitSnapshotFrame( + streamId, + TerminalStreamOpcode.SnapshotChunk, + encodeTerminalStreamText('requested replay') + ) + emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array()) + + await expect(snapshotPromise).resolves.toEqual({ + data: 'requested replay', + cols: 100, + rows: 20, + seq: undefined, + source: undefined + }) + expect(onReplayData).toHaveBeenCalledTimes(1) + }) + it('bounds oversized binary snapshots without closing the live stream', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onReplayData = vi.fn() @@ -1194,6 +1310,6 @@ describe('createRemoteRuntimePtyTransport', () => { 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' ) expect(onConnect).toHaveBeenCalled() - expect(onData).toHaveBeenCalledWith('live-after-overflow') + expect(onData).toHaveBeenCalledWith('live-after-overflow', expect.objectContaining({ seq: 1 })) }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index cb123acc6..59680d733 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -310,7 +310,7 @@ export function createRemoteRuntimePtyTransport( client: { id: clientId, type: 'desktop' }, viewport: desiredViewport ?? undefined, callbacks: { - onData: (data) => outputProcessor.processData(data, storedCallbacks), + onData: (data, meta) => outputProcessor.processData(data, storedCallbacks, undefined, meta), onSnapshot: (data) => { if (data) { outputProcessor.processData(data, storedCallbacks, { @@ -506,6 +506,13 @@ export function createRemoteRuntimePtyTransport( return remotePtyId }, + async serializeBuffer(opts) { + if (!connected || !multiplexedStream) { + return null + } + return multiplexedStream.serializeBuffer(opts) + }, + destroy() { destroyed = true this.disconnect() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index 63c205ff3..d27577ab2 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -624,6 +624,26 @@ describe('pane terminal output scheduler', () => { expect(terminals[0].write).toHaveBeenCalledTimes(2) }) + it('keeps draining background chunks without per-write parse callback backpressure', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(16 * 1024) + + for (let i = 0; i < 6; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + vi.advanceTimersByTime(50) + vi.advanceTimersByTime(16) + + expect(terminal.write).toHaveBeenCalledTimes(4) + + vi.advanceTimersByTime(16) + + expect(terminal.write).toHaveBeenCalledTimes(6) + }) + it('promotes large background backlogs to high-priority drains', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 1013e3174..bd2f13fc6 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -3,6 +3,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' import { TerminalStreamOpcode, decodeTerminalStreamFrame, + decodeTerminalStreamJson, decodeTerminalStreamText, encodeTerminalStreamFrame, encodeTerminalStreamJson, @@ -35,7 +36,7 @@ type TerminalMultiplexEvent = | { type: string; streamId?: number; [key: string]: unknown } export type RemoteRuntimeMultiplexedTerminalCallbacks = { - onData: (data: string) => void + onData: (data: string, meta?: { seq?: number; rawLength?: number }) => void onSnapshot: (data: string) => void onSubscribed?: () => void onEnd?: () => void @@ -55,6 +56,13 @@ export type RemoteRuntimeMultiplexedTerminal = { streamId: number sendInput: (text: string) => boolean resize: (cols: number, rows: number) => boolean + serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null> close: () => void } @@ -65,10 +73,39 @@ type RemoteRuntimeMultiplexedTerminalState = { snapshotChunks: Uint8Array[] snapshotBytes: number snapshotOverflowed: boolean + snapshotTarget: 'initial' | 'request' + snapshotInfo: RemoteRuntimeSnapshotInfo | null + initialSnapshotReceived: boolean + pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null +} + +type RemoteRuntimeSnapshotInfo = { + cols?: number + rows?: number + seq?: number + source?: 'headless' | 'renderer' + requestId?: number + truncated?: boolean +} + +type RemoteRuntimeSnapshotRequest = { + requestId: number + resolve: ( + snapshot: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null + ) => void + reject: (error: Error) => void + timer: ReturnType } const CONTROL_STREAM_ID = 0 const MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES = 2 * 1024 * 1024 +const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000 const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' @@ -80,6 +117,7 @@ class RemoteRuntimeTerminalMultiplexer { private readyRejecter: ((error: Error) => void) | null = null private ready = false private nextStreamId = 1 + private nextSnapshotRequestId = 1 constructor( private readonly environmentId: string, @@ -102,7 +140,11 @@ class RemoteRuntimeTerminalMultiplexer { callbacks: args.callbacks, snapshotChunks: [], snapshotBytes: 0, - snapshotOverflowed: false + snapshotOverflowed: false, + snapshotTarget: 'initial', + snapshotInfo: null, + initialSnapshotReceived: false, + pendingSnapshotRequest: null } this.streams.set(streamId, state) @@ -116,9 +158,11 @@ class RemoteRuntimeTerminalMultiplexer { TerminalStreamOpcode.Resize, encodeTerminalStreamJson({ cols, rows }) ), + serializeBuffer: (opts) => this.requestSnapshot(state, opts), close: () => { if (this.streams.get(streamId) === state) { this.sendFrame(streamId, TerminalStreamOpcode.Unsubscribe) + rejectPendingSnapshotRequest(state, 'Remote terminal stream closed.') this.streams.delete(streamId) this.closeIfIdle() } @@ -240,11 +284,16 @@ class RemoteRuntimeTerminalMultiplexer { } if (event.type === 'end') { clearSnapshot(stream) + rejectPendingSnapshotRequest(stream, 'Remote terminal stream ended.') this.streams.delete(event.streamId) stream.callbacks.onEnd?.() this.closeIfIdle() } else if (event.type === 'error') { clearSnapshot(stream) + rejectPendingSnapshotRequest( + stream, + typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.' + ) stream.callbacks.onError?.( typeof event.message === 'string' ? event.message : 'Remote terminal stream failed.' ) @@ -279,11 +328,22 @@ class RemoteRuntimeTerminalMultiplexer { return } if (frame.opcode === TerminalStreamOpcode.Output) { - stream.callbacks.onData(decodeTerminalStreamText(frame.payload)) + const data = decodeTerminalStreamText(frame.payload) + stream.callbacks.onData(data, { + seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined, + rawLength: data.length + }) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { clearSnapshot(stream) + stream.snapshotInfo = decodeSnapshotInfo(frame.payload) + const requestId = stream.snapshotInfo?.requestId + stream.snapshotTarget = + typeof requestId === 'number' || + (stream.initialSnapshotReceived && stream.pendingSnapshotRequest) + ? 'request' + : 'initial' return } if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { @@ -292,28 +352,112 @@ class RemoteRuntimeTerminalMultiplexer { } stream.snapshotBytes += frame.payload.byteLength if (stream.snapshotBytes > MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES) { - clearSnapshot(stream) stream.snapshotOverflowed = true - stream.callbacks.onError?.(REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE) + if (stream.snapshotTarget === 'initial') { + stream.callbacks.onError?.(REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE) + } return } stream.snapshotChunks.push(frame.payload) return } if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) { - if (!stream.snapshotOverflowed) { - stream.callbacks.onSnapshot(decodeTerminalStreamText(concatBytes(stream.snapshotChunks))) + const data = stream.snapshotOverflowed + ? null + : decodeTerminalStreamText(concatBytes(stream.snapshotChunks)) + const target = stream.snapshotTarget + const info = stream.snapshotInfo + const pendingRequest = stream.pendingSnapshotRequest + const matchesPendingRequest = + target === 'request' && + pendingRequest && + (typeof info?.requestId === 'number' + ? info.requestId === pendingRequest.requestId + : stream.initialSnapshotReceived) + if (!stream.snapshotOverflowed && info?.truncated !== true) { + if (matchesPendingRequest) { + pendingRequest.resolve({ + data: data ?? '', + cols: info?.cols ?? 80, + rows: info?.rows ?? 24, + seq: info?.seq, + source: info?.source + }) + clearPendingSnapshotRequest(stream) + } else if (target === 'initial') { + stream.callbacks.onSnapshot(data ?? '') + } + } else if (matchesPendingRequest) { + pendingRequest.resolve(null) + clearPendingSnapshotRequest(stream) } clearSnapshot(stream) - stream.callbacks.onSubscribed?.() + if (target === 'initial') { + stream.initialSnapshotReceived = true + stream.callbacks.onSubscribed?.() + } return } if (frame.opcode === TerminalStreamOpcode.Error) { clearSnapshot(stream) + const pendingSnapshotRequest = stream.pendingSnapshotRequest + if (pendingSnapshotRequest) { + clearPendingSnapshotRequest(stream) + pendingSnapshotRequest.reject(new Error(decodeTerminalStreamText(frame.payload))) + return + } stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload)) } } + private requestSnapshot( + stream: RemoteRuntimeMultiplexedTerminalState, + opts?: { scrollbackRows?: number } + ): Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + } | null> { + if (this.streams.get(stream.streamId) !== stream || !this.ready || !this.subscription) { + return Promise.resolve(null) + } + if (stream.pendingSnapshotRequest) { + return Promise.reject(new Error('Remote terminal snapshot already in flight.')) + } + const requestId = this.allocateSnapshotRequestId() + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (stream.pendingSnapshotRequest?.timer === timer) { + stream.pendingSnapshotRequest = null + reject(new Error('Remote terminal snapshot timed out.')) + } + }, REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + stream.pendingSnapshotRequest = { requestId, resolve, reject, timer } + if ( + !this.sendFrame( + stream.streamId, + TerminalStreamOpcode.SnapshotRequest, + encodeTerminalStreamJson({ requestId, scrollbackRows: opts?.scrollbackRows }) + ) + ) { + clearPendingSnapshotRequest(stream) + resolve(null) + } + }) + } + + private allocateSnapshotRequestId(): number { + const id = this.nextSnapshotRequestId + this.nextSnapshotRequestId = + this.nextSnapshotRequestId >= 0x7fffffff ? 1 : this.nextSnapshotRequestId + 1 + return id + } + private sendFrame( streamId: number, opcode: TerminalStreamOpcode, @@ -357,6 +501,7 @@ class RemoteRuntimeTerminalMultiplexer { this.streams.clear() for (const stream of streams) { clearSnapshot(stream) + rejectPendingSnapshotRequest(stream, message ?? 'Remote runtime connection closed.') const canHandleClose = Boolean(stream.callbacks.onTransportClose) stream.callbacks.onTransportClose?.() if (message && !canHandleClose) { @@ -429,6 +574,52 @@ function clearSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void { stream.snapshotChunks = [] stream.snapshotBytes = 0 stream.snapshotOverflowed = false + stream.snapshotTarget = 'initial' + stream.snapshotInfo = null +} + +function clearPendingSnapshotRequest(stream: RemoteRuntimeMultiplexedTerminalState): void { + const request = stream.pendingSnapshotRequest + stream.pendingSnapshotRequest = null + if (request) { + clearTimeout(request.timer) + } +} + +function rejectPendingSnapshotRequest( + stream: RemoteRuntimeMultiplexedTerminalState, + message: string +): void { + const request = stream.pendingSnapshotRequest + if (!request) { + return + } + clearPendingSnapshotRequest(stream) + request.reject(new Error(message)) +} + +function decodeSnapshotInfo( + payload: Uint8Array +): RemoteRuntimeSnapshotInfo | null { + const raw = decodeTerminalStreamJson<{ + cols?: unknown + rows?: unknown + seq?: unknown + source?: unknown + requestId?: unknown + truncated?: unknown + }>(payload) + if (!raw) { + return null + } + return { + cols: typeof raw.cols === 'number' ? raw.cols : undefined, + rows: typeof raw.rows === 'number' ? raw.rows : undefined, + seq: typeof raw.seq === 'number' ? raw.seq : undefined, + source: raw.source === 'headless' || raw.source === 'renderer' ? raw.source : undefined, + requestId: typeof raw.requestId === 'number' ? raw.requestId : undefined, + truncated: raw.truncated === true + } } function isTerminalDriverState( diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index c7046378e..7dbdf3eb0 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -71,7 +71,7 @@ export async function subscribeToRuntimeTerminalData( terminal, client: { id: clientId, type: 'desktop' }, callbacks: { - onData: watcher, + onData: (data) => watcher(data), onSnapshot: watcher } }) diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 3cb446b3b..907e42595 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -19,7 +19,7 @@ export const RUNTIME_PROTOCOL_VERSION = 3 export const MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION = 2 -export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 2 +export const MIN_COMPATIBLE_RUNTIME_SERVER_VERSION = 3 export const RUNTIME_CAPABILITIES = [ 'runtime.status.compat.v1', diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index eb82bbc39..626973b10 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -68,7 +68,7 @@ describe('terminal-stream-protocol', () => { expect(resize && decodeTerminalStreamJson(resize.payload)).toEqual({ cols: 120, rows: 40 }) }) - it('round-trips multiplex subscribe and unsubscribe frames', () => { + it('round-trips multiplex subscribe, snapshot request, and unsubscribe frames', () => { const subscribe = decodeTerminalStreamFrame( encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Subscribe, @@ -89,12 +89,22 @@ describe('terminal-stream-protocol', () => { payload: new Uint8Array() }) ) + const snapshotRequest = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotRequest, + streamId: 12, + seq: 3, + payload: new Uint8Array() + }) + ) expect(subscribe?.opcode).toBe(TerminalStreamOpcode.Subscribe) expect(subscribe && decodeTerminalStreamJson(subscribe.payload)).toMatchObject({ streamId: 12, terminal: 'terminal-1' }) + expect(snapshotRequest?.opcode).toBe(TerminalStreamOpcode.SnapshotRequest) + expect(snapshotRequest?.streamId).toBe(12) expect(unsubscribe?.opcode).toBe(TerminalStreamOpcode.Unsubscribe) expect(unsubscribe?.streamId).toBe(12) }) diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 1dab528f9..9a993bee9 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -12,7 +12,8 @@ export enum TerminalStreamOpcode { Input = 7, Resize = 8, Subscribe = 9, - Unsubscribe = 10 + Unsubscribe = 10, + SnapshotRequest = 11 } export type TerminalStreamFrame = { @@ -90,6 +91,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Input || value === TerminalStreamOpcode.Resize || value === TerminalStreamOpcode.Subscribe || - value === TerminalStreamOpcode.Unsubscribe + value === TerminalStreamOpcode.Unsubscribe || + value === TerminalStreamOpcode.SnapshotRequest ) } diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts new file mode 100644 index 000000000..12bba9a10 --- /dev/null +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -0,0 +1,305 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +type HiddenTuiWindow = Window & { + __terminalPtyDataInjection?: { + inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean + } + __terminalPtyOutputDebug?: { + reset: () => void + snapshot: () => { + hiddenRendererSkipCount: number + hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number + } + } + __terminalHiddenSnapshotOverride?: { + setPending: ( + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' } + ) => void + resolve: (ptyId: string) => void + clear: (ptyId: string) => void + } +} + +type HiddenTuiDebugSnapshot = { + hiddenRendererSkipCount: number + hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number +} + +function tuiFrame(runId: string, frame: number): string { + const rows = [ + `OpenCode visual restore ${runId}`, + `Frame ${String(frame).padStart(3, '0')}`, + `Status ${frame % 2 === 0 ? 'thinking' : 'streaming'}`, + `Input echo ${'#'.repeat((frame % 18) + 1)}`, + `Diff +${frame * 3} -${frame}`, + `VISUAL_RESTORE_FINAL_${runId}_${frame}` + ] + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[?2026l' + ].join('') +} + +async function resetHiddenDebug(page: Page): Promise { + await page.evaluate(() => { + ;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset() + }) +} + +function writeHiddenFrameScript(scriptPath: string, runId: string): void { + const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame)) + writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`) +} + +async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise { + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) +} + +async function readHiddenDebug(page: Page): Promise { + return page.evaluate(() => { + return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null + }) +} + +async function injectPaneData( + page: Page, + paneKey: string, + data: string, + meta?: { seq?: number; rawLength?: number } +): Promise { + const injected = await page.evaluate( + ({ paneKey, data, meta }) => { + return (window as HiddenTuiWindow).__terminalPtyDataInjection?.inject(paneKey, data, meta) + }, + { paneKey, data, meta } + ) + if (!injected) { + throw new Error(`No terminal PTY data injector registered for ${paneKey}`) + } +} + +async function installDelayedMainSnapshot( + page: Page, + ptyId: string, + snapshot: { data: string; cols: number; rows: number; seq: number; source: 'headless' } +): Promise { + await page.evaluate( + ({ ptyId, snapshot }) => { + ;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.setPending(ptyId, snapshot) + }, + { ptyId, snapshot } + ) +} + +async function resolveDelayedMainSnapshot(page: Page, ptyId: string): Promise { + await page.evaluate((ptyId) => { + ;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.resolve(ptyId) + }, ptyId) +} + +async function clearDelayedMainSnapshot(page: Page, ptyId: string): Promise { + await page.evaluate((ptyId) => { + ;(window as HiddenTuiWindow).__terminalHiddenSnapshotOverride?.clear(ptyId) + }, ptyId) +} + +async function readMainSnapshotSource( + page: Page, + ptyId: string +): Promise<'headless' | 'renderer' | null> { + return page.evaluate(async (ptyId) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(ptyId, { + scrollbackRows: 200 + }) + return snapshot?.source ?? null + }, ptyId) +} + +test.describe('Hidden terminal TUI visual restore', () => { + test('restores skipped hidden full-screen TUI output without visible corruption', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const hiddenPane = hiddenSnapshot.panes[0] + if (!hiddenPane?.ptyId) { + throw new Error('hidden visual restore pane did not bind a PTY') + } + await switchToWorktree(orcaPage, firstWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'first worktree did not become active before hidden TUI injection' + }) + .toBe(firstWorktreeId) + + const runId = randomUUID() + const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24` + const scriptPath = path.join(testRepoPath, `.orca-hidden-tui-visual-${runId}.mjs`) + writeHiddenFrameScript(scriptPath, runId) + await resetHiddenDebug(orcaPage) + await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + + await expect + .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 10_000, + message: 'hidden TUI output did not exercise the skipped-renderer path' + }) + .toBeGreaterThan(0) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'hidden TUI restore did not use the runtime headless snapshot' + }) + .toBe('headless') + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'hidden TUI final frame did not restore when the workspace became visible' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame 024`) + expect(content).not.toContain('Orca skipped hidden terminal output') + + const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('hidden-tui-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + rmSync(scriptPath, { force: true }) + }) + + test('keeps newer live TUI output visually correct while hidden restore is in flight', async ({ + orcaPage + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const hiddenPane = hiddenSnapshot.panes[0] + if (!hiddenPane?.ptyId) { + throw new Error('hidden visual restore pane did not bind a PTY') + } + const paneKey = `${hiddenSnapshot.tabId}:${hiddenPane.leafId}` + + await switchToWorktree(orcaPage, firstWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'first worktree did not become active before hidden TUI injection' + }) + .toBe(firstWorktreeId) + + const runId = randomUUID() + const hiddenFrame = tuiFrame(runId, 40) + const liveFrame = tuiFrame(runId, 41) + const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41` + await resetHiddenDebug(orcaPage) + await injectPaneData(orcaPage, paneKey, hiddenFrame, { + seq: hiddenFrame.length, + rawLength: hiddenFrame.length + }) + + await expect + .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + timeout: 10_000, + message: 'hidden injected TUI output did not skip renderer parsing' + }) + .toBeGreaterThan(0) + + await installDelayedMainSnapshot(orcaPage, hiddenPane.ptyId, { + data: hiddenFrame, + cols: 120, + rows: 40, + seq: hiddenFrame.length, + source: 'headless' + }) + + try { + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await injectPaneData(orcaPage, paneKey, liveFrame, { + seq: hiddenFrame.length + liveFrame.length, + rawLength: liveFrame.length + }) + await resolveDelayedMainSnapshot(orcaPage, hiddenPane.ptyId) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'newer live TUI frame did not render after delayed hidden snapshot' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain('Frame 041') + expect(content).not.toContain('Frame 040') + expect(content).not.toContain('Orca skipped hidden terminal output') + + const screenshotPath = testInfo.outputPath('hidden-tui-delayed-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('hidden-tui-delayed-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + await clearDelayedMainSnapshot(orcaPage, hiddenPane.ptyId) + } + }) +})