diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-drain.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-drain.ts index d9d0db8744a..4753eb38feb 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-drain.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-drain.ts @@ -7,7 +7,6 @@ import { clearForegroundRelease, isEntryDrainable } from './pane-terminal-foregr import { hasHighPriorityBacklog, hasQueuedChunks } from './pane-terminal-output-queue-backlog' import { BACKGROUND_DRAIN_INTERVAL_MS, - canDrainQueueEntry, DRAIN_TIME_BUDGET_MS, HIGH_PRIORITY_DRAIN_INTERVAL_MS, HIGH_PRIORITY_MAX_WRITES_PER_DRAIN, @@ -25,7 +24,7 @@ import { writeQueuedChunk } from './pane-terminal-output-pipeline' function hasDrainableBacklog(): boolean { for (const entry of queuedByTerminal.values()) { - if (isEntryDrainable(entry) && canDrainQueueEntry(entry)) { + if (isEntryDrainable(entry)) { return true } } @@ -40,9 +39,6 @@ function takeNextDrainableEntry(): QueueEntry | null { if (!isEntryDrainable(entry)) { continue } - if (!canDrainQueueEntry(entry)) { - continue - } // Why: active/foreground output should be chosen first, not left in insertion order behind older background terminals. if (entry.highPriority) { queuedByTerminal.delete(entry.terminal) @@ -60,9 +56,6 @@ function takeNextDrainableEntry(): QueueEntry | null { if (!isEntryDrainable(entry)) { continue } - if (!canDrainQueueEntry(entry)) { - continue - } queuedByTerminal.delete(entry.terminal) return entry } @@ -108,11 +101,6 @@ export function drainQueuedOutputImpl(): void { entry.highPriority = false clearForegroundRelease(entry) } - // Dense SGR batches are parser paced. A completion callback schedules the - // next drain after xterm has finished this batch. - if (entry.denseSgr) { - break - } // Why: xterm parsing and DOM work share the renderer thread with input; keep draining cooperative so WSL/agent output can't pin the UI. if (writes > 0 && getDrainNow() - startedAt >= DRAIN_TIME_BUDGET_MS) { break diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-flusher.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-flusher.ts index 422f5b809db..dbaaea1c173 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-flusher.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-flusher.ts @@ -22,15 +22,12 @@ import { import { discardDetachedQueueEntry, hasQueuedChunks } from './pane-terminal-output-queue-backlog' import { clearForegroundRelease, isEntryDrainable } from './pane-terminal-foreground-queue-state' import { - BACKGROUND_CHUNK_CHARS, - canDrainQueueEntry, discardTerminalOutput, fireQueuedAckCredits, queuedByTerminal, requestRegisteredTerminalBacklogRecovery, resolveQueueEntryChunkLimit, scheduleDrain, - reserveDenseSgrBatch, type TerminalOutputTarget } from './pane-terminal-output-queue-registry' @@ -43,8 +40,6 @@ export function flushTerminalOutputImpl( if (!entry) { return } - // Why: a budget-free flush is an ordering barrier (replay paint, shutdown capture, parse settle) whose caller writes straight to xterm next, so it must submit every queued byte; only budgeted callers tolerate dense pacing. - const explicitFullDrain = options?.maxChars === undefined queuedByTerminal.delete(terminal) if (isTerminalWritePipelineCertifiedDead(terminal)) { discardDetachedQueueEntry(entry) @@ -55,11 +50,6 @@ export function flushTerminalOutputImpl( queuedByTerminal.set(terminal, entry) return } - if (!explicitFullDrain && !canDrainQueueEntry(entry)) { - queuedByTerminal.set(terminal, entry) - scheduleDrain(0) - return - } if (entry.backgroundBacklogDropped && requestRegisteredTerminalBacklogRecovery(terminal)) { fireQueuedAckCredits(entry) entry.chunks.length = 0 @@ -72,17 +62,13 @@ export function flushTerminalOutputImpl( } let flushedChars = 0 - const chunkLimit = resolveQueueEntryChunkLimit(entry) - let queuedWrite = takeQueuedChunk(entry, explicitFullDrain ? BACKGROUND_CHUNK_CHARS : chunkLimit) + let queuedWrite = takeQueuedChunk(entry, resolveQueueEntryChunkLimit(entry)) while (queuedWrite) { flushedChars += queuedWrite.data.length if (debugEnabled) { debugState.flushWriteCount++ } const ackCreditsParsed = registerTerminalOutputAckCredits(terminal, queuedWrite.ackCredits) - // Why not reserved on a full drain: this path ignores the pacing gate, so a reservation only strands the counter and blocks the terminal's next dense entry until xterm parses every batch. - const denseSgrRelease = - !explicitFullDrain && entry.denseSgr ? reserveDenseSgrBatch(terminal) : undefined armTerminalWriteStallWatch(terminal, { onCertifiedDead: () => discardTerminalOutput(terminal) }) @@ -102,27 +88,16 @@ export function flushTerminalOutputImpl( terminal, queuedWrite.onParsed, ackCreditsParsed, - undefined, - denseSgrRelease + undefined ), - onWriteFailure: composeWriteFailureCallback( - terminal, - ackCreditsParsed, - denseSgrRelease - ) + onWriteFailure: composeWriteFailureCallback(terminal, ackCreditsParsed) } ) : writeBackgroundTerminalChunk( terminal, queuedWrite.data, - composeParsedCallback( - terminal, - queuedWrite.onParsed, - ackCreditsParsed, - undefined, - denseSgrRelease - ), - composeWriteFailureCallback(terminal, ackCreditsParsed, denseSgrRelease) + composeParsedCallback(terminal, queuedWrite.onParsed, ackCreditsParsed, undefined), + composeWriteFailureCallback(terminal, ackCreditsParsed) ) if (!writeAccepted) { fireQueuedAckCredits(entry) @@ -136,9 +111,6 @@ export function flushTerminalOutputImpl( if (ackCreditsParsed) { runGuardedWriteCompletionStep('flush-abort-ack-credits', ackCreditsParsed) } - if (denseSgrRelease) { - runGuardedWriteCompletionStep('flush-abort-dense-release', denseSgrRelease) - } fireQueuedAckCredits(entry) clearForegroundRelease(entry) recordQueueDebugPressure() @@ -147,10 +119,7 @@ export function flushTerminalOutputImpl( if (options?.maxChars !== undefined && flushedChars >= options.maxChars) { break } - if (!explicitFullDrain && entry.denseSgr) { - break - } - queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) + queuedWrite = takeQueuedChunk(entry, resolveQueueEntryChunkLimit(entry)) } if (hasQueuedChunks(entry)) { entry.highPriority = true diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-pipeline.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-pipeline.ts index 467c4d9b416..b8d89ebc8df 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-pipeline.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-pipeline.ts @@ -16,7 +16,6 @@ import { discardTerminalOutput, fireQueuedAckCredits, queuedByTerminal, - reserveDenseSgrBatch, resolveQueueEntryChunkLimit, scheduleDrain, type QueueEntry, @@ -73,21 +72,17 @@ export function composeParsedCallback( terminal: TerminalOutputTarget, onParsed: TerminalOutputParsedCallback | undefined, ackCreditsParsed: (() => void) | undefined, - pacer: (() => void) | undefined, - denseSgrRelease: (() => void) | undefined = undefined + pacer: (() => void) | undefined ): TerminalOutputParsedCallback { // Why always non-undefined: the callback doubles as the pipeline-health settle signal — with none, the stall watch could never settle, forcing a probe round-trip per healthy idle pane. return () => { try { onParsed?.() } finally { - // Why guarded per step: one throwing step would skip every later one, and a skipped dense release pins inFlight so the pane never drains again. + // Why guarded per step: one throwing step would skip every later one. if (ackCreditsParsed) { runGuardedWriteCompletionStep('parsed-ack-credits', ackCreditsParsed) } - if (denseSgrRelease) { - runGuardedWriteCompletionStep('parsed-dense-release', denseSgrRelease) - } if (pacer) { runGuardedWriteCompletionStep('parsed-pacer', pacer) } @@ -100,8 +95,7 @@ export function composeParsedCallback( export function composeWriteFailureCallback( terminal: TerminalOutputTarget, - ackCreditsParsed: (() => void) | undefined, - denseSgrRelease: (() => void) | undefined = undefined + ackCreditsParsed: (() => void) | undefined ): () => void { return () => { try { @@ -109,9 +103,6 @@ export function composeWriteFailureCallback( if (ackCreditsParsed) { runGuardedWriteCompletionStep('write-failure-ack-credits', ackCreditsParsed) } - if (denseSgrRelease) { - runGuardedWriteCompletionStep('write-failure-dense-release', denseSgrRelease) - } } finally { // Why: a synchronous rejection proves undeliverability but nothing about parse progress; recover without extending replay guards. failTerminalWriteStallWatch(terminal) @@ -131,7 +122,6 @@ export function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' return null } const pacer = entry.highPriority ? makeParseClockPacer() : undefined - const denseSgrRelease = entry.denseSgr ? reserveDenseSgrBatch(entry.terminal) : undefined const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits) // Why armed BEFORE the write: a wedged WriteBuffer (issue #2836) or disposed xterm (6.1.0-beta.287) never runs the parsed callback, so the watch must be live first to catch it. armTerminalWriteStallWatch(entry.terminal, { @@ -153,27 +143,16 @@ export function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' entry.terminal, queuedWrite.onParsed, ackCreditsParsed, - pacer, - denseSgrRelease + pacer ), - onWriteFailure: composeWriteFailureCallback( - entry.terminal, - ackCreditsParsed, - denseSgrRelease - ) + onWriteFailure: composeWriteFailureCallback(entry.terminal, ackCreditsParsed) } ) : writeBackgroundTerminalChunk( entry.terminal, queuedWrite.data, - composeParsedCallback( - entry.terminal, - queuedWrite.onParsed, - ackCreditsParsed, - pacer, - denseSgrRelease - ), - composeWriteFailureCallback(entry.terminal, ackCreditsParsed, denseSgrRelease) + composeParsedCallback(entry.terminal, queuedWrite.onParsed, ackCreditsParsed, pacer), + composeWriteFailureCallback(entry.terminal, ackCreditsParsed) ) if (!writeAccepted) { // Why: the failure callback credited the submitted chunk; credit and abandon the detached tail so the drain can't retry a certified-dead xterm. @@ -191,9 +170,6 @@ export function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' if (ackCreditsParsed) { runGuardedWriteCompletionStep('drain-abort-ack-credits', ackCreditsParsed) } - if (denseSgrRelease) { - runGuardedWriteCompletionStep('drain-abort-dense-release', denseSgrRelease) - } fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-backlog.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-backlog.ts index 1cf831f979c..4bc1bcfb970 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-backlog.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-backlog.ts @@ -17,7 +17,6 @@ import { MAX_BACKGROUND_QUEUE_CHUNKS, fireQueuedAckCredits, getTerminalOutputMaxQueueChars, - canDrainQueueEntry, queuedByTerminal, type QueueEntry, type TerminalOutputBeforeWrite @@ -96,7 +95,6 @@ export function hasHighPriorityBacklog(): boolean { for (const entry of queuedByTerminal.values()) { if ( isEntryDrainable(entry) && - canDrainQueueEntry(entry) && (entry.highPriority || entry.queuedChars > LARGE_BACKLOG_CHARS) ) { return true diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-registry.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-registry.ts index 796f556c95b..2e5a066e070 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-registry.ts @@ -134,51 +134,6 @@ let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestE let drainChannel: MessageChannel | null = null // Why indirect: the drain loop lives downstream of this module, so it registers itself here rather than being imported back into the queue state it operates on. let runDrain: (() => void) | null = null -type DenseSgrPacingState = { - generation: number - inFlight: number -} - -const denseSgrPacingByTerminal = new WeakMap() - -export function canDrainQueueEntry(entry: QueueEntry): boolean { - return !entry.denseSgr || (denseSgrPacingByTerminal.get(entry.terminal)?.inFlight ?? 0) === 0 -} - -export function reserveDenseSgrBatch(terminal: TerminalOutputTarget): () => void { - const state = denseSgrPacingByTerminal.get(terminal) ?? { generation: 0, inFlight: 0 } - denseSgrPacingByTerminal.set(terminal, state) - state.inFlight += 1 - const generation = state.generation - let released = false - return () => { - if (released) { - return - } - released = true - const current = denseSgrPacingByTerminal.get(terminal) - // A disposed xterm can invoke an old callback after its queue is cleared - // and the same object is reused. Do not let that callback release a new - // generation's reservation. - if (!current || current.generation !== generation) { - return - } - current.inFlight = Math.max(0, current.inFlight - 1) - scheduleDrain(0) - } -} - -export function clearDenseSgrPacing(terminal: TerminalOutputTarget): void { - const state = denseSgrPacingByTerminal.get(terminal) - if (state) { - state.generation += 1 - state.inFlight = 0 - return - } - // Retain a tombstone generation so a callback reserved before the first - // clear cannot match a reservation made after it. - denseSgrPacingByTerminal.set(terminal, { generation: 1, inFlight: 0 }) -} // Why re-classified per batch rather than latched at enqueue: density changes mid-stream — a plain banner ahead of a TUI would miss the pacing entirely, and a dense header ahead of a long plain tail would pin that tail at the dense budget, which is slower than no pacing at all. export function resolveQueueEntryChunkLimit(entry: QueueEntry): number { @@ -316,7 +271,6 @@ export function discardTerminalOutput(terminal: TerminalOutputTarget): void { } discardInFlightTerminalOutputAckCredits(terminal) queuedByTerminal.delete(terminal) - clearDenseSgrPacing(terminal) discardForegroundRenderSettle(terminal) // Why: cancel the watch without masquerading as parse progress; replay guards use real completions to tell slow from wedged. cancelTerminalWriteStallWatch(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 71a0aa87006..439e1c2cb36 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 @@ -335,16 +335,10 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledTimes(6) }) - it('paces dense SGR output to one parser batch and preserves bytes', async () => { + it('splits dense SGR output into 4 KiB parser batches and preserves bytes', async () => { vi.useFakeTimers() - const { writeTerminalOutput } = await loadScheduler() + const { queuedByTerminal, writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) const input = Array.from( { length: 1_200 }, (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` @@ -352,32 +346,22 @@ describe('pane terminal output scheduler', () => { writeTerminalOutput(terminal, input, { foreground: false }) vi.advanceTimersByTime(50) - - expect(terminal.write).toHaveBeenCalledTimes(1) - expect(parsed).toHaveLength(1) - const written: string[] = [terminal.write.mock.calls[0]?.[0] ?? ''] - while (parsed.length > 0) { - parsed.shift()?.() - vi.advanceTimersByTime(0) - const next = terminal.write.mock.calls[written.length]?.[0] - if (next !== undefined) { - written.push(next) - } + while (queuedByTerminal.has(terminal)) { + vi.advanceTimersByTime(16) } + const written = terminal.write.mock.calls.map(([data]) => data) + expect(written.length).toBeGreaterThan(1) + for (const data of written) { + expect(data.length).toBeLessThanOrEqual(4 * 1024) + } expect(written.join('')).toBe(input) }) it('classifies dense SGR split across sub-batch deliveries', async () => { vi.useFakeTimers() - const { writeTerminalOutput } = await loadScheduler() + const { queuedByTerminal, writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) const input = Array.from( { length: 1_200 }, (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` @@ -390,179 +374,40 @@ describe('pane terminal output scheduler', () => { } vi.advanceTimersByTime(50) - expect(terminal.write).toHaveBeenCalledTimes(1) expect(terminal.write.mock.calls[0]?.[0]).toHaveLength(4 * 1024) - while (parsed.length > 0) { - parsed.shift()?.() - vi.advanceTimersByTime(0) + while (queuedByTerminal.has(terminal)) { + vi.advanceTimersByTime(16) } expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe(input) expect(terminal.write.mock.calls.length).toBeGreaterThan(1) }) - it('ignores a stale dense parse release after the terminal queue is discarded', async () => { - vi.useFakeTimers() - const { discardTerminalOutput, writeTerminalOutput } = await loadScheduler() - const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) - const dense = Array.from( - { length: 1_200 }, - (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` - ).join('') - - writeTerminalOutput(terminal, dense, { foreground: false }) - vi.advanceTimersByTime(50) - expect(terminal.write).toHaveBeenCalledTimes(1) - const staleRelease = parsed.shift() - expect(staleRelease).toBeDefined() - - discardTerminalOutput(terminal) - writeTerminalOutput(terminal, dense, { foreground: false }) - vi.advanceTimersByTime(50) - expect(terminal.write).toHaveBeenCalledTimes(2) - - staleRelease?.() - vi.advanceTimersByTime(0) - // The old callback must not release the replacement generation's batch. - expect(terminal.write).toHaveBeenCalledTimes(2) - - parsed.shift()?.() - vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(3) - }) - - it('keeps foreground bytes behind a dense batch retained by an explicit flush', async () => { + it('keeps foreground bytes behind dense output still queued after a drain', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) const dense = Array.from( - { length: 600 }, + { length: 1_300 }, (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` ).join('') - const input = dense.slice(0, 4 * 1024 + 256) - writeTerminalOutput(terminal, input, { foreground: false }) + writeTerminalOutput(terminal, dense, { foreground: false }) vi.advanceTimersByTime(50) - expect(terminal.write).toHaveBeenCalledTimes(1) + // One background tick submits MAX_WRITES_PER_DRAIN batches; the rest stays queued. + expect(terminal.write).toHaveBeenCalledTimes(2) writeTerminalOutput(terminal, 'echo', { foreground: true }) - // The foreground write's budget-free flush submits the retained dense tail first. - expect(terminal.write.mock.calls.map(([data]) => data)).toEqual([ - input.slice(0, 4 * 1024), - input.slice(4 * 1024), - 'echo' - ]) + // The foreground write's flush submits the queued dense tail first. + const written = terminal.write.mock.calls.map(([data]) => data) + expect(written.at(-1)).toBe('echo') + expect(written.join('')).toBe(`${dense}echo`) }) - it('drains a dense entry completely when the flush carries no char budget', async () => { + it('guards a throwing parsed ack credit so the terminal keeps draining', async () => { vi.useFakeTimers() - const { flushTerminalOutput, queuedByTerminal, writeTerminalOutput } = await loadScheduler() + const { queuedByTerminal, writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) - const dense = Array.from( - { length: 1_300 }, - (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` - ).join('') - - writeTerminalOutput(terminal, dense, { foreground: false }) - vi.advanceTimersByTime(50) - expect(terminal.write).toHaveBeenCalledTimes(1) - - // The replay/shutdown-capture callers write straight to xterm next, so the - // flush must leave nothing queued behind the batch still in flight. - flushTerminalOutput(terminal) - - expect(queuedByTerminal.has(terminal)).toBe(false) - expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe(dense) - }) - - it('does not reserve pacing slots for the batches a budget-free flush drains', async () => { - vi.useFakeTimers() - const { flushTerminalOutput, writeTerminalOutput } = await loadScheduler() - const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) - const denseChunk = (count: number): string => - Array.from({ length: count }, (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m`).join('') - - writeTerminalOutput(terminal, denseChunk(1_300), { foreground: false }) - vi.advanceTimersByTime(50) - flushTerminalOutput(terminal) - - // Release the paced batch the drain submitted; only the flush's own writes - // stay pending, modelling xterm not having parsed them yet. - parsed.shift()?.() - const pendingFromFlush = parsed.length - const writesFromFlush = terminal.write.mock.calls.length - writeTerminalOutput(terminal, denseChunk(1_300), { foreground: false }) - for (let tick = 0; tick < 4; tick += 1) { - vi.advanceTimersByTime(50) - while (parsed.length > pendingFromFlush) { - parsed.pop()?.() - } - } - - // Slots stranded by the flush would gate every batch after the first, which - // the front of the queue clears before it is classified dense. - expect(terminal.write.mock.calls.length - writesFromFlush).toBeGreaterThan(1) - }) - - it('keeps pacing a dense entry when the flush carries a char budget', async () => { - vi.useFakeTimers() - const { flushTerminalOutput, queuedByTerminal, writeTerminalOutput } = await loadScheduler() - const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) - const dense = Array.from( - { length: 1_300 }, - (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` - ).join('') - - writeTerminalOutput(terminal, dense, { foreground: false }) - vi.advanceTimersByTime(50) - expect(terminal.write).toHaveBeenCalledTimes(1) - - flushTerminalOutput(terminal, { maxChars: 64 * 1024 }) - - expect(terminal.write).toHaveBeenCalledTimes(1) - expect(queuedByTerminal.has(terminal)).toBe(true) - }) - - it('releases the dense pacing slot when a parsed ack credit throws', async () => { - vi.useFakeTimers() - const { writeTerminalOutput } = await loadScheduler() - const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) const dense = Array.from( { length: 300 }, (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` @@ -576,25 +421,21 @@ describe('pane terminal output scheduler', () => { }) writeTerminalOutput(terminal, dense, { foreground: false }) vi.advanceTimersByTime(50) - expect(terminal.write).toHaveBeenCalledTimes(1) + while (queuedByTerminal.has(terminal)) { + vi.advanceTimersByTime(16) + } - parsed.shift()?.() - vi.advanceTimersByTime(0) - - // A throwing credit must not skip the dense release, or inFlight stays - // pinned and the terminal never drains again. - expect(terminal.write).toHaveBeenCalledTimes(2) + expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe(`${dense}${dense}`) expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( 'terminal_write_completion_error', expect.objectContaining({ context: 'parsed-ack-credits' }) ) }) - it('starts pacing once a plain banner gives way to dense output', async () => { + it('shrinks the batch once a plain banner gives way to dense output', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() - terminal.write.mockImplementation(() => {}) const banner = 'banner\r\n'.repeat(640) const dense = Array.from( { length: 2_000 }, @@ -607,21 +448,15 @@ describe('pane terminal output scheduler', () => { const written = terminal.write.mock.calls.map(([data]) => data) expect(written[0]).toHaveLength(16 * 1024) - // Once the dense body reaches the front the budget must drop, or this is - // the 128 KiB parser burst the pacing exists to bound. + // Once the dense body reaches the front the batch must shrink, or this is + // the 128 KiB parser burst the split exists to bound. expect(written[1]).toHaveLength(4 * 1024) }) - it('stops pacing once a dense header gives way to a plain tail', async () => { + it('restores the full batch once a dense header gives way to a plain tail', async () => { vi.useFakeTimers() - const { writeTerminalOutput } = await loadScheduler() + const { queuedByTerminal, writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() - const parsed: (() => void)[] = [] - terminal.write.mockImplementation((_data: string, callback?: () => void) => { - if (callback) { - parsed.push(callback) - } - }) const dense = Array.from( { length: 500 }, (_, index) => `\x1b[${30 + (index % 8)}mX\x1b[0m` @@ -631,21 +466,16 @@ describe('pane terminal output scheduler', () => { writeTerminalOutput(terminal, dense, { foreground: false }) writeTerminalOutput(terminal, tail, { foreground: false }) vi.advanceTimersByTime(50) - expect(terminal.write.mock.calls[0]?.[0]).toHaveLength(4 * 1024) - expect(terminal.write).toHaveBeenCalledTimes(1) - - parsed.shift()?.() - vi.advanceTimersByTime(0) - - // A latched verdict would pin the plain tail at 4 KiB per parse - // round-trip, which is slower than not pacing at all. - expect(terminal.write.mock.calls[1]?.[0]).toHaveLength(16 * 1024) - - while (parsed.length > 0 || terminal.write.mock.calls.length < 2) { - parsed.shift()?.() + while (queuedByTerminal.has(terminal)) { vi.advanceTimersByTime(16) } - expect(terminal.write.mock.calls.map(([data]) => data).join('')).toBe(`${dense}${tail}`) + + const written = terminal.write.mock.calls.map(([data]) => data) + expect(written[0]).toHaveLength(4 * 1024) + // A latched verdict would pin the plain tail at 4 KiB per batch, which is + // slower than not splitting at all. + expect(written[1]).toHaveLength(16 * 1024) + expect(written.join('')).toBe(`${dense}${tail}`) }) it('writes a latency-sensitive foreground redraw whole, even when it is dense', async () => {