Fix F1-cycle for #17131

This commit is contained in:
Neil
2026-08-29 15:44:48 -07:00
parent 26e168d5cb
commit 4dae0df521
9 changed files with 407 additions and 343 deletions
@@ -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
@@ -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)
@@ -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,
@@ -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(
@@ -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
}
@@ -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
@@ -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<typeof setTimeout> | null
foregroundCoalesceTimer: ReturnType<typeof setTimeout> | 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<TerminalOutputTarget, QueueEntry>()
setTerminalOutputDebugQueueReader(() => queuedByTerminal.values())
const backlogRecoveryByTerminal = new WeakMap<
TerminalOutputTarget,
TerminalBacklogRecoveryRequest
>()
let drainTimer: ReturnType<typeof setTimeout> | 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()
}
@@ -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<typeof setTimeout> | null
foregroundCoalesceTimer: ReturnType<typeof setTimeout> | 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<TerminalOutputTarget, QueueEntry>()
setTerminalOutputDebugQueueReader(() => queuedByTerminal.values())
const backlogRecoveryByTerminal = new WeakMap<
TerminalOutputTarget,
TerminalBacklogRecoveryRequest
>()
let drainTimer: ReturnType<typeof setTimeout> | 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<void> {
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()
@@ -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,