perf: batch visible terminal output floods
This commit is contained in:
parent
35e1c221a9
commit
2c8bbacca1
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue