Files
orca/src/shared/cheap-process-table-snapshot-reader.ts
T
Neil e95d247be1 perf(terminal): cheap-tier process inspection for anchored local agent panes (#18780)
* perf(terminal): cheap-tier process inspection for anchored local agent panes

Every idle local pane's completion cadence ran a full whole-host `ps` (with
`tty=` and `command=`, 0.34-0.50s on a 1,900-process Mac, 1.15s on Linux)
purely to build `foregroundProcessEvidence` that the renderer then discards
for local ids. Add a cheap tier (same job-control columns, no tty/command,
0.03s) gated so that it introduces no user-facing trade-off:

- Only a pane whose last FULL capture proved a recognized agent may take the
  cheap tier. Panes with no anchor always take the full capture, so start
  discovery keeps today's exact behaviour.
- The cheap tick compares a per-pane fingerprint (root shell pid+start, tpgid,
  every descendant's pid+start+pgid+job-control state). Any change, a changed
  node-pty foreground name, an unreadable capture, or an incarnation mismatch
  escalates to the full capture. A recognized agent's exit is always a pid
  vanishing, which the fingerprint always sees.
- A cheap answer OMITS evidence rather than fabricating a tty-less fence.
  Remote/restore consumers never send `steadyState`, so they keep the full
  capture unchanged.
- `steadyState` is a new optional request field; an old daemon ignores it and
  answers with the full capture.

Measured (8 idle panes, 60s, idle cadence, forks counted by column set):
30 full -> 1 full + 29 cheap.

* fix(terminal): route the cheap ps capture through runProcess

The cheap-tier reader imported node:child_process directly, which the
child-process import-boundary and windowsHide ratchet tests reject (CI shards
1/8 and 3/8). Use Orca's single spawn entry point instead; it pins windowsHide
and encodes argv. Map its result onto the capture-error vocabulary:
outputTruncated -> capture_truncated, timedOut -> capture_timeout, non-zero
exit -> ps_exit_<code>. Tests mock at the runProcess seam.

* fix(perf): refuse a pane fingerprint when any descendant start marker is missing

`buildPaneProcessFingerprint` rejected only a missing root start marker; a missing descendant
marker was stamped as `?`. Two captures that both failed to read the same descendant therefore
compared equal, which removes the pid-reuse protection the fingerprint exists to provide: a
recycled pid could make a vanished agent look unchanged, and the cheap tier would keep serving
its name instead of escalating.

Reachable on Linux, where `readLinuxProcStartTime` legitimately returns null when a process
exits between the `ps` capture and the `/proc/<pid>/stat` read.

Every subtree member now needs a start marker or the fingerprint is refused, which sends the
caller to the full capture — the same conservative default every other uncertain path takes.

Reported by CodeRabbit on #18780. The two new tests fail against the previous code with
`expected '4242@2400#4300:|4300@?:4300:+' to be null`.
2026-09-05 00:57:02 -07:00

52 lines
1.8 KiB
TypeScript

import { runProcess } from './child-process/run-process'
import {
CHEAP_PS_ARGS,
PS_MAX_BUFFER_BYTES,
ProcessTableCaptureError,
parseCheapProcessTableRows,
type CheapProcessTableRow
} from './process-table-snapshot'
import {
PS_TIMEOUT_MS,
createProcessTableSnapshotReader,
withEvidenceBudget
} from './process-table-snapshot-reader'
/**
* The cheap-tier sibling of the strict evidence reader: same coalescing and TTL, a
* column set without `tty=`/`command=`. Separate instance because the two column sets
* parse differently and a cheap capture must never be served to an evidence consumer.
*/
const cheapProcessTableReader = createProcessTableSnapshotReader<CheapProcessTableRow[]>({
runPs: async () => {
const result = await runProcess({
program: 'ps',
args: CHEAP_PS_ARGS,
timeoutMs: PS_TIMEOUT_MS,
maxOutputBytes: PS_MAX_BUFFER_BYTES
})
// A ceiling hit is truncation, not absence: name it in the domain vocabulary.
if (result.outputTruncated) {
throw new ProcessTableCaptureError('capture_truncated')
}
if (result.timedOut) {
throw new ProcessTableCaptureError('capture_timeout')
}
if (result.code !== 0) {
throw new ProcessTableCaptureError(`ps_exit_${result.code ?? result.signal ?? 'unknown'}`)
}
return parseCheapProcessTableRows(result.stdout)
},
now: () => Date.now()
})
/** Same wait bound as the evidence read: a stalled cheap capture must fall through to the full
* path's own handling rather than pin a polled tick. */
export async function getCheapProcessTableSnapshot(): Promise<CheapProcessTableRow[]> {
return withEvidenceBudget(cheapProcessTableReader.getSnapshot())
}
export function resetCheapProcessTableSnapshotForTests(): void {
cheapProcessTableReader.reset()
}