mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 08:03:58 +00:00
Fix F1-cycle for #17131
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user