diff --git a/src/main/pty/posix-pty-foreground-group.test.ts b/src/main/pty/posix-pty-foreground-group.test.ts index de88ba8def8..efe2d51fde5 100644 --- a/src/main/pty/posix-pty-foreground-group.test.ts +++ b/src/main/pty/posix-pty-foreground-group.test.ts @@ -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() + } + }) }) diff --git a/src/main/pty/posix-pty-foreground-group.ts b/src/main/pty/posix-pty-foreground-group.ts index 8b3e375dbff..e9df7baeb66 100644 --- a/src/main/pty/posix-pty-foreground-group.ts +++ b/src/main/pty/posix-pty-foreground-group.ts @@ -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[] { diff --git a/src/main/runtime/orca-runtime-fit-override-listeners.ts b/src/main/runtime/orca-runtime-fit-override-listeners.ts index d5cacdad8c4..63adb867d55 100644 --- a/src/main/runtime/orca-runtime-fit-override-listeners.ts +++ b/src/main/runtime/orca-runtime-fit-override-listeners.ts @@ -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 | null - } - >() + protected waitBlockedCheckStateByPtyId = new Map() protected agentStatusOscProcessorsByPtyId = new Map< string, diff --git a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts index ed3f8a786ba..cc3bd4f6d3c 100644 --- a/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts +++ b/src/main/runtime/orca-runtime-schedule-wait-blocked-check.ts @@ -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 | 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) { diff --git a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts index 61731459ab8..adaa5fde72f 100644 --- a/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts +++ b/src/main/runtime/orca-runtime-start-tui-idle-visible-read-probe.ts @@ -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) }) diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index 3ea47fd6598..534a24fa9ec 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -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 } diff --git a/src/main/runtime/runtime-terminal-idle-polls.test.ts b/src/main/runtime/runtime-terminal-idle-polls.test.ts new file mode 100644 index 00000000000..e153d60d1fa --- /dev/null +++ b/src/main/runtime/runtime-terminal-idle-polls.test.ts @@ -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 = {}) { + 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 = {}) { + 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 + + 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((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) + }) +}) diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index c3a0e04ceb3..eda89b6f9a9 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -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() + private sweepTimer: ReturnType | 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 { + 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 { + 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 + } } } diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index de7ccdc674a..fd92582c09e 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -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 } diff --git a/src/main/runtime/runtime-terminal-waiter-registry.ts b/src/main/runtime/runtime-terminal-waiter-registry.ts index 55f8db8eb5a..46026f7f7df 100644 --- a/src/main/runtime/runtime-terminal-waiter-registry.ts +++ b/src/main/runtime/runtime-terminal-waiter-registry.ts @@ -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() diff --git a/src/main/runtime/terminal-ansi-normalization.ts b/src/main/runtime/terminal-ansi-normalization.ts index a3f35899a2c..e6be4dac859 100644 --- a/src/main/runtime/terminal-ansi-normalization.ts +++ b/src/main/runtime/terminal-ansi-normalization.ts @@ -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) diff --git a/src/main/runtime/terminal-tail-buffer.ts b/src/main/runtime/terminal-tail-buffer.ts index d26884a9738..0cf551e92c6 100644 --- a/src/main/runtime/terminal-tail-buffer.ts +++ b/src/main/runtime/terminal-tail-buffer.ts @@ -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 diff --git a/src/main/runtime/wait-blocked-check-state.test.ts b/src/main/runtime/wait-blocked-check-state.test.ts new file mode 100644 index 00000000000..11d4d508a62 --- /dev/null +++ b/src/main/runtime/wait-blocked-check-state.test.ts @@ -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) + }) +}) diff --git a/src/main/runtime/wait-blocked-check-state.ts b/src/main/runtime/wait-blocked-check-state.ts new file mode 100644 index 00000000000..923827ae1b6 --- /dev/null +++ b/src/main/runtime/wait-blocked-check-state.ts @@ -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 | 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 +}