diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-queue-state.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-queue-state.ts index f9a808f33ac..6d9d7524e27 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-queue-state.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-queue-state.ts @@ -4,7 +4,7 @@ import { type QueueEntry, type TerminalOutputTarget, type WriteTerminalOutputOptions -} from './pane-terminal-output-scheduler' +} from './pane-terminal-output-queue-registry' const DEFAULT_FOREGROUND_COALESCE_DELAY_MS = 1000 export const FOREGROUND_HOLD_SAFETY_DELAY_MS = 250 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 ee4ad02a4ef..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 @@ -1,122 +1,26 @@ -import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { recordTerminalOutputQueueDebugPressure as recordQueueDebugPressure, terminalOutputSchedulerDebugEnabled as debugEnabled, terminalOutputSchedulerDebugState as debugState } from './pane-terminal-output-scheduler-debug' +import { clearForegroundRelease, isEntryDrainable } from './pane-terminal-foreground-queue-state' +import { hasHighPriorityBacklog, hasQueuedChunks } from './pane-terminal-output-queue-backlog' import { - clearForegroundHoldSafety, - clearForegroundRelease, - isEntryDrainable -} from './pane-terminal-foreground-queue-state' -import { - ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, - BACKGROUND_BACKLOG_WARNING, BACKGROUND_DRAIN_INTERVAL_MS, DRAIN_TIME_BUDGET_MS, - FOREGROUND_BACKLOG_WARNING, HIGH_PRIORITY_DRAIN_INTERVAL_MS, HIGH_PRIORITY_MAX_WRITES_PER_DRAIN, LARGE_BACKLOG_CHARS, - MAX_BACKGROUND_QUEUE_CHUNKS, MAX_WRITES_PER_DRAIN, - getTerminalOutputMaxQueueChars, isMessageChannelDrainEnabled, markTerminalOutputDrainStarted, queuedByTerminal, scheduleDrain, - type QueueEntry, - type TerminalOutputBeforeWrite -} from './pane-terminal-output-scheduler' + setTerminalOutputDrainRunner, + type QueueEntry +} from './pane-terminal-output-queue-registry' import { writeQueuedChunk } from './pane-terminal-output-pipeline' -// Why: every discard path MUST fire these before clearing/replacing the queue — a dropped chunk still counts as consumed, or main's in-flight window shrinks permanently and the PTY wedges. -export function fireQueuedAckCredits(entry: QueueEntry): void { - for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) { - entry.chunks[index].ackCredit?.() - } -} - -export function discardDetachedQueueEntry(entry: QueueEntry): void { - fireQueuedAckCredits(entry) - entry.chunks.length = 0 - entry.chunkIndex = 0 - entry.queuedChars = 0 - entry.highPriority = false - clearForegroundRelease(entry) -} - -export function queueCapExceeded(entry: QueueEntry): boolean { - return ( - entry.queuedChars > getTerminalOutputMaxQueueChars() || - entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS - ) -} - -export function replaceBacklogWithWarning( - entry: QueueEntry, - warning: string = BACKGROUND_BACKLOG_WARNING -): void { - const shouldNotify = !entry.backgroundBacklogDropped - if (shouldNotify) { - // Why: field visibility for cap tuning — drop frequency and size decide whether the cap is too small (issue #2836 / #7017). - recordRendererCrashBreadcrumb('terminal_output_backlog_dropped', { - foreground: warning === FOREGROUND_BACKLOG_WARNING, - droppedChars: entry.queuedChars, - capChars: getTerminalOutputMaxQueueChars() - }) - } - let beforeWrite: TerminalOutputBeforeWrite | undefined - for (let index = entry.chunks.length - 1; index >= entry.chunkIndex; index--) { - if (entry.chunks[index]?.beforeWrite) { - beforeWrite = entry.chunks[index].beforeWrite - break - } - } - clearForegroundHoldSafety(entry) - fireQueuedAckCredits(entry) - entry.chunks = [ - { - data: warning, - retainedChars: warning.length, - foreground: false, - forceForegroundRefresh: false, - followupForegroundRefresh: false, - shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, - stripTransientCursorShows: false, - beforeWrite - } - ] - entry.chunkIndex = 0 - entry.queuedChars = warning.length - entry.backgroundBacklogDropped = true - entry.highPriority = true - entry.foregroundHold = false - if (debugEnabled && shouldNotify) { - debugState.droppedBacklogCount++ - } - clearForegroundRelease(entry) - recordQueueDebugPressure() - if (shouldNotify) { - entry.onBackgroundBacklogDropped?.() - } -} - -export function hasQueuedChunks(entry: QueueEntry): boolean { - return entry.chunkIndex < entry.chunks.length -} - -export function hasHighPriorityBacklog(): boolean { - for (const entry of queuedByTerminal.values()) { - if ( - isEntryDrainable(entry) && - (entry.highPriority || entry.queuedChars > LARGE_BACKLOG_CHARS) - ) { - return true - } - } - return false -} function hasDrainableBacklog(): boolean { for (const entry of queuedByTerminal.values()) { @@ -219,3 +123,6 @@ export function drainQueuedOutputImpl(): void { ) } } + +// Why at module scope: the registry's scheduleDrain must never fire before a runner exists, so registration happens as this module is evaluated rather than on first use. +setTerminalOutputDrainRunner(drainQueuedOutputImpl) 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 230dd5cf347..cf6727bb7ff 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 @@ -18,20 +18,17 @@ import { composeWriteFailureCallback, writeBackgroundTerminalChunk } from './pane-terminal-output-pipeline' -import { - discardDetachedQueueEntry, - fireQueuedAckCredits, - hasQueuedChunks -} from './pane-terminal-output-drain' +import { discardDetachedQueueEntry, hasQueuedChunks } from './pane-terminal-output-queue-backlog' import { clearForegroundRelease, isEntryDrainable } from './pane-terminal-foreground-queue-state' import { BACKGROUND_CHUNK_CHARS, discardTerminalOutput, + fireQueuedAckCredits, queuedByTerminal, requestRegisteredTerminalBacklogRecovery, scheduleDrain, type TerminalOutputTarget -} from './pane-terminal-output-scheduler' +} from './pane-terminal-output-queue-registry' export function flushTerminalOutputImpl( terminal: TerminalOutputTarget, 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 b2f276d01ae..06ba58568d2 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 @@ -15,17 +15,17 @@ import { clearForegroundRelease } from './pane-terminal-foreground-queue-state' import { BACKGROUND_CHUNK_CHARS, discardTerminalOutput, + fireQueuedAckCredits, queuedByTerminal, scheduleDrain, type QueueEntry, type TerminalOutputParsedCallback, type TerminalOutputTarget -} from './pane-terminal-output-scheduler' +} from './pane-terminal-output-queue-registry' import { discardDetachedQueueEntry, - fireQueuedAckCredits, hasHighPriorityBacklog -} from './pane-terminal-output-drain' +} from './pane-terminal-output-queue-backlog' // Why no per-write scroll enforcement: xterm's BufferService.isUserScrolling owns live follow/pin; app-side enforcement is limited to structural ops xterm can't identify, like replay. export function writeBackgroundTerminalChunk( 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 new file mode 100644 index 00000000000..4bc1bcfb970 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-backlog.ts @@ -0,0 +1,104 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + recordTerminalOutputQueueDebugPressure as recordQueueDebugPressure, + terminalOutputSchedulerDebugEnabled as debugEnabled, + terminalOutputSchedulerDebugState as debugState +} from './pane-terminal-output-scheduler-debug' +import { + clearForegroundHoldSafety, + clearForegroundRelease, + isEntryDrainable +} from './pane-terminal-foreground-queue-state' +import { + ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + BACKGROUND_BACKLOG_WARNING, + FOREGROUND_BACKLOG_WARNING, + LARGE_BACKLOG_CHARS, + MAX_BACKGROUND_QUEUE_CHUNKS, + fireQueuedAckCredits, + getTerminalOutputMaxQueueChars, + queuedByTerminal, + type QueueEntry, + type TerminalOutputBeforeWrite +} from './pane-terminal-output-queue-registry' + +export function discardDetachedQueueEntry(entry: QueueEntry): void { + fireQueuedAckCredits(entry) + entry.chunks.length = 0 + entry.chunkIndex = 0 + entry.queuedChars = 0 + entry.highPriority = false + clearForegroundRelease(entry) +} + +export function queueCapExceeded(entry: QueueEntry): boolean { + return ( + entry.queuedChars > getTerminalOutputMaxQueueChars() || + entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS + ) +} + +export function replaceBacklogWithWarning( + entry: QueueEntry, + warning: string = BACKGROUND_BACKLOG_WARNING +): void { + const shouldNotify = !entry.backgroundBacklogDropped + if (shouldNotify) { + // Why: field visibility for cap tuning — drop frequency and size decide whether the cap is too small (issue #2836 / #7017). + recordRendererCrashBreadcrumb('terminal_output_backlog_dropped', { + foreground: warning === FOREGROUND_BACKLOG_WARNING, + droppedChars: entry.queuedChars, + capChars: getTerminalOutputMaxQueueChars() + }) + } + let beforeWrite: TerminalOutputBeforeWrite | undefined + for (let index = entry.chunks.length - 1; index >= entry.chunkIndex; index--) { + if (entry.chunks[index]?.beforeWrite) { + beforeWrite = entry.chunks[index].beforeWrite + break + } + } + clearForegroundHoldSafety(entry) + fireQueuedAckCredits(entry) + entry.chunks = [ + { + data: warning, + retainedChars: warning.length, + foreground: false, + forceForegroundRefresh: false, + followupForegroundRefresh: false, + shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + stripTransientCursorShows: false, + beforeWrite + } + ] + entry.chunkIndex = 0 + entry.queuedChars = warning.length + entry.backgroundBacklogDropped = true + entry.highPriority = true + entry.foregroundHold = false + if (debugEnabled && shouldNotify) { + debugState.droppedBacklogCount++ + } + clearForegroundRelease(entry) + recordQueueDebugPressure() + if (shouldNotify) { + entry.onBackgroundBacklogDropped?.() + } +} + +export function hasQueuedChunks(entry: QueueEntry): boolean { + return entry.chunkIndex < entry.chunks.length +} + +export function hasHighPriorityBacklog(): boolean { + for (const entry of queuedByTerminal.values()) { + if ( + isEntryDrainable(entry) && + (entry.highPriority || entry.queuedChars > LARGE_BACKLOG_CHARS) + ) { + return true + } + } + return false +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.ts index 0142c3528be..bdf7251df84 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-chunks.ts @@ -4,7 +4,7 @@ import type { QueuedWrite, TerminalOutputBeforeWrite, TerminalOutputParsedCallback -} from './pane-terminal-output-scheduler' +} from './pane-terminal-output-queue-registry' import { recordTerminalOutputQueueDebugPressure as recordQueueDebugPressure } from './pane-terminal-output-scheduler-debug' type ForegroundRefreshSyncResolver = () => boolean 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 new file mode 100644 index 00000000000..a7e87f380de --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-queue-registry.ts @@ -0,0 +1,246 @@ +import { + discardForegroundRenderSettle, + type ForegroundTerminalOutputTarget +} from './pane-terminal-foreground-render-settle' +import { discardInFlightTerminalOutputAckCredits } from './pane-terminal-output-ack-credit' +import { cancelTerminalWriteStallWatch } from './terminal-write-pipeline-health' +import { + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + terminalOutputBacklogCapChars +} from '../../../../shared/terminal-scrollback-policy' +import { + exposeTerminalOutputSchedulerDebugApi as exposeDebugApi, + recordTerminalOutputQueueDebugPressure as recordQueueDebugPressure, + setTerminalOutputDebugQueueReader, + terminalOutputSchedulerDebugEnabled as debugEnabled, + terminalOutputSchedulerDebugState as debugState +} from './pane-terminal-output-scheduler-debug' + +export type TerminalOutputTarget = ForegroundTerminalOutputTarget + +export type TerminalOutputBeforeWrite = (data: string) => void +type TerminalBacklogRecoveryRequest = () => boolean +export type TerminalOutputParsedCallback = () => void +type ForegroundRefreshSyncResolver = () => boolean + +export type WriteTerminalOutputOptions = { + foreground: boolean + beforeWrite?: TerminalOutputBeforeWrite + onParsed?: TerminalOutputParsedCallback + /** Parse-deferred delivery ACK (terminal-pty-ack-gate). MUST be invoked when the chunk is parsed OR discarded by any drop path; fire-once, so double invocation is safe but omission permanently shrinks main's in-flight window. */ + ackCredit?: () => void + onBackgroundBacklogDropped?: () => void + latencySensitive?: boolean + forceForegroundRefresh?: boolean + followupForegroundRefresh?: boolean + shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver + stripTransientCursorShows?: boolean + coalesceForeground?: boolean + holdForeground?: boolean +} + +type QueueChunk = { + data: string + // Tracks the backing data still reachable through this queue slot. + retainedChars: number + foreground: boolean + forceForegroundRefresh: boolean + followupForegroundRefresh: boolean + shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver + stripTransientCursorShows: boolean + beforeWrite?: TerminalOutputBeforeWrite + onParsed?: TerminalOutputParsedCallback + ackCredit?: () => void +} + +export type QueuedWrite = { + data: string + foreground: boolean + forceForegroundRefresh: boolean + followupForegroundRefresh: boolean + shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver + stripTransientCursorShows: boolean + beforeWrite?: TerminalOutputBeforeWrite + onParsed?: TerminalOutputParsedCallback + ackCredits: (() => void)[] +} + +export type QueueEntry = { + terminal: TerminalOutputTarget + chunks: QueueChunk[] + chunkIndex: number + queuedChars: number + onBackgroundBacklogDropped?: () => void + backgroundBacklogDropped: boolean + highPriority: boolean + foregroundHold: boolean + foregroundHoldSafetyDelayMs: number + foregroundCoalesce: boolean + foregroundCoalesceDelayMs: number + foregroundHoldSafetyTimer: ReturnType | null + foregroundCoalesceTimer: ReturnType | null + // Why: hold and coalesce cancel each other's fallback timer, so an alternating DEC 2026 stream could re-arm both forever and freeze a visible pane (#8754). This caps one non-drainable episode. + foregroundReleaseDeadlineAt: number | null + // Why: an open frame's own hold chunks may still push the deadline out, but once coalesce has taken the entry over the deadline stops moving so the two mechanisms can't re-arm each other. + foregroundReleaseDeadlineFixed: boolean +} + +export const BACKGROUND_FLUSH_DELAY_MS = 50 +export const BACKGROUND_DRAIN_INTERVAL_MS = 16 +export const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 +export const BACKGROUND_CHUNK_CHARS = 16 * 1024 +export const MAX_WRITES_PER_DRAIN = 2 +// Why 8: per-tick volume (8 x 16KB = 128KB ≈ 1.3ms parse) sets the sustained ceiling (~30MB/s) within DRAIN_TIME_BUDGET_MS; at 2 it was only 8MB/s against a ~100MB/s parser (see throughput bench). +export const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8 +export const DRAIN_TIME_BUDGET_MS = 8 +export const LARGE_BACKLOG_CHARS = 512 * 1024 +// Why mutable: the cap scales with the user's scrollback setting (terminalOutputBacklogCapChars), configured when settings apply; the chunk-count cap stays fixed. +let maxQueueChars = TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS +export const MAX_BACKGROUND_QUEUE_CHUNKS = 4096 + +export function configureTerminalOutputBacklogCap(scrollbackRows: unknown): void { + maxQueueChars = terminalOutputBacklogCapChars(scrollbackRows) +} + +export function getTerminalOutputMaxQueueChars(): number { + return maxQueueChars +} +// Why: leading CAN aborts any partial escape sequence before the style reset so the backlog warning renders cleanly. +export const BACKGROUND_BACKLOG_WARNING = + '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog grew too large.]\r\n' +// Why a separate foreground message: a visible pane hitting the cap means the drain couldn't keep up with a flood (starved renderer), not merely output produced while hidden. +export const FOREGROUND_BACKLOG_WARNING = + '\x18\x1b[0m\r\n[Orca skipped a burst of terminal output because the backlog grew too large.]\r\n' +export const ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY = (): boolean => true + +export const queuedByTerminal = new Map() +setTerminalOutputDebugQueueReader(() => queuedByTerminal.values()) +const backlogRecoveryByTerminal = new WeakMap< + TerminalOutputTarget, + TerminalBacklogRecoveryRequest +>() +let drainTimer: ReturnType | null = null +let drainTimerDelayMs: number | null = null +// Why a MessageChannel for zero-delay drains: Chromium clamps nested setTimeout(0) to ~4ms; a posted macrotask isn't clamped yet still yields to input/paint. Cancellation is by generation. +let drainImmediatePending = false +let drainImmediateGeneration = 0 +let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestEnv() +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 + +export function setTerminalOutputDrainRunner(runner: () => void): void { + runDrain = runner +} + +function runDrainTick(): void { + runDrain?.() +} + +function isVitestEnv(): boolean { + // Why: vitest fake timers can't advance MessageChannel macrotasks; the timer path keeps the suites' virtual clock authoritative. + return typeof process !== 'undefined' && process.env?.VITEST === 'true' +} + +function getDrainChannel(): MessageChannel { + if (drainChannel === null) { + drainChannel = new MessageChannel() + drainChannel.port1.onmessage = (event: MessageEvent) => { + if (event.data !== drainImmediateGeneration || !drainImmediatePending) { + return + } + drainImmediatePending = false + runDrainTick() + } + } + return drainChannel +} + +function cancelImmediateDrain(): void { + drainImmediateGeneration++ + drainImmediatePending = false +} + +export function setUseMessageChannelDrainForTesting(value: boolean | null): void { + cancelImmediateDrain() + useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv()) +} + +export function isMessageChannelDrainEnabled(): boolean { + return useMessageChannelDrain +} + +export function markTerminalOutputDrainStarted(): void { + drainTimer = null + drainTimerDelayMs = null +} + +export function scheduleDrain(delayMs: number): void { + if (drainImmediatePending) { + // An immediate drain is already armed — nothing can beat zero delay. + return + } + if (drainTimer !== null) { + if (drainTimerDelayMs !== null && drainTimerDelayMs <= delayMs) { + return + } + clearTimeout(drainTimer) + drainTimer = null + drainTimerDelayMs = null + } + if (queuedByTerminal.size === 0) { + return + } + if (debugEnabled) { + debugState.scheduledDrainCount++ + } + if (delayMs === 0 && useMessageChannelDrain) { + drainImmediatePending = true + getDrainChannel().port2.postMessage(drainImmediateGeneration) + return + } + drainTimer = setTimeout(runDrainTick, delayMs) + drainTimerDelayMs = delayMs +} + +// Why: every discard path MUST fire these before clearing/replacing the queue — a dropped chunk still counts as consumed, or main's in-flight window shrinks permanently and the PTY wedges. +export function fireQueuedAckCredits(entry: QueueEntry): void { + for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) { + entry.chunks[index].ackCredit?.() + } +} + +export function requestRegisteredTerminalBacklogRecovery(terminal: TerminalOutputTarget): boolean { + const requestRecovery = backlogRecoveryByTerminal.get(terminal) + if (!requestRecovery) { + return false + } + return requestRecovery() +} + +export function registerTerminalBacklogRecovery( + terminal: TerminalOutputTarget, + requestRecovery: TerminalBacklogRecoveryRequest +): () => void { + backlogRecoveryByTerminal.set(terminal, requestRecovery) + return () => { + if (backlogRecoveryByTerminal.get(terminal) === requestRecovery) { + backlogRecoveryByTerminal.delete(terminal) + } + } +} + +export function discardTerminalOutput(terminal: TerminalOutputTarget): void { + exposeDebugApi() + const entry = queuedByTerminal.get(terminal) + if (entry) { + // Why: discarded chunks still consumed their deliveries — credit them or main's in-flight window leaks (fireQueuedAckCredits). + fireQueuedAckCredits(entry) + } + discardInFlightTerminalOutputAckCredits(terminal) + queuedByTerminal.delete(terminal) + discardForegroundRenderSettle(terminal) + // Why: cancel the watch without masquerading as parse progress; replay guards use real completions to tell slow from wedged. + cancelTerminalWriteStallWatch(terminal) + recordQueueDebugPressure() +} 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 242f5fe6645..a181de7416e 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 @@ -1,207 +1,52 @@ import { - discardForegroundRenderSettle, - type ForegroundTerminalOutputTarget -} from './pane-terminal-foreground-render-settle' -import { discardInFlightTerminalOutputAckCredits } from './pane-terminal-output-ack-credit' -import { - cancelTerminalWriteStallWatch, failTerminalWriteStallWatch, isTerminalWritePipelineCertifiedDead, recordTerminalParseProgress } from './terminal-write-pipeline-health' -import { - TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, - terminalOutputBacklogCapChars -} from '../../../../shared/terminal-scrollback-policy' -import { - exposeTerminalOutputSchedulerDebugApi as exposeDebugApi, - recordTerminalOutputQueueDebugPressure as recordQueueDebugPressure, - setTerminalOutputDebugQueueReader, - terminalOutputSchedulerDebugEnabled as debugEnabled, - terminalOutputSchedulerDebugState as debugState -} from './pane-terminal-output-scheduler-debug' +import { exposeTerminalOutputSchedulerDebugApi as exposeDebugApi } from './pane-terminal-output-scheduler-debug' import { flushTerminalOutputImpl } from './pane-terminal-output-flusher' import { writeTerminalOutputImpl } from './pane-terminal-output-writer' +import { + requestRegisteredTerminalBacklogRecovery, + type TerminalOutputTarget, + type WriteTerminalOutputOptions +} from './pane-terminal-output-queue-registry' +// Why this bare import: pane-terminal-output-drain registers the drain runner that the registry's +// scheduleDrain invokes, and this facade is every consumer's entry point into the scheduler graph. +import './pane-terminal-output-drain' -import { drainQueuedOutputImpl, fireQueuedAckCredits } from './pane-terminal-output-drain' -export type TerminalOutputTarget = ForegroundTerminalOutputTarget +export { + ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + BACKGROUND_BACKLOG_WARNING, + BACKGROUND_CHUNK_CHARS, + BACKGROUND_DRAIN_INTERVAL_MS, + BACKGROUND_FLUSH_DELAY_MS, + DRAIN_TIME_BUDGET_MS, + FOREGROUND_BACKLOG_WARNING, + HIGH_PRIORITY_DRAIN_INTERVAL_MS, + HIGH_PRIORITY_MAX_WRITES_PER_DRAIN, + LARGE_BACKLOG_CHARS, + MAX_BACKGROUND_QUEUE_CHUNKS, + MAX_WRITES_PER_DRAIN, + configureTerminalOutputBacklogCap, + discardTerminalOutput, + getTerminalOutputMaxQueueChars, + isMessageChannelDrainEnabled, + markTerminalOutputDrainStarted, + queuedByTerminal, + registerTerminalBacklogRecovery, + requestRegisteredTerminalBacklogRecovery, + scheduleDrain, + setUseMessageChannelDrainForTesting, + type QueueEntry, + type QueuedWrite, + type TerminalOutputBeforeWrite, + type TerminalOutputParsedCallback, + type TerminalOutputTarget, + type WriteTerminalOutputOptions +} from './pane-terminal-output-queue-registry' -export type TerminalOutputBeforeWrite = (data: string) => void -type TerminalBacklogRecoveryRequest = () => boolean -export type TerminalOutputParsedCallback = () => void -type ForegroundRefreshSyncResolver = () => boolean - -export type WriteTerminalOutputOptions = { - foreground: boolean - beforeWrite?: TerminalOutputBeforeWrite - onParsed?: TerminalOutputParsedCallback - /** Parse-deferred delivery ACK (terminal-pty-ack-gate). MUST be invoked when the chunk is parsed OR discarded by any drop path; fire-once, so double invocation is safe but omission permanently shrinks main's in-flight window. */ - ackCredit?: () => void - onBackgroundBacklogDropped?: () => void - latencySensitive?: boolean - forceForegroundRefresh?: boolean - followupForegroundRefresh?: boolean - shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver - stripTransientCursorShows?: boolean - coalesceForeground?: boolean - holdForeground?: boolean -} - -type QueueChunk = { - data: string - // Tracks the backing data still reachable through this queue slot. - retainedChars: number - foreground: boolean - forceForegroundRefresh: boolean - followupForegroundRefresh: boolean - shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver - stripTransientCursorShows: boolean - beforeWrite?: TerminalOutputBeforeWrite - onParsed?: TerminalOutputParsedCallback - ackCredit?: () => void -} - -export type QueuedWrite = { - data: string - foreground: boolean - forceForegroundRefresh: boolean - followupForegroundRefresh: boolean - shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver - stripTransientCursorShows: boolean - beforeWrite?: TerminalOutputBeforeWrite - onParsed?: TerminalOutputParsedCallback - ackCredits: (() => void)[] -} - -export type QueueEntry = { - terminal: TerminalOutputTarget - chunks: QueueChunk[] - chunkIndex: number - queuedChars: number - onBackgroundBacklogDropped?: () => void - backgroundBacklogDropped: boolean - highPriority: boolean - foregroundHold: boolean - foregroundHoldSafetyDelayMs: number - foregroundCoalesce: boolean - foregroundCoalesceDelayMs: number - foregroundHoldSafetyTimer: ReturnType | null - foregroundCoalesceTimer: ReturnType | null - // Why: hold and coalesce cancel each other's fallback timer, so an alternating DEC 2026 stream could re-arm both forever and freeze a visible pane (#8754). This caps one non-drainable episode. - foregroundReleaseDeadlineAt: number | null - // Why: an open frame's own hold chunks may still push the deadline out, but once coalesce has taken the entry over the deadline stops moving so the two mechanisms can't re-arm each other. - foregroundReleaseDeadlineFixed: boolean -} - -export const BACKGROUND_FLUSH_DELAY_MS = 50 -export const BACKGROUND_DRAIN_INTERVAL_MS = 16 -export const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 -export const BACKGROUND_CHUNK_CHARS = 16 * 1024 -export const MAX_WRITES_PER_DRAIN = 2 -// Why 8: per-tick volume (8 x 16KB = 128KB ≈ 1.3ms parse) sets the sustained ceiling (~30MB/s) within DRAIN_TIME_BUDGET_MS; at 2 it was only 8MB/s against a ~100MB/s parser (see throughput bench). -export const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8 -export const DRAIN_TIME_BUDGET_MS = 8 -export const LARGE_BACKLOG_CHARS = 512 * 1024 -// Why mutable: the cap scales with the user's scrollback setting (terminalOutputBacklogCapChars), configured when settings apply; the chunk-count cap stays fixed. -let maxQueueChars = TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS -export const MAX_BACKGROUND_QUEUE_CHUNKS = 4096 - -export function configureTerminalOutputBacklogCap(scrollbackRows: unknown): void { - maxQueueChars = terminalOutputBacklogCapChars(scrollbackRows) -} - -export function getTerminalOutputMaxQueueChars(): number { - return maxQueueChars -} const PARSE_SETTLE_TIMEOUT_MS = 250 -// Why: leading CAN aborts any partial escape sequence before the style reset so the backlog warning renders cleanly. -export const BACKGROUND_BACKLOG_WARNING = - '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog grew too large.]\r\n' -// Why a separate foreground message: a visible pane hitting the cap means the drain couldn't keep up with a flood (starved renderer), not merely output produced while hidden. -export const FOREGROUND_BACKLOG_WARNING = - '\x18\x1b[0m\r\n[Orca skipped a burst of terminal output because the backlog grew too large.]\r\n' -export const ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY = (): boolean => true - -export const queuedByTerminal = new Map() -setTerminalOutputDebugQueueReader(() => queuedByTerminal.values()) -const backlogRecoveryByTerminal = new WeakMap< - TerminalOutputTarget, - TerminalBacklogRecoveryRequest ->() -let drainTimer: ReturnType | null = null -let drainTimerDelayMs: number | null = null -// Why a MessageChannel for zero-delay drains: Chromium clamps nested setTimeout(0) to ~4ms; a posted macrotask isn't clamped yet still yields to input/paint. Cancellation is by generation. -let drainImmediatePending = false -let drainImmediateGeneration = 0 -let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestEnv() -let drainChannel: MessageChannel | null = null - -function isVitestEnv(): boolean { - // Why: vitest fake timers can't advance MessageChannel macrotasks; the timer path keeps the suites' virtual clock authoritative. - return typeof process !== 'undefined' && process.env?.VITEST === 'true' -} - -function getDrainChannel(): MessageChannel { - if (drainChannel === null) { - drainChannel = new MessageChannel() - drainChannel.port1.onmessage = (event: MessageEvent) => { - if (event.data !== drainImmediateGeneration || !drainImmediatePending) { - return - } - drainImmediatePending = false - drainQueuedOutputImpl() - } - } - return drainChannel -} - -function cancelImmediateDrain(): void { - drainImmediateGeneration++ - drainImmediatePending = false -} - -export function setUseMessageChannelDrainForTesting(value: boolean | null): void { - cancelImmediateDrain() - useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv()) -} - -export function isMessageChannelDrainEnabled(): boolean { - return useMessageChannelDrain -} - -export function markTerminalOutputDrainStarted(): void { - drainTimer = null - drainTimerDelayMs = null -} - -export function scheduleDrain(delayMs: number): void { - if (drainImmediatePending) { - return - } - if (drainTimer !== null) { - if (drainTimerDelayMs !== null && drainTimerDelayMs <= delayMs) { - return - } - clearTimeout(drainTimer) - drainTimer = null - drainTimerDelayMs = null - } - if (queuedByTerminal.size === 0) { - return - } - if (debugEnabled) { - debugState.scheduledDrainCount++ - } - if (delayMs === 0 && useMessageChannelDrain) { - drainImmediatePending = true - getDrainChannel().port2.postMessage(drainImmediateGeneration) - return - } - drainTimer = setTimeout(drainQueuedOutputImpl, delayMs) - drainTimerDelayMs = delayMs -} - -// Why: every discard path MUST fire these before clearing/replacing the queue — a dropped chunk still counts as consumed, or main's in-flight window shrinks permanently and the PTY wedges. export function writeTerminalOutput( terminal: TerminalOutputTarget, @@ -218,31 +63,11 @@ export function flushTerminalOutput( flushTerminalOutputImpl(terminal, options) } -export function requestRegisteredTerminalBacklogRecovery(terminal: TerminalOutputTarget): boolean { - const requestRecovery = backlogRecoveryByTerminal.get(terminal) - if (!requestRecovery) { - return false - } - return requestRecovery() -} - export function requestTerminalBacklogRecovery(terminal: TerminalOutputTarget): void { exposeDebugApi() requestRegisteredTerminalBacklogRecovery(terminal) } -export function registerTerminalBacklogRecovery( - terminal: TerminalOutputTarget, - requestRecovery: TerminalBacklogRecoveryRequest -): () => void { - backlogRecoveryByTerminal.set(terminal, requestRecovery) - return () => { - if (backlogRecoveryByTerminal.get(terminal) === requestRecovery) { - backlogRecoveryByTerminal.delete(terminal) - } - } -} - export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Promise { flushTerminalOutput(terminal) if (isTerminalWritePipelineCertifiedDead(terminal)) { @@ -279,19 +104,4 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro }) } -export function discardTerminalOutput(terminal: TerminalOutputTarget): void { - exposeDebugApi() - const entry = queuedByTerminal.get(terminal) - if (entry) { - // Why: discarded chunks still consumed their deliveries — credit them or main's in-flight window leaks (fireQueuedAckCredits). - fireQueuedAckCredits(entry) - } - discardInFlightTerminalOutputAckCredits(terminal) - queuedByTerminal.delete(terminal) - discardForegroundRenderSettle(terminal) - // Why: cancel the watch without masquerading as parse progress; replay guards use real completions to tell slow from wedged. - cancelTerminalWriteStallWatch(terminal) - recordQueueDebugPressure() -} - exposeDebugApi() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-writer.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-writer.ts index fa8dae6a309..89c3ba13364 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-writer.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-writer.ts @@ -19,7 +19,7 @@ import { } from './pane-terminal-output-scheduler-debug' import { flushTerminalOutputImpl } from './pane-terminal-output-flusher' import { composeParsedCallback, composeWriteFailureCallback } from './pane-terminal-output-pipeline' -import { queueCapExceeded, replaceBacklogWithWarning } from './pane-terminal-output-drain' +import { queueCapExceeded, replaceBacklogWithWarning } from './pane-terminal-output-queue-backlog' import { FOREGROUND_HOLD_SAFETY_DELAY_MS, LATENCY_SENSITIVE_FOREGROUND_COALESCE_DELAY_MS, @@ -41,7 +41,7 @@ import { scheduleDrain, type TerminalOutputTarget, type WriteTerminalOutputOptions -} from './pane-terminal-output-scheduler' +} from './pane-terminal-output-queue-registry' export function writeTerminalOutputImpl( terminal: TerminalOutputTarget,