Files
orca/src/shared/cheap-process-table-snapshot.test.ts
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

148 lines
4.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
// The cheap reader goes through Orca's single child-process entry point (windowsHide, argv
// encoding, tree termination); mock at that seam rather than node:child_process.
vi.mock('./child-process/run-process', () => ({ runProcess: runProcessMock }))
import {
getCheapProcessTableSnapshot,
resetCheapProcessTableSnapshotForTests
} from './cheap-process-table-snapshot-reader'
import { PS_TIMEOUT_MS } from './process-table-snapshot-reader'
import {
CHEAP_PS_ARGS,
PS_ARGS,
PS_MAX_BUFFER_BYTES,
parseCheapProcessTableRows,
ProcessTableCaptureError
} from './process-table-snapshot'
function installPs(stdout: string, outputTruncated = false): string[][] {
const calls: string[][] = []
runProcessMock.mockImplementation(async (spec: { program: string; args: readonly string[] }) => {
calls.push([spec.program, ...spec.args])
return { code: 0, signal: null, stdout, stderr: '', timedOut: false, outputTruncated }
})
return calls
}
describe('parseCheapProcessTableRows', () => {
it('parses the macOS column set with a padded lstart marker', () => {
const rows = parseCheapProcessTableRows(
[
' 1 0 1 0 Ss Tue Sep 1 01:49:39 2026',
' 4242 4200 4242 4243 S Thu Sep 3 16:02:01 2026',
' 4243 4242 4243 4243 S+ Thu Sep 3 16:02:05 2026',
''
].join('\n')
)
expect(rows).toEqual([
{ pid: 1, ppid: 0, pgid: 1, tpgid: 0, stat: 'Ss', startTime: 'Tue Sep 1 01:49:39 2026' },
{
pid: 4242,
ppid: 4200,
pgid: 4242,
tpgid: 4243,
stat: 'S',
startTime: 'Thu Sep 3 16:02:01 2026'
},
{
pid: 4243,
ppid: 4242,
pgid: 4243,
tpgid: 4243,
stat: 'S+',
startTime: 'Thu Sep 3 16:02:05 2026'
}
])
})
it('parses the Linux column set, which carries no start marker', () => {
const rows = parseCheapProcessTableRows(
' 2 0 0 -1 S\r\n 900 1 900 900 Ss+\r\n'
)
expect(rows).toEqual([
{ pid: 2, ppid: 0, pgid: 0, tpgid: -1, stat: 'S' },
{ pid: 900, ppid: 1, pgid: 900, tpgid: 900, stat: 'Ss+' }
])
})
it('skips malformed rows rather than failing the capture', () => {
expect(parseCheapProcessTableRows('garbage\n 7 1 7 7 S\n')).toEqual([
{ pid: 7, ppid: 1, pgid: 7, tpgid: 7, stat: 'S' }
])
})
it('treats an empty capture as unreadable, never as "no processes"', () => {
expect(() => parseCheapProcessTableRows('\n\n')).toThrow(ProcessTableCaptureError)
})
})
describe('getCheapProcessTableSnapshot', () => {
beforeEach(() => {
runProcessMock.mockReset()
resetCheapProcessTableSnapshotForTests()
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(0)
})
afterEach(() => {
vi.useRealTimers()
})
it('forks ps with the cheap column set only, never tty or command', async () => {
const calls = installPs(' 7 1 7 7 S\n')
await getCheapProcessTableSnapshot()
expect(calls).toEqual([['ps', ...CHEAP_PS_ARGS]])
expect(CHEAP_PS_ARGS.join(' ')).not.toMatch(/tty=|command=|etimes=/)
expect(CHEAP_PS_ARGS).not.toEqual(PS_ARGS)
})
it('coalesces concurrent readers onto one fork and honours the TTL', async () => {
const calls = installPs(' 7 1 7 7 S\n')
await Promise.all([getCheapProcessTableSnapshot(), getCheapProcessTableSnapshot()])
await getCheapProcessTableSnapshot()
expect(calls).toHaveLength(1)
vi.setSystemTime(600)
await getCheapProcessTableSnapshot()
expect(calls).toHaveLength(2)
})
it('passes the full-tier buffer ceiling and timeout to the runner', async () => {
installPs(' 7 1 7 7 S\n')
await getCheapProcessTableSnapshot()
expect(runProcessMock).toHaveBeenCalledWith(
expect.objectContaining({ maxOutputBytes: PS_MAX_BUFFER_BYTES, timeoutMs: PS_TIMEOUT_MS })
)
})
it('names a clipped capture as truncated, a killed one as a timeout, and a non-zero exit by its code', async () => {
installPs(' 7 1 7 7 S\n', true)
await expect(getCheapProcessTableSnapshot()).rejects.toMatchObject({
reason: 'capture_truncated'
})
resetCheapProcessTableSnapshotForTests()
runProcessMock.mockResolvedValueOnce({
code: null,
signal: 'SIGKILL',
stdout: '',
stderr: '',
timedOut: true
})
await expect(getCheapProcessTableSnapshot()).rejects.toMatchObject({
reason: 'capture_timeout'
})
resetCheapProcessTableSnapshotForTests()
runProcessMock.mockResolvedValueOnce({
code: 1,
signal: null,
stdout: '',
stderr: 'ps: bad column',
timedOut: false
})
await expect(getCheapProcessTableSnapshot()).rejects.toMatchObject({ reason: 'ps_exit_1' })
})
})