perf: batch visible terminal output floods

This commit is contained in:
Neil 2026-05-31 02:13:25 -07:00 committed by GitHub
parent 35e1c221a9
commit 2c8bbacca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 381 additions and 26 deletions

View File

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

View File

@ -83,6 +83,7 @@ const ptySizes = new Map<string, { cols: number; rows: number }>()
// 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<string, number>()
const interactiveOutputCharsByPty = new Map<string, number>()
// 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<string>()
@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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