diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 7bf7f1421..3fc37dd7e 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -3266,6 +3266,43 @@ describe('registerPtyHandlers', () => { } }) + it('batches repeated small PTY chunks after the interactive output budget is exhausted', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + + writeListener(null, { + id: spawnResult.id, + data: 'a' + }) + mainWindow.webContents.send.mockClear() + + const smallChunk = 'x'.repeat(512) + for (let index = 0; index < 65; index++) { + mockProc.emitData(smallChunk) + } + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(64) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(65) + expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(65, 'pty:data', { + id: spawnResult.id, + data: smallChunk + }) + } finally { + vi.useRealTimers() + } + }) + it('sends larger ANSI redraws immediately after terminal input', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 33e71e71e..70ab51a24 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -83,6 +83,7 @@ const ptySizes = new Map() // is PTY-scoped and must be cleared by every teardown path, including SSH and // daemon shutdowns that do not flow through the local provider exit listener. const lastInputAtByPty = new Map() +const interactiveOutputCharsByPty = new Map() // Why: hidden renderer panes restore from main-owned snapshots, so ordinary // PTY bytes do not need to wake the renderer while a pane is hidden. const rendererPausedOutputPtys = new Set() @@ -737,6 +738,7 @@ export function clearProviderPtyState(id: string): void { piTitlebarExtensionService.clearPty(id) ptySizes.delete(id) lastInputAtByPty.delete(id) + interactiveOutputCharsByPty.delete(id) rendererPausedOutputPtys.delete(id) rendererPausedMode2031ScanTailByPty.delete(id) const paneKey = ptyPaneKey.get(id) @@ -947,6 +949,7 @@ export function registerPtyHandlers( const INTERACTIVE_OUTPUT_WINDOW_MS = 100 const INTERACTIVE_OUTPUT_MAX_CHARS = 1024 const INTERACTIVE_REDRAW_MAX_CHARS = PTY_BATCH_FLUSH_CHUNK_CHARS + const INTERACTIVE_OUTPUT_BUDGET_CHARS = 32 * 1024 const BACKGROUND_OUTPUT_INPUT_QUIET_MS = 50 const BACKGROUND_OUTPUT_MAX_INPUT_HOLD_MS = 250 let lastRendererInputAt = Number.NEGATIVE_INFINITY @@ -963,6 +966,25 @@ export function registerPtyHandlers( return data.length <= INTERACTIVE_REDRAW_MAX_CHARS && data.includes('\x1b[') } + function shouldSendInteractiveOutputNow(id: string, data: string, now: number): boolean { + const lastInputAt = lastInputAtByPty.get(id) + if (lastInputAt === undefined || now - lastInputAt > INTERACTIVE_OUTPUT_WINDOW_MS) { + interactiveOutputCharsByPty.delete(id) + return false + } + if (!isLikelyInteractiveRedraw(data)) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + const usedChars = interactiveOutputCharsByPty.get(id) ?? 0 + if (usedChars + data.length > INTERACTIVE_OUTPUT_BUDGET_CHARS) { + interactiveOutputCharsByPty.set(id, INTERACTIVE_OUTPUT_BUDGET_CHARS) + return false + } + interactiveOutputCharsByPty.set(id, usedChars + data.length) + return true + } + function getChunkStartSeq(endSeq: number | undefined, data: string): number | undefined { return typeof endSeq === 'number' ? Math.max(0, endSeq - data.length) : undefined } @@ -1185,11 +1207,11 @@ export function registerPtyHandlers( const existing = pendingData.get(payload.id) const pending = appendPendingPtyData(existing, payload.data, startSeq) const nextData = pending.data - const lastInputAt = lastInputAtByPty.get(payload.id) - const isInteractiveOutput = - isLikelyInteractiveRedraw(nextData) && - lastInputAt !== undefined && - performance.now() - lastInputAt <= INTERACTIVE_OUTPUT_WINDOW_MS + const isInteractiveOutput = shouldSendInteractiveOutputNow( + payload.id, + nextData, + performance.now() + ) if (isInteractiveOutput) { pendingData.delete(payload.id) clearFlushTimerIfIdle() @@ -1229,6 +1251,7 @@ export function registerPtyHandlers( pendingData.delete(payload.id) } lastInputAtByPty.delete(payload.id) + interactiveOutputCharsByPty.delete(payload.id) if (lastRendererInputPtyId === payload.id) { lastRendererInputPtyId = null } @@ -2209,6 +2232,7 @@ export function registerPtyHandlers( lastRendererInputAt = now lastRendererInputPtyId = args.id lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) provider.write(args.id, args.data) return true } catch { @@ -2236,6 +2260,7 @@ export function registerPtyHandlers( lastRendererInputAt = now lastRendererInputPtyId = args.id lastInputAtByPty.set(args.id, now) + interactiveOutputCharsByPty.set(args.id, 0) provider.write(args.id, args.data) return true } catch { 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 24c994615..d298f2bdb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -545,6 +545,55 @@ describe('connectPanePty', () => { ) }) + it('queues visible bulk output off the synchronous xterm write path', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + }) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + vi.useFakeTimers() + capturedDataCallback.current?.('x'.repeat(16 * 1024)) + + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(0) + expect(pane.terminal.write).toHaveBeenCalledWith('x'.repeat(16 * 1024), expect.any(Function)) + }) + + it('keeps ANSI redraws after terminal input on the immediate xterm write path', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-1') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-1' + }) + transportFactoryQueue.push(transport) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + const onDataMock = pane.terminal.onData as unknown as { + mock: { calls: [[(data: string) => void] | []] } + } + const terminalInputHandler = onDataMock.mock.calls[0]?.[0] + expect(terminalInputHandler).toBeTypeOf('function') + terminalInputHandler?.('a') + + const redraw = `\x1b[2J\x1b[H${'codex composer redraw '.repeat(200)}` + capturedDataCallback.current?.(redraw) + + expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function)) + }) + it('keeps the surviving split pane mounted when an intentional pane-close PTY exit arrives', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-pane-2') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 85e374b6b..9a6096ee7 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -84,6 +84,9 @@ 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 const REATTACH_IDLE_AGENT_CURSOR_RESET_DELAY_MS = 250 +const FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS = 2048 +const FOREGROUND_INTERACTIVE_REDRAW_CHARS = 16 * 1024 +const FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS = 150 // 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 = @@ -1076,6 +1079,10 @@ export function connectPanePty( const activeRuntimeEnvironmentId = state.settings?.activeRuntimeEnvironmentId?.trim() || null const runtimeEnvironmentId = remoteRuntimeOwnerForTransport ?? activeRuntimeEnvironmentId const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' + let lastTerminalInputAt = Number.NEGATIVE_INFINITY + const markTerminalInputSent = (): void => { + lastTerminalInputAt = performance.now() + } const transportOptions = { cwd: deps.cwd, env: paneEnv, @@ -1180,6 +1187,7 @@ export function connectPanePty( const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data) if (acknowledgedIntent && transport.sendInputAccepted) { clearPendingTerminalInputIntent() + markTerminalInputSent() const writePromise = transport .sendInputAccepted(data) .then((accepted) => { @@ -1197,12 +1205,14 @@ export function connectPanePty( } if (intent) { if (transport.sendInput(data)) { + markTerminalInputSent() observeAcceptedTerminalInput(data, intent) } clearPendingTerminalInputIntent() return } if (transport.sendInput(data)) { + markTerminalInputSent() observeAcceptedTerminalInput(data) observeSentTerminalInputIntent(data) } else { @@ -1581,6 +1591,58 @@ export function connectPanePty( recordTerminalOutput(pane.terminal) } + function isLatencySensitiveForegroundOutput(data: string): boolean { + if (data.length <= FOREGROUND_THROUGHPUT_IMMEDIATE_CHARS) { + return true + } + const recentInput = + performance.now() - lastTerminalInputAt <= FOREGROUND_INTERACTIVE_REDRAW_WINDOW_MS + return ( + recentInput && data.length <= FOREGROUND_INTERACTIVE_REDRAW_CHARS && data.includes('\x1b[') + ) + } + + function containsNonAsciiOutput(data: string): boolean { + for (let index = 0; index < data.length; index++) { + if (data.charCodeAt(index) > 0x7f) { + return true + } + } + return false + } + + function containsWindowsRewriteControl(data: string): boolean { + if (data.includes('\r') || data.includes('\b')) { + return true + } + let escapeIndex = data.indexOf('\x1b[') + while (escapeIndex !== -1) { + for (let index = escapeIndex + 2; index < data.length; index++) { + const char = data[index] + if (char >= '0' && char <= '9') { + continue + } + if (char === ';' || char === '?') { + continue + } + if (char === 'J' || char === 'K') { + return true + } + break + } + escapeIndex = data.indexOf('\x1b[', escapeIndex + 2) + } + return false + } + + function shouldForceForegroundRenderRefresh(data: string): boolean { + return ( + shouldSuppressForegroundCursor && + containsNonAsciiOutput(data) && + containsWindowsRewriteControl(data) + ) + } + function writePtyOutputToXterm(data: string, foreground: boolean): void { const parseHiddenStartupOutput = !foreground && @@ -1604,7 +1666,11 @@ export function connectPanePty( writeTerminalOutput(pane.terminal, data, { foreground: foreground || parseHiddenStartupOutput, beforeWrite: beforeTerminalOutputWrite, - onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded + onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, + latencySensitive: + !foreground || parseHiddenStartupOutput ? true : isLatencySensitiveForegroundOutput(data), + forceForegroundRefresh: + (foreground || parseHiddenStartupOutput) && shouldForceForegroundRenderRefresh(data) }) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 036f9b670..3bb755111 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -22,6 +22,10 @@ export type ForegroundTerminalOutputTarget = TerminalCursorSuppressionTarget & { write(data: string, callback?: () => void): void } +type ForegroundTerminalWriteOptions = { + forceViewportRefresh?: boolean +} + const pendingViewportSettleRefreshByTerminal = new WeakMap< ForegroundTerminalOutputTarget, { kind: 'raf'; id: number } | { kind: 'timeout'; id: ReturnType } @@ -123,20 +127,27 @@ function settleForegroundRender( export function writeForegroundTerminalChunk( terminal: ForegroundTerminalOutputTarget, - data: string + data: string, + options: ForegroundTerminalWriteOptions = {} ): void { - const beforeWriteViewport = captureViewportSnapshot(terminal) + const beforeWriteViewport = options.forceViewportRefresh + ? captureViewportSnapshot(terminal) + : null suppressForegroundTerminalCursor(terminal) // Why: a disposed terminal may never fire xterm's write callback; keep a // safety restore so the cursor cannot remain hidden after teardown races. scheduleForegroundTerminalCursorRestore(terminal, FOREGROUND_CURSOR_RESTORE_SAFETY_DELAY_MS) try { terminal.write(data, () => { - settleForegroundRender(terminal, beforeWriteViewport) + if (beforeWriteViewport) { + settleForegroundRender(terminal, beforeWriteViewport) + } scheduleForegroundTerminalCursorRestore(terminal) }) } catch { - settleForegroundRender(terminal, beforeWriteViewport) + if (beforeWriteViewport) { + settleForegroundRender(terminal, beforeWriteViewport) + } restoreForegroundTerminalCursor(terminal) } } 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 6301c35fd..431245643 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 @@ -66,7 +66,10 @@ describe('pane terminal output scheduler', () => { callback?.() }) - writeTerminalOutput(terminal, '中文 PowerShell repaint\r\n', { foreground: true }) + writeTerminalOutput(terminal, '中文 PowerShell repaint\r\n', { + foreground: true, + forceForegroundRefresh: true + }) expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true) expect(terminal.refresh).not.toHaveBeenCalled() @@ -90,7 +93,10 @@ describe('pane terminal output scheduler', () => { callback?.() }) - writeTerminalOutput(terminal, '顶部滚动中文复现\r\n', { foreground: true }) + writeTerminalOutput(terminal, '顶部滚动中文复现\r\n', { + foreground: true, + forceForegroundRefresh: true + }) expect(terminal._core.refresh).toHaveBeenCalledTimes(1) expect(scheduledFrames).toHaveLength(1) @@ -101,6 +107,16 @@ describe('pane terminal output scheduler', () => { expect(terminal._core.refresh).toHaveBeenLastCalledWith(0, 23, true) }) + it('skips forced viewport refresh for ordinary foreground output', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + + writeTerminalOutput(terminal, 'plain foreground output\r\n', { foreground: true }) + + expect(terminal._core.refresh).not.toHaveBeenCalled() + expect(terminal.refresh).not.toHaveBeenCalled() + }) + it('hides the foreground cursor until output parsing has gone quiet', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -149,6 +165,29 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('ab') }) + it('defers throughput foreground output to the shared high-priority drain', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + + writeTerminalOutput(terminal, 'a'.repeat(16 * 1024), { + foreground: true, + latencySensitive: false + }) + writeTerminalOutput(terminal, 'b'.repeat(16 * 1024), { + foreground: true, + latencySensitive: false + }) + + expect(terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(0) + + expect(terminal.write).toHaveBeenCalledTimes(2) + expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe( + `${'a'.repeat(16 * 1024)}${'b'.repeat(16 * 1024)}` + ) + }) + it('defers background write preparation until coalesced output drains', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 3a65d0311..75b42cc80 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -18,16 +18,20 @@ type WriteTerminalOutputOptions = { foreground: boolean beforeWrite?: TerminalOutputBeforeWrite onBackgroundBacklogDropped?: () => void + latencySensitive?: boolean + forceForegroundRefresh?: boolean } type QueueChunk = { data: string foreground: boolean + forceForegroundRefresh: boolean } type QueuedWrite = { data: string foreground: boolean + forceForegroundRefresh: boolean } type QueueEntry = { @@ -72,8 +76,10 @@ const debugEnabled = e2eConfig.exposeStore type TerminalOutputSchedulerDebugSnapshot = { backgroundEnqueueCount: number + deferredForegroundEnqueueCount: number foregroundWriteCount: number backgroundWriteCount: number + deferredForegroundWriteCount: number flushWriteCount: number scheduledDrainCount: number drainWrites: number[] @@ -86,8 +92,10 @@ type TerminalOutputSchedulerDebugApi = { const debugState: TerminalOutputSchedulerDebugSnapshot = { backgroundEnqueueCount: 0, + deferredForegroundEnqueueCount: 0, foregroundWriteCount: 0, backgroundWriteCount: 0, + deferredForegroundWriteCount: 0, flushWriteCount: 0, scheduledDrainCount: 0, drainWrites: [] @@ -95,8 +103,10 @@ const debugState: TerminalOutputSchedulerDebugSnapshot = { function resetDebugState(): void { debugState.backgroundEnqueueCount = 0 + debugState.deferredForegroundEnqueueCount = 0 debugState.foregroundWriteCount = 0 debugState.backgroundWriteCount = 0 + debugState.deferredForegroundWriteCount = 0 debugState.flushWriteCount = 0 debugState.scheduledDrainCount = 0 debugState.drainWrites = [] @@ -143,13 +153,21 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let remaining = limit let data = '' let foreground: boolean | null = null + let forceForegroundRefresh: boolean | null = null while (remaining > 0 && entry.chunkIndex < entry.chunks.length) { const chunk = entry.chunks[entry.chunkIndex] if (foreground !== null && chunk.foreground !== foreground) { break } + if ( + forceForegroundRefresh !== null && + chunk.forceForegroundRefresh !== forceForegroundRefresh + ) { + break + } foreground ??= chunk.foreground + forceForegroundRefresh ??= chunk.forceForegroundRefresh if (chunk.data.length <= remaining) { data += chunk.data remaining -= chunk.data.length @@ -171,7 +189,13 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { if (entry.queuedChars < 0) { entry.queuedChars = 0 } - return data ? { data, foreground: foreground === true } : null + return data + ? { + data, + foreground: foreground === true, + forceForegroundRefresh: forceForegroundRefresh === true + } + : null } function compactConsumedChunks(entry: QueueEntry): void { @@ -189,14 +213,24 @@ function compactConsumedChunks(entry: QueueEntry): void { } } -function enqueueChunk(entry: QueueEntry, data: string, options?: { foreground?: boolean }): void { - entry.chunks.push({ data, foreground: options?.foreground === true }) +function enqueueChunk( + entry: QueueEntry, + data: string, + options?: { foreground?: boolean; forceForegroundRefresh?: boolean } +): void { + entry.chunks.push({ + data, + foreground: options?.foreground === true, + forceForegroundRefresh: options?.forceForegroundRefresh === true + }) entry.queuedChars += data.length } function replaceBacklogWithWarning(entry: QueueEntry): void { const shouldNotify = !entry.backgroundBacklogDropped - entry.chunks = [{ data: BACKGROUND_BACKLOG_WARNING, foreground: false }] + entry.chunks = [ + { data: BACKGROUND_BACKLOG_WARNING, foreground: false, forceForegroundRefresh: false } + ] entry.chunkIndex = 0 entry.queuedChars = BACKGROUND_BACKLOG_WARNING.length entry.backgroundBacklogDropped = true @@ -219,15 +253,17 @@ function hasHighPriorityBacklog(): boolean { return false } -function writeQueuedChunk(entry: QueueEntry): boolean { +function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { const queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) if (!queuedWrite) { - return false + return null } try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { - writeForegroundTerminalChunk(entry.terminal, queuedWrite.data) + writeForegroundTerminalChunk(entry.terminal, queuedWrite.data, { + forceViewportRefresh: queuedWrite.forceForegroundRefresh + }) } else { entry.terminal.write(queuedWrite.data) } @@ -238,9 +274,9 @@ function writeQueuedChunk(entry: QueueEntry): boolean { entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 - return false + return null } - return true + return queuedWrite.foreground ? 'foreground' : 'background' } function drainQueuedOutput(): void { @@ -258,10 +294,15 @@ function drainQueuedOutput(): void { } queuedByTerminal.delete(entry.terminal) - if (writeQueuedChunk(entry)) { + const writeKind = writeQueuedChunk(entry) + if (writeKind) { writes++ if (debugEnabled) { - debugState.backgroundWriteCount++ + if (writeKind === 'foreground') { + debugState.deferredForegroundWriteCount++ + } else { + debugState.backgroundWriteCount++ + } } } if (hasQueuedChunks(entry)) { @@ -296,9 +337,13 @@ export function writeTerminalOutput( if (entry && entry.queuedChars > SYNC_FOREGROUND_FLUSH_CHARS) { entry.beforeWrite = options.beforeWrite entry.highPriority = true - enqueueChunk(entry, data, { foreground: true }) + enqueueChunk(entry, data, { + foreground: true, + forceForegroundRefresh: options.forceForegroundRefresh + }) if (debugEnabled) { debugState.foregroundWriteCount++ + debugState.deferredForegroundEnqueueCount++ } // Why: returning from a hidden window can have megabytes queued. Keep // byte order, but drain it asynchronously so the first foreground frame @@ -306,12 +351,47 @@ export function writeTerminalOutput( scheduleDrain(0) return } + if (options.latencySensitive === false) { + let queued = entry + if (!queued) { + queued = { + terminal, + chunks: [], + chunkIndex: 0, + queuedChars: 0, + beforeWrite: options.beforeWrite, + onBackgroundBacklogDropped: options.onBackgroundBacklogDropped, + backgroundBacklogDropped: false, + highPriority: true + } + queuedByTerminal.set(terminal, queued) + } else { + queued.beforeWrite = options.beforeWrite + queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped + queued.highPriority = true + } + enqueueChunk(queued, data, { + foreground: true, + forceForegroundRefresh: options.forceForegroundRefresh + }) + if (debugEnabled) { + debugState.foregroundWriteCount++ + debugState.deferredForegroundEnqueueCount++ + } + // Why: visible command floods are throughput work, not keystroke echo. + // Queue them behind a zero-delay drain so one IPC callback cannot pin + // the renderer in xterm.write while input and paint are waiting. + scheduleDrain(0) + return + } flushTerminalOutput(terminal) if (debugEnabled) { debugState.foregroundWriteCount++ } options.beforeWrite?.(data) - writeForegroundTerminalChunk(terminal, data) + writeForegroundTerminalChunk(terminal, data, { + forceViewportRefresh: options.forceForegroundRefresh + }) return } @@ -378,7 +458,9 @@ export function flushTerminalOutput( try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { - writeForegroundTerminalChunk(terminal, queuedWrite.data) + writeForegroundTerminalChunk(terminal, queuedWrite.data, { + forceViewportRefresh: queuedWrite.forceForegroundRefresh + }) } else { terminal.write(queuedWrite.data) } diff --git a/tests/e2e/terminal-output-scheduler.spec.ts b/tests/e2e/terminal-output-scheduler.spec.ts index 823a28865..b3fb147b4 100644 --- a/tests/e2e/terminal-output-scheduler.spec.ts +++ b/tests/e2e/terminal-output-scheduler.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- Scheduler E2E coverage shares one booted Electron app and debug API. */ /** * E2E repro for terminal output bursts from many background tabs. * @@ -19,8 +20,10 @@ import { getTerminalContent, waitForActiveTerminalManager } from './helpers/term type SchedulerDebugSnapshot = { backgroundEnqueueCount: number + deferredForegroundEnqueueCount: number foregroundWriteCount: number backgroundWriteCount: number + deferredForegroundWriteCount: number flushWriteCount: number scheduledDrainCount: number drainWrites: number[] @@ -276,6 +279,49 @@ test.describe('Terminal output scheduler', () => { .toBe(true) }) + test('visible bulk output uses the high-priority drain instead of synchronous xterm writes', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const activeTabId = await createTerminalTab(orcaPage) + if (!activeTabId) { + throw new Error('Expected a fresh terminal tab') + } + const ptyId = await waitForTabPtyId(orcaPage, activeTabId) + await resetSchedulerDebug(orcaPage) + + const runId = Date.now() + const marker = `VISIBLE_THROUGHPUT_${runId}` + const floodCommand = nodeScriptCommand( + `const marker='VISIBLE' + '_THROUGHPUT_' + '${runId}'; process.stdout.write('VISIBLE_FILL_${runId}\\n' + 'x'.repeat(700000) + '\\n' + marker + '\\n')` + ) + + await sendPtyCommands(orcaPage, [{ ptyId, command: floodCommand }]) + + await expect + .poll(async () => (await getTerminalContent(orcaPage, 12_000)).includes(marker), { + timeout: 30_000, + message: 'Active terminal did not render the visible throughput marker' + }) + .toBe(true) + + const debug = await getSchedulerDebug(orcaPage) + await testInfo.attach('terminal-visible-throughput-proof', { + body: JSON.stringify(debug, null, 2), + contentType: 'application/json' + }) + testInfo.annotations.push({ + type: 'terminal-visible-throughput', + description: `foreground=${debug.foregroundWriteCount} deferredForegroundEnqueue=${debug.deferredForegroundEnqueueCount} deferredForegroundWrite=${debug.deferredForegroundWriteCount} drains=${debug.drainWrites.join(',')}` + }) + expect(debug.deferredForegroundEnqueueCount).toBeGreaterThan(0) + expect(debug.deferredForegroundWriteCount).toBeGreaterThan(0) + }) + test('hidden overflow restores from main-owned terminal state when the tab becomes visible', async ({ orcaPage }) => {