perf(main): remove four per-chunk/per-waiter hot-path costs in PTY and terminal-wait (#18315)

Four independent wastes on the main process, none of which changes behavior:

- One shared 2s sweep replaces one setInterval per terminal-wait waiter. 20
  waiters allocated 20 handles and 10 main wakeups/s independent of output;
  now 1 handle and 0.5 wakeups/s. Same cadence, same per-waiter checks in the
  same order, same resolve semantics; the foregroundPollInFlight latch moved
  into the waiter's poll entry unchanged and each entry still interleaves its
  own foreground read, so one slow ps cannot delay another waiter.

- SIGWINCH's `ps` for Orca's own row is memoized. It reads this process's
  controlling tty, which is invariant for the process lifetime, and feeds
  exactly one guard. Exec count per 4-pane tab switch drops 16 -> 8. The call
  stays synchronous: making it async would reorder SIGWINCH against subsequent
  writes.

- The wait-blocked carry retains chunks with a running char count instead of
  concatenating and re-slicing a 256KB window on every chunk, and joins once
  at scan time. runWaitBlockedCheck receives a byte-identical `appended`.

- maxUpwardCursorReach no longer compiles a RegExp per redraw chunk, and
  containsTerminalVerticalLineControl walks with charCodeAt instead of minting
  a one-char string per position.
This commit is contained in:
Neil
2026-09-02 23:19:18 -07:00
committed by GitHub
parent c09d89b77a
commit 7ea213cf8c
14 changed files with 648 additions and 170 deletions
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { execFileSync } from 'node:child_process'
import {
getPosixPtyForegroundGroup,
resetPosixPtyForegroundGroupOwnRowCache,
signalPosixPtyForegroundGroup
} from './posix-pty-foreground-group'
@@ -137,6 +138,10 @@ describe('signalPosixPtyForegroundGroup', () => {
})
describe('process table lookup', () => {
beforeEach(() => {
resetPosixPtyForegroundGroupOwnRowCache()
})
it('asks ps for one pid at a time', () => {
// Why pinned: macOS ps only takes its by-pid fast path for a SINGLE pid. Any list
// form walks the whole process table (~3.6s on a busy machine vs ~3ms), which
@@ -170,4 +175,53 @@ describe('process table lookup', () => {
kill.mockRestore()
}
})
it('forks ps once per pane after the first SIGWINCH of the process', () => {
// Why: `runPs(currentPid)` reads Orca's own controlling tty, which cannot change
// for the process lifetime and feeds only the "do we share this PTY" guard. The
// renderer fires SIGWINCH twice per revealed pane, so re-forking it made a 4-pane
// tab switch eight synchronous ~3ms `ps` calls on the main event loop.
const execFileSyncMock = vi.mocked(execFileSync)
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true)
const panes = [
{ rootPid: 900, tty: 'ttys301' },
{ rootPid: 901, tty: 'ttys302' },
{ rootPid: 902, tty: 'ttys303' },
{ rootPid: 903, tty: 'ttys304' }
]
try {
execFileSyncMock.mockClear()
execFileSyncMock.mockImplementation(((_file: string, args: string[]) => {
const pid = Number(args[args.indexOf('-p') + 1])
if (pid === 4242) {
return '4242 4242 ttys002'
}
const pane = panes.find((entry) => entry.rootPid === pid)
return pane ? `${pane.rootPid} ${pane.rootPid + 50} ${pane.tty}` : ''
}) as never)
for (const pane of panes) {
// Two signals per revealed pane: hidden-restore snapshot + reattach repaint.
for (let signalIndex = 0; signalIndex < 2; signalIndex += 1) {
signalPosixPtyForegroundGroup(pane.rootPid, `/dev/${pane.tty}`, 'SIGWINCH', vi.fn(), {
platform: 'darwin',
currentPid: 4242
})
}
}
const pidArgs = execFileSyncMock.mock.calls.map((call) =>
Number((call[1] as string[])[(call[1] as string[]).indexOf('-p') + 1])
)
// 8 root-pid reads (one per signal) + exactly ONE read of Orca's own row.
expect(pidArgs.filter((pid) => pid === 4242)).toHaveLength(1)
expect(pidArgs).toHaveLength(9)
expect(kill).toHaveBeenCalledTimes(8)
} finally {
execFileSyncMock.mockReset()
execFileSyncMock.mockReturnValue('' as never)
kill.mockRestore()
}
})
})
+24 -2
View File
@@ -33,15 +33,37 @@ function runPs(pid: number): string {
})
}
let ownRowCache: { pid: number; row: string } | null = null
/**
* Why two calls instead of `-p a,b`: macOS `ps` only takes the KERN_PROC_PID fast
* Orca's own row, read once per process. It feeds exactly one guard — the
* "does this process share the PTY" check below — and the only field that guard
* reads is the controlling tty, which cannot change for a process's lifetime.
* Re-forking `ps` for it on every SIGWINCH doubled a ~3ms synchronous stall that
* the renderer fires twice per revealed pane.
*/
function readOwnProcessRow(currentPid: number): string {
if (ownRowCache?.pid !== currentPid) {
// A throw is not cached: the caller already treats a failed read as "no group".
ownRowCache = { pid: currentPid, row: runPs(currentPid) }
}
return ownRowCache.row
}
/** Test seam: the cache is keyed by pid, but tests reuse one pid across cases. */
export function resetPosixPtyForegroundGroupOwnRowCache(): void {
ownRowCache = null
}
/**
* Why two rows instead of `-p a,b`: macOS `ps` only takes the KERN_PROC_PID fast
* path for a single pid. ANY pid list — even a duplicate of one pid — walks the
* whole process table, measured at ~3.6s on a busy machine versus ~3ms here. That
* blew the timeout below, so the group lookup silently fell back to the very
* root-pid delivery this module exists to replace.
*/
function readForegroundGroupTable(rootPid: number, currentPid: number): string {
return `${runPs(rootPid)}\n${runPs(currentPid)}`
return `${runPs(rootPid)}\n${readOwnProcessRow(currentPid)}`
}
function parseProcessRows(output: string): ProcessRow[] {
@@ -11,7 +11,7 @@ import type {
} from './runtime-terminal-state-records'
import type { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import type { PtyProviderBufferSnapshot } from '../providers/types'
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
import type { WaitBlockedCheckState } from './wait-blocked-check-state'
import type { createAgentStatusOscProcessor } from '../../shared/agent-status-osc'
import { RuntimeAgentRowStore } from './runtime-agent-row-store'
import { RuntimeTerminalViewSubscribers } from './runtime-terminal-view-subscribers'
@@ -94,16 +94,7 @@ export class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWithStopRequ
// arbitrary, so running the identical computation over coalesced chunks at
// a bounded cadence (plus a trailing-edge timer so burst-final state is
// always evaluated) preserves semantics while removing it from the hot path.
protected waitBlockedCheckStateByPtyId = new Map<
string,
{
lastAt: number
lastWaitState: TerminalTailWaitState | null
appended: string
keywordCarry: string
timer: ReturnType<typeof setTimeout> | null
}
>()
protected waitBlockedCheckStateByPtyId = new Map<string, WaitBlockedCheckState>()
protected agentStatusOscProcessorsByPtyId = new Map<
string,
@@ -5,8 +5,13 @@ import {
WAIT_BLOCKED_KEYWORD_CARRY_CHARS,
WAIT_BLOCKED_KEYWORD_PATTERN
} from './orca-runtime-postlude'
import { MAX_TAIL_CHARS } from './terminal-tail-limits'
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
import {
appendWaitBlockedCarry,
createWaitBlockedCheckState,
readWaitBlockedCarry,
resetWaitBlockedCarry,
type WaitBlockedCheckState
} from './wait-blocked-check-state'
import {
computeTerminalTailWaitState,
tailGainedNewerBlockedReason
@@ -19,19 +24,16 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
protected scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void {
let state = this.waitBlockedCheckStateByPtyId.get(ptyId)
if (!state) {
state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null }
state = createWaitBlockedCheckState()
this.waitBlockedCheckStateByPtyId.set(ptyId, state)
}
const appendedLower = appendedText.toLowerCase()
const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`)
state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS)
// Why the cap keeps the tail: the accumulated text only anchors boundary-
// spanning prompt detection; anything past the tail cap has scrolled out
// of the retained tail the check reads anyway.
state.appended =
state.appended.length + appendedText.length > MAX_TAIL_CHARS
? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS)
: `${state.appended}${appendedText}`
// Why lowercase the joined window and not the chunk: the carry is already
// lowercase, so this is one folded copy instead of a discarded per-chunk copy
// plus the concatenation the pattern flattens anyway.
const keywordWindow = `${state.keywordCarry}${appendedText}`.toLowerCase()
const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(keywordWindow)
state.keywordCarry = keywordWindow.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS)
appendWaitBlockedCarry(state.appended, appendedText)
const elapsed = at - state.lastAt
if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) {
this.runWaitBlockedCheck(ptyId, state, at)
@@ -48,20 +50,10 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
}
}
protected runWaitBlockedCheck(
ptyId: string,
state: {
lastAt: number
lastWaitState: TerminalTailWaitState | null
appended: string
keywordCarry: string
timer: ReturnType<typeof setTimeout> | null
},
at: number
): void {
protected runWaitBlockedCheck(ptyId: string, state: WaitBlockedCheckState, at: number): void {
const pty = this.ptysById.get(ptyId)
if (!pty) {
state.appended = ''
resetWaitBlockedCarry(state.appended)
return
}
const nextWaitState = computeTerminalTailWaitState(
@@ -74,13 +66,19 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
signal: null,
fromTail: false
}
if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) {
if (
tailGainedNewerBlockedReason(
previousWaitState,
nextWaitState,
readWaitBlockedCarry(state.appended)
)
) {
pty.waitBlockedAt = at
this.recordAgentPromptPermissionObservation(ptyId)
}
state.lastAt = at
state.lastWaitState = nextWaitState
state.appended = ''
resetWaitBlockedCarry(state.appended)
}
// Why: the scanner's first run after a restore seed compares against a null
@@ -95,7 +93,7 @@ export class OrcaRuntimeWithScheduleWaitBlockedCheck extends OrcaRuntimeWithOnPt
}
let state = this.waitBlockedCheckStateByPtyId.get(ptyId)
if (!state) {
state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null }
state = createWaitBlockedCheckState()
this.waitBlockedCheckStateByPtyId.set(ptyId, state)
}
if (state.lastWaitState === null) {
@@ -72,9 +72,8 @@ export class OrcaRuntimeWithStartTuiIdleVisibleReadProbe extends OrcaRuntimeWith
return
}
const result = this.buildTuiIdleProbeResult(waiter.handle, blockedReason)
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
if (waiter.cancelIdlePoll) {
waiter.cancelIdlePoll()
}
this.terminalWaiters.resolve(waiter, result)
})
@@ -157,7 +157,8 @@ export type TerminalWaiter = {
resolve: (result: RuntimeTerminalWait) => void
reject: (error: Error) => void
timeout: NodeJS.Timeout | null
pollInterval: NodeJS.Timeout | null
/** Retires this waiter from the shared idle-poll sweep; null when not polling. */
cancelIdlePoll: (() => void) | null
abortCleanup: (() => void) | null
}
@@ -0,0 +1,175 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls'
import type { TerminalWaiter } from './runtime-terminal-contracts'
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import type { RuntimeTerminalWait } from '../../shared/runtime-types'
const INTERVAL_MS = 2000
function makePty(ptyId: string, overrides: Partial<RuntimePtyWorktreeRecord> = {}) {
return {
ptyId,
connected: true,
lastExitCode: null,
lastExitCause: null,
lastAgentStatus: null,
lastOutputAt: null,
tailBuffer: [],
tailPartialLine: '',
preview: '',
...overrides
} as unknown as RuntimePtyWorktreeRecord
}
function makeLeaf(tabId: string, overrides: Partial<RuntimeLeafRecord> = {}) {
return {
tabId,
ptyId: `${tabId}-pty`,
connected: true,
lastExitCode: null,
lastExitCause: null,
lastAgentStatus: null,
lastOutputAt: null,
paneTitle: null,
tailBuffer: [],
tailPartialLine: '',
preview: '',
...overrides
} as unknown as RuntimeLeafRecord
}
function makeWaiter(handle: string): TerminalWaiter {
return {
handle,
condition: 'tui-idle',
resolve: () => {},
reject: () => {},
timeout: null,
cancelIdlePoll: null,
abortCleanup: null
}
}
describe('RuntimeTerminalIdlePolls timer budget', () => {
let setIntervalSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.useFakeTimers()
setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
})
afterEach(() => {
setIntervalSpy.mockRestore()
vi.useRealTimers()
})
it('allocates one interval for 20 concurrent waiters and still resolves them on the first tick', () => {
const resolved: { handle: string; result: RuntimeTerminalWait }[] = []
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () => null,
getAdoptedPtyIdleStatus: () => null,
resolve: (waiter, result) => resolved.push({ handle: waiter.handle, result })
})
const waiters = Array.from({ length: 20 }, (_, index) => {
const waiter = makeWaiter(`handle-${index}`)
// Already idle: an independent interval would have resolved this on its own
// first tick at exactly intervalMs, and so must the shared sweep.
polls.startPty(waiter, makePty(`pty-${index}`, { lastAgentStatus: 'idle' }))
return waiter
})
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
expect(polls.activeTimerCount).toBe(1)
expect(resolved).toHaveLength(0)
vi.advanceTimersByTime(INTERVAL_MS - 1)
expect(resolved).toHaveLength(0)
vi.advanceTimersByTime(1)
expect(resolved.map((entry) => entry.handle)).toEqual(waiters.map((waiter) => waiter.handle))
// Every waiter retired, so the shared timer must retire with them.
expect(polls.activeTimerCount).toBe(0)
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
})
it('keeps one interval across mixed leaf and pty waiters and re-arms after going idle', () => {
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () => null,
getAdoptedPtyIdleStatus: () => null,
resolve: () => {}
})
for (let index = 0; index < 10; index += 1) {
polls.startPty(makeWaiter(`pty-handle-${index}`), makePty(`pty-${index}`))
polls.startLeaf(makeWaiter(`leaf-handle-${index}`), makeLeaf(`tab-${index}`))
}
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(INTERVAL_MS * 5)
// Nothing resolved: still exactly one live handle after 5 sweeps.
expect(polls.activeTimerCount).toBe(1)
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
})
it('retires the shared timer when the last waiter is cancelled through the waiter record', () => {
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () => null,
getAdoptedPtyIdleStatus: () => null,
resolve: () => {}
})
const first = makeWaiter('a')
const second = makeWaiter('b')
polls.startPty(first, makePty('pty-a'))
polls.startPty(second, makePty('pty-b'))
first.cancelIdlePoll?.()
expect(first.cancelIdlePoll).toBeNull()
expect(polls.activeTimerCount).toBe(1)
second.cancelIdlePoll?.()
expect(polls.activeTimerCount).toBe(0)
})
it('runs the foreground read per waiter without one waiter blocking another', async () => {
const resolved: string[] = []
const gates: ((value: string | null) => void)[] = []
const polls = new RuntimeTerminalIdlePolls({
intervalMs: INTERVAL_MS,
quiescenceMs: 1500,
getTabTitle: () => null,
getForegroundProcess: () =>
new Promise<string | null>((resolve) => {
gates.push(resolve)
}),
getAdoptedPtyIdleStatus: () => null,
resolve: (waiter) => resolved.push(waiter.handle)
})
polls.startPty(makeWaiter('slow'), makePty('pty-slow', { lastOutputAt: Date.now() - 10_000 }))
polls.startPty(makeWaiter('fast'), makePty('pty-fast', { lastOutputAt: Date.now() - 10_000 }))
vi.advanceTimersByTime(INTERVAL_MS)
// Both waiters issued their read in the same sweep — a sequential sweep would
// have blocked the second behind the first's unresolved promise.
expect(gates).toHaveLength(2)
gates[1]('node')
await vi.advanceTimersByTimeAsync(0)
expect(resolved).toEqual(['fast'])
gates[0]('node')
await vi.advanceTimersByTimeAsync(0)
expect(resolved).toEqual(['fast', 'slow'])
expect(polls.activeTimerCount).toBe(0)
})
})
+167 -117
View File
@@ -24,133 +24,183 @@ type RuntimeTerminalIdlePollDependencies = {
resolve(waiter: TerminalWaiter, result: RuntimeTerminalWait): void
}
type IdlePollEntry =
| {
kind: 'leaf'
waiter: TerminalWaiter
leaf: RuntimeLeafRecord
foregroundPollInFlight: boolean
}
| {
kind: 'pty'
waiter: TerminalWaiter
pty: RuntimePtyWorktreeRecord
foregroundPollInFlight: boolean
}
export class RuntimeTerminalIdlePolls {
private readonly entries = new Set<IdlePollEntry>()
private sweepTimer: ReturnType<typeof setInterval> | null = null
constructor(private readonly deps: RuntimeTerminalIdlePollDependencies) {}
startLeaf(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void {
let foregroundPollInFlight = false
waiter.pollInterval = setInterval(async () => {
if (!waiter.pollInterval) {
return
}
let startedForegroundPoll = false
try {
if (leaf.lastAgentStatus === 'idle') {
this.stop(waiter)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
const title = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId)
if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') {
this.stop(waiter)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(waiter)
this.deps.resolve(
waiter,
buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason)
)
return
}
if (isKnownReadyPromptPreview(waitText)) {
this.stop(waiter)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
if (leaf.lastAgentStatus === null && leaf.ptyId && !foregroundPollInFlight) {
const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId)
if (!foregroundRead) {
return
}
foregroundPollInFlight = true
startedForegroundPoll = true
const foreground = await foregroundRead
if (
foreground &&
!isShellProcess(foreground) &&
(leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0) >= this.deps.quiescenceMs
) {
this.stop(waiter)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
}
}
} catch {
// Transient process inspection errors do not retire the waiter.
} finally {
if (startedForegroundPoll) {
foregroundPollInFlight = false
}
}
}, this.deps.intervalMs)
this.start({ kind: 'leaf', waiter, leaf, foregroundPollInFlight: false })
}
startPty(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void {
let foregroundPollInFlight = false
waiter.pollInterval = setInterval(async () => {
if (!waiter.pollInterval) {
return
}
let startedForegroundPoll = false
try {
if (pty.lastAgentStatus === 'idle') {
this.stop(waiter)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
return
}
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(waiter)
this.deps.resolve(
waiter,
buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason)
)
return
}
if (
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
isKnownReadyPromptPreview(waitText)
) {
this.stop(waiter)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
return
}
if (pty.lastAgentStatus === null && !foregroundPollInFlight) {
const foregroundRead = this.deps.getForegroundProcess(pty.ptyId)
if (!foregroundRead) {
return
}
foregroundPollInFlight = true
startedForegroundPoll = true
const foreground = await foregroundRead
if (
foreground &&
!isShellProcess(foreground) &&
(pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0) >= this.deps.quiescenceMs
) {
this.stop(waiter)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
}
}
} catch {
// Transient process inspection errors do not retire the waiter.
} finally {
if (startedForegroundPoll) {
foregroundPollInFlight = false
}
}
}, this.deps.intervalMs)
this.start({ kind: 'pty', waiter, pty, foregroundPollInFlight: false })
}
private stop(waiter: TerminalWaiter): void {
if (!waiter.pollInterval) {
/** Test/diagnostic seam: live sweep handles, which must stay at most one. */
get activeTimerCount(): number {
return this.sweepTimer ? 1 : 0
}
private start(entry: IdlePollEntry): void {
this.entries.add(entry)
entry.waiter.cancelIdlePoll = () => this.stop(entry)
// Why one shared timer for every waiter: a per-waiter interval multiplied idle
// main-process wakeups by the number of concurrent `wait` calls, independent of
// whether any terminal produced output. Same shape as the synthetic-title spinner.
if (!this.sweepTimer) {
this.sweepTimer = setInterval(() => this.sweep(), this.deps.intervalMs)
}
}
private sweep(): void {
// Why a snapshot and no await: each entry must run its checks and then interleave
// its own foreground read exactly as an independent interval callback did — one
// slow `ps` must never delay another waiter's checks, and a waiter registered by a
// resolve inside this sweep must wait for the next tick, as a fresh interval would.
for (const entry of Array.from(this.entries)) {
void (entry.kind === 'leaf' ? this.tickLeaf(entry) : this.tickPty(entry))
}
}
private async tickLeaf(entry: IdlePollEntry & { kind: 'leaf' }): Promise<void> {
if (!this.entries.has(entry)) {
return
}
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
const { waiter, leaf } = entry
let startedForegroundPoll = false
try {
if (leaf.lastAgentStatus === 'idle') {
this.stop(entry)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
const title = leaf.paneTitle ?? this.deps.getTabTitle(leaf.tabId)
if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') {
this.stop(entry)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
const waitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(entry)
this.deps.resolve(
waiter,
buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason)
)
return
}
if (isKnownReadyPromptPreview(waitText)) {
this.stop(entry)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
if (leaf.lastAgentStatus === null && leaf.ptyId && !entry.foregroundPollInFlight) {
const foregroundRead = this.deps.getForegroundProcess(leaf.ptyId)
if (!foregroundRead) {
return
}
entry.foregroundPollInFlight = true
startedForegroundPoll = true
const foreground = await foregroundRead
if (
foreground &&
!isShellProcess(foreground) &&
(leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0) >= this.deps.quiescenceMs
) {
this.stop(entry)
this.deps.resolve(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
}
}
} catch {
// Transient process inspection errors do not retire the waiter.
} finally {
if (startedForegroundPoll) {
entry.foregroundPollInFlight = false
}
}
}
private async tickPty(entry: IdlePollEntry & { kind: 'pty' }): Promise<void> {
if (!this.entries.has(entry)) {
return
}
const { waiter, pty } = entry
let startedForegroundPoll = false
try {
if (pty.lastAgentStatus === 'idle') {
this.stop(entry)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
return
}
const waitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview)
const blockedReason = detectTerminalWaitBlockedReason(waitText)
if (blockedReason) {
this.stop(entry)
this.deps.resolve(
waiter,
buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason)
)
return
}
if (
this.deps.getAdoptedPtyIdleStatus(pty) === 'idle' ||
isKnownReadyPromptPreview(waitText)
) {
this.stop(entry)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
return
}
if (pty.lastAgentStatus === null && !entry.foregroundPollInFlight) {
const foregroundRead = this.deps.getForegroundProcess(pty.ptyId)
if (!foregroundRead) {
return
}
entry.foregroundPollInFlight = true
startedForegroundPoll = true
const foreground = await foregroundRead
if (
foreground &&
!isShellProcess(foreground) &&
(pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0) >= this.deps.quiescenceMs
) {
this.stop(entry)
this.deps.resolve(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty))
}
}
} catch {
// Transient process inspection errors do not retire the waiter.
} finally {
if (startedForegroundPoll) {
entry.foregroundPollInFlight = false
}
}
}
private stop(entry: IdlePollEntry): void {
if (!this.entries.delete(entry)) {
return
}
entry.waiter.cancelIdlePoll = null
if (this.entries.size === 0 && this.sweepTimer) {
clearInterval(this.sweepTimer)
this.sweepTimer = null
}
}
}
+2 -2
View File
@@ -83,7 +83,7 @@ export class RuntimeTerminalWait {
resolve,
reject,
timeout: null,
pollInterval: null,
cancelIdlePoll: null,
abortCleanup: null
}
if (!this.waiters.bindAbort(waiter, options?.signal)) {
@@ -177,7 +177,7 @@ export class RuntimeTerminalWait {
resolve,
reject,
timeout: null,
pollInterval: null,
cancelIdlePoll: null,
abortCleanup: null
}
@@ -60,8 +60,8 @@ export class RuntimeTerminalWaiterRegistry {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
}
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
if (waiter.cancelIdlePoll) {
waiter.cancelIdlePoll()
}
if (waiter.abortCleanup) {
waiter.abortCleanup()
@@ -59,9 +59,12 @@ export function hasCanonicalNumericCsiParams(params: string): boolean {
return /^[0-9;]*$/.test(params)
}
const ESCAPE_CHAR_CODE = 0x1b
export function containsTerminalVerticalLineControl(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
if (value[index] !== '\u001b') {
// Why charCodeAt: `value[index]` mints a one-char string per position on every chunk.
if (value.charCodeAt(index) !== ESCAPE_CHAR_CODE) {
continue
}
const parsed = parseAnsiControlSequence(value, index)
+7 -2
View File
@@ -110,14 +110,19 @@ export function appendNormalizedToTailBuffer(
// Why a window: the unwindowed impl below is O(tail) per chunk (~93% of the event loop under TUI flood, findings log 2026-07-03); a redraw only touches rows the cursor reaches, so window the suffix and share the prefix by reference. Equivalence fuzz-verified in retained-tail-redraw-window.equivalence.test.ts.
const REDRAW_WINDOW_SAFETY_ROWS = 8
// Why module-level: this ran `new RegExp` per redraw chunk — i.e. per TUI frame per PTY.
// Safe to share because `maxUpwardCursorReach` is synchronous and non-reentrant; it resets
// `lastIndex` before every scan.
const CURSOR_UP_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g')
function maxUpwardCursorReach(
normalizedChunk: string,
previousRedrawCursor: RetainedTailRedrawCursor | null
): number {
let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0
const cursorUpPattern = new RegExp(`${String.fromCharCode(27)}\\[(\\d*)(?:;[\\d;]*)?A`, 'g')
CURSOR_UP_PATTERN.lastIndex = 0
let match: RegExpExecArray | null
while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) {
while ((match = CURSOR_UP_PATTERN.exec(normalizedChunk)) !== null) {
reach += match[1] ? Number.parseInt(match[1], 10) : 1
}
return reach
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import {
appendWaitBlockedCarry,
createWaitBlockedAppendedCarry,
readWaitBlockedCarry,
resetWaitBlockedCarry
} from './wait-blocked-check-state'
import { MAX_TAIL_CHARS } from './terminal-tail-limits'
/** The accumulation this replaced, verbatim, as the equivalence oracle. */
function referenceAppend(previous: string, chunk: string): string {
return previous.length + chunk.length > MAX_TAIL_CHARS
? `${previous}${chunk}`.slice(-MAX_TAIL_CHARS)
: `${previous}${chunk}`
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
describe('wait-blocked appended carry', () => {
it('is byte-identical to the concat+slice carry across a 1MB flood in one 50ms window', () => {
// One 50ms throttle window under a TUI flood: ~1MB of output arrives as many
// chunks and `runWaitBlockedCheck` must observe exactly the bytes the old
// rolling window would have handed it.
const carry = createWaitBlockedAppendedCarry()
let reference = ''
const rng = mulberry32(20260902)
let produced = 0
let chunkIndex = 0
while (produced < 1024 * 1024) {
const size = 1 + Math.floor(rng() * 8192)
const chunk = `${chunkIndex}:${'█'.repeat(Math.max(0, size - 3))}\n`
chunkIndex += 1
produced += chunk.length
appendWaitBlockedCarry(carry, chunk)
reference = referenceAppend(reference, chunk)
expect(carry.chars).toBe(reference.length)
}
expect(readWaitBlockedCarry(carry)).toBe(reference)
expect(reference.length).toBe(MAX_TAIL_CHARS)
})
it('matches the reference for boundary shapes: empty, exact-cap, and over-cap single chunks', () => {
const cases: string[][] = [
[],
[''],
['abc', '', 'def'],
['x'.repeat(MAX_TAIL_CHARS)],
['x'.repeat(MAX_TAIL_CHARS), 'y'],
['a', 'b'.repeat(MAX_TAIL_CHARS + 5)],
['a'.repeat(MAX_TAIL_CHARS - 1), 'bc'],
['a'.repeat(10), 'b'.repeat(MAX_TAIL_CHARS - 10)],
['a'.repeat(10), 'b'.repeat(MAX_TAIL_CHARS - 10), 'c']
]
for (const chunks of cases) {
const carry = createWaitBlockedAppendedCarry()
let reference = ''
for (const chunk of chunks) {
appendWaitBlockedCarry(carry, chunk)
reference = referenceAppend(reference, chunk)
}
expect(readWaitBlockedCarry(carry)).toBe(reference)
expect(carry.chars).toBe(reference.length)
}
})
it('retains chunks instead of flattening the window on every chunk', () => {
// The named antipattern: once a window exceeds the cap, the old `.slice(-cap)`
// copied 256K chars per chunk. Retained chunks copy only the straddling head.
const carry = createWaitBlockedAppendedCarry()
const chunk = 'z'.repeat(32 * 1024)
for (let i = 0; i < 16; i += 1) {
appendWaitBlockedCarry(carry, chunk)
}
expect(carry.chars).toBe(MAX_TAIL_CHARS)
expect(carry.chunks.length).toBe(8)
appendWaitBlockedCarry(carry, 'tail')
expect(carry.chars).toBe(MAX_TAIL_CHARS)
// Head trimmed in place by 4 chars; no full-window copy.
expect(carry.chunks.length).toBe(9)
expect(carry.chunks[0].length).toBe(chunk.length - 4)
})
it('reset empties the window without leaking retained chunks', () => {
const carry = createWaitBlockedAppendedCarry()
appendWaitBlockedCarry(carry, 'hello')
resetWaitBlockedCarry(carry)
expect(readWaitBlockedCarry(carry)).toBe('')
expect(carry.chars).toBe(0)
expect(carry.chunks).toHaveLength(0)
})
})
@@ -0,0 +1,80 @@
import { MAX_TAIL_CHARS } from './terminal-tail-limits'
import type { TerminalTailWaitState } from './terminal-wait-tail-state'
/**
* The capped rolling window of output appended since the last wait-blocked scan.
*
* Why chunks instead of one string: the scan is throttled to 50ms but the
* accumulation is per chunk, so concatenating and re-slicing a 256KB window on
* every PTY frame flattened the whole window per frame once a burst filled it.
* Chunks are retained with a running char count and joined once, at scan time.
*/
export type WaitBlockedAppendedCarry = {
chunks: string[]
chars: number
}
export type WaitBlockedCheckState = {
lastAt: number
lastWaitState: TerminalTailWaitState | null
appended: WaitBlockedAppendedCarry
keywordCarry: string
timer: ReturnType<typeof setTimeout> | null
}
export function createWaitBlockedAppendedCarry(): WaitBlockedAppendedCarry {
return { chunks: [], chars: 0 }
}
export function createWaitBlockedCheckState(): WaitBlockedCheckState {
return {
lastAt: 0,
lastWaitState: null,
appended: createWaitBlockedAppendedCarry(),
keywordCarry: '',
timer: null
}
}
/**
* Appends one chunk, dropping from the head past `MAX_TAIL_CHARS`. The cap keeps
* the tail: the accumulated text only anchors boundary-spanning prompt detection,
* and anything past the tail cap has scrolled out of the retained tail the check
* reads anyway. Byte-for-byte identical to `(previous + chunk).slice(-cap)`,
* including the partial trim of the chunk that straddles the cap boundary.
*/
export function appendWaitBlockedCarry(carry: WaitBlockedAppendedCarry, chunk: string): void {
if (chunk.length === 0) {
return
}
carry.chunks.push(chunk)
carry.chars += chunk.length
if (carry.chars <= MAX_TAIL_CHARS) {
return
}
let excess = carry.chars - MAX_TAIL_CHARS
let dropCount = 0
while (excess > 0 && dropCount < carry.chunks.length) {
const head = carry.chunks[dropCount]
if (head.length <= excess) {
excess -= head.length
dropCount += 1
continue
}
carry.chunks[dropCount] = head.slice(excess)
excess = 0
}
if (dropCount > 0) {
carry.chunks.splice(0, dropCount)
}
carry.chars = MAX_TAIL_CHARS
}
export function readWaitBlockedCarry(carry: WaitBlockedAppendedCarry): string {
return carry.chunks.length === 1 ? carry.chunks[0] : carry.chunks.join('')
}
export function resetWaitBlockedCarry(carry: WaitBlockedAppendedCarry): void {
carry.chunks.length = 0
carry.chars = 0
}