Files
orca/src/shared/process-table-snapshot.ts
T
NeilandOrca 91323cf925 perf(windows): dedupe per-pane process-table scans in agent inspection (#7384)
* perf(windows): dedupe per-pane process-table scans in agent inspection

Windows agent foreground-process inspection forks a whole-process-table
PowerShell/CIM scan per pane on the same 750ms/2000ms cadence the POSIX path
uses. The POSIX side routes through getProcessTableSnapshot (500ms TTL + single
in-flight, #6288/#6667), collapsing N concurrent panes to ~2 scans/sec. The
Windows path (queryWindowsProcessDescendants) had no such dedup: K concurrent
agent panes forked K powershell.exe cold-starts, each enumerating the ENTIRE
process table then filtering per-pid in JS — ~10-40x heavier than `ps` (a
powershell cold start is ~150-400ms CPU + tens of MB RSS). The degraded/local
PTY provider path calls it with no per-pane throttle at all. This is the
Windows analogue of the idle-CPU churn #6288 fixed for POSIX.

Generalize the existing createProcessTableSnapshotReader factory to be generic
over its scan result (default T = string, so the POSIX path and its test are
byte-identical) and add a Windows singleton reader that caches parsed
WindowsProcessRow[]. queryWindowsProcessDescendants now reads the shared
snapshot and runs its own descendant walk; runWindowsProcessRows throws on total
enumeration failure so the miss is not cached and the prior null-fallback
contract (callers fall through to node-pty's name) is preserved.

Windows scan-volume regression test (mirrors the POSIX #6288 guard) drives
PANE_COUNT concurrent panes over the cadence window and asserts powershell.exe
spawns are bounded by ticks, not pane count, while every pane still resolves its
descendant. Reverting the dedup fails both cases. POSIX snapshot + volume tests
unchanged and green; node/web/cli typecheck clean.

Co-authored-by: Orca <help@stably.ai>

* test: reset windows process-rows snapshot between agent-foreground cases

The new module-level Windows rows reader caches for 500ms with real
Date.now(), so one case's mocked process table was served to the next
case's assertions (7 CI failures in agent-foreground-process.test.ts).
Mirror the suite's existing POSIX resetProcessTableSnapshotForTests()
with the Windows reset in beforeEach.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-04 18:10:45 -07:00

113 lines
3.9 KiB
TypeScript

import { execFile as execFileCb } from 'node:child_process'
import { promisify } from 'node:util'
const execFile = promisify(execFileCb)
// Why: agent foreground-process inspection runs this full process-table scan on
// a 750ms/2000ms per-pane cadence. On a shared SSH relay every tracked agent
// terminal drives it, so concurrent panes used to each fork their own `ps`,
// pinning idle CPU (issue #6288). Memoizing collapses overlapping scans to one.
const PS_ARGS = ['-axo', 'pid=,ppid=,stat=,command='] as const
const PS_TIMEOUT_MS = 3000
// Why: 500ms is below the active cadence poll's minimum inter-poll gap (~675ms
// = 750ms less jitter), so a cadence-driven pane never reuses a snapshot older
// than it would have scanned itself; a burst of panes polling in the same
// window collapses from up to 8 scans/sec down to ~2/sec. The faster
// event-driven follow-up inspections (e.g. the pending-title confirmation,
// which can re-fire <500ms apart) intentionally accept a <=500ms-stale table:
// they only confirm the same agent still owns the pane, and process-exit is
// debounced across repeated samples, so a near-instant cached scan answers
// identically to a fresh fork.
const DEFAULT_SNAPSHOT_TTL_MS = 500
type Snapshot<T> = { value: T; capturedAtMs: number }
type ProcessTableSnapshotReaderDeps<T> = {
runPs: () => Promise<T>
now: () => number
ttlMs?: number
}
/**
* Build a process-table snapshot reader that deduplicates concurrent and
* near-simultaneous scans behind a single in-flight promise + short TTL.
* Exposed as a factory so tests can inject the scan and clock; production code
* uses the shared `getProcessTableSnapshot` instance below. Generic over the
* scan result so the Windows path can cache parsed rows while POSIX caches the
* raw `ps` stdout string (the default).
*/
export function createProcessTableSnapshotReader<T = string>(
deps: ProcessTableSnapshotReaderDeps<T>
): {
getSnapshot: () => Promise<T>
reset: () => void
} {
const ttlMs = deps.ttlMs ?? DEFAULT_SNAPSHOT_TTL_MS
let cached: Snapshot<T> | null = null
let inFlight: Promise<T> | null = null
async function getSnapshot(): Promise<T> {
if (cached && deps.now() - cached.capturedAtMs < ttlMs) {
return cached.value
}
if (inFlight) {
return inFlight
}
const promise = deps.runPs()
inFlight = promise
try {
const value = await promise
// Why: stamp capture time AFTER the scan returns so a slow scan can't
// hand back a snapshot that is already older than its TTL.
cached = { value, capturedAtMs: deps.now() }
return value
} finally {
// Clear in-flight on success and failure so a transient `ps` error
// (timeout, nonzero exit) retries on the next call instead of being
// cached; callers keep their existing best-effort fall-through.
if (inFlight === promise) {
inFlight = null
}
}
}
return {
getSnapshot,
// Why: lets tests that mock `ps` per case clear the cross-call cache so one
// case's snapshot can't satisfy the next within the TTL window.
reset: () => {
cached = null
inFlight = null
}
}
}
const defaultReader = createProcessTableSnapshotReader({
runPs: async () => {
const { stdout } = await execFile('ps', [...PS_ARGS], {
encoding: 'utf-8',
timeout: PS_TIMEOUT_MS
})
return stdout
},
now: () => Date.now()
})
/**
* Run (or reuse a recent) `ps -axo pid=,ppid=,stat=,command=` scan and return
* its raw stdout. Per-process singleton: the relay and local main processes
* each dedupe their own scans.
*/
export function getProcessTableSnapshot(): Promise<string> {
return defaultReader.getSnapshot()
}
/**
* Test-only: clear the shared snapshot cache so suites that mock `ps` between
* cases don't have one case's snapshot served to the next within the TTL.
*/
export function resetProcessTableSnapshotForTests(): void {
defaultReader.reset()
}