perf(runtime): cache parsed ps rows on POSIX so panes share one parse (#7518)

getProcessTableSnapshot deduped the ps fork (#6288/#6667) but cached only the
raw stdout string on POSIX, so every concurrent agent pane re-ran parsePsRows
over the identical output within each 500ms TTL window — O(M*P) redundant
tokenization + row allocation. The Windows reader already caches parsed rows;
this makes the POSIX default reader do the same by parsing inside the deduped
scan and returning ProcessTableRow[]. Collapses the duplicate parsePsRows in
the main and relay foreground resolvers into one shared parseProcessTableRows.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil 2026-07-06 00:03:54 -07:00 committed by GitHub
parent 8348443719
commit 27ba95cf31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 99 additions and 69 deletions

View File

@ -1,5 +1,5 @@
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import { getProcessTableSnapshot } from '../../shared/process-table-snapshot'
import { getProcessTableSnapshot, type ProcessTableRow } from '../../shared/process-table-snapshot'
import {
resolveWindowsAgentForegroundProcess,
shouldInspectWindowsAgentForeground,
@ -8,30 +8,6 @@ import {
export type { AgentForegroundResolutionOptions } from './windows-agent-foreground-process'
type ProcessRow = {
pid: number
ppid: number
stat: string
command: string
}
function parsePsRows(stdout: string): ProcessRow[] {
const rows: ProcessRow[] = []
for (const line of stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
stat: match[3],
command: match[4]
})
}
return rows
}
function collectDescendants<Row extends { pid: number; ppid: number }>(
rows: Row[],
rootPid: number
@ -55,7 +31,7 @@ function collectDescendants<Row extends { pid: number; ppid: number }>(
return descendants
}
function candidateScore(row: ProcessRow & { depth: number }): number {
function candidateScore(row: ProcessTableRow & { depth: number }): number {
// Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
// them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
// parent shell but still lets the native child confirm the same identity.
@ -82,8 +58,8 @@ export async function resolveAgentForegroundProcess(
}
try {
const stdout = await getProcessTableSnapshot()
return resolveAgentForegroundProcessFromPs(stdout, shellPid) ?? fallbackProcess
const rows = await getProcessTableSnapshot()
return resolveAgentForegroundProcessFromPs(rows, shellPid) ?? fallbackProcess
} catch {
// Fall through to node-pty's process name. Foreground process inspection is
// best-effort because terminal identity should never break PTY operation.
@ -92,8 +68,10 @@ export async function resolveAgentForegroundProcess(
return fallbackProcess
}
function resolveAgentForegroundProcessFromPs(stdout: string, shellPid: number): string | null {
const rows = parsePsRows(stdout)
function resolveAgentForegroundProcessFromPs(
rows: ProcessTableRow[],
shellPid: number
): string | null {
const shellRow = rows.find((row) => row.pid === shellPid)
const candidates = collectDescendants(rows, shellPid).sort(
(a, b) => candidateScore(b) - candidateScore(a)

View File

@ -10,7 +10,7 @@ import {
recognizeAgentProcessFromCommandLine
} from '../shared/agent-process-recognition'
import { getFirstCommandToken } from '../shared/command-token-scanner'
import { getProcessTableSnapshot } from '../shared/process-table-snapshot'
import { getProcessTableSnapshot, type ProcessTableRow } from '../shared/process-table-snapshot'
import { isShellProcess } from '../shared/shell-process-detection'
import {
resolveWindowsAgentForegroundProcess,
@ -19,13 +19,6 @@ import {
const execFile = promisify(execFileCb)
type ProcessRow = {
pid: number
ppid: number
stat: string
command: string
}
export function resolveWindowsDefaultShell(
env: NodeJS.ProcessEnv = process.env,
existsPath: (path: string) => boolean = existsSync
@ -154,35 +147,18 @@ export async function processHasChildren(pid: number): Promise<boolean> {
}
}
function parsePsRows(stdout: string): ProcessRow[] {
const rows: ProcessRow[] = []
for (const line of stdout.split(/\r?\n/)) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
stat: match[3],
command: match[4]
})
}
return rows
}
function collectDescendants(
rows: ProcessRow[],
rows: ProcessTableRow[],
rootPid: number
): (ProcessRow & { depth: number })[] {
const childrenByParent = new Map<number, ProcessRow[]>()
): (ProcessTableRow & { depth: number })[] {
const childrenByParent = new Map<number, ProcessTableRow[]>()
for (const row of rows) {
const children = childrenByParent.get(row.ppid) ?? []
children.push(row)
childrenByParent.set(row.ppid, children)
}
const descendants: (ProcessRow & { depth: number })[] = []
const descendants: (ProcessTableRow & { depth: number })[] = []
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
while (stack.length > 0) {
const { row, depth } = stack.pop()!
@ -194,7 +170,7 @@ function collectDescendants(
return descendants
}
function candidateScore(row: ProcessRow & { depth: number }): number {
function candidateScore(row: ProcessTableRow & { depth: number }): number {
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
}
@ -202,7 +178,10 @@ function processCommandToken(command: string): string {
return getFirstCommandToken(command)
}
function candidateMatchesFallbackWrapper(candidate: ProcessRow, fallbackProcess: string): boolean {
function candidateMatchesFallbackWrapper(
candidate: ProcessTableRow,
fallbackProcess: string
): boolean {
return isExpectedAgentProcess(processCommandToken(candidate.command), fallbackProcess)
}
@ -211,8 +190,7 @@ async function getRecognizedForegroundDescendant(
fallbackProcess?: string | null
): Promise<string | null> {
try {
const stdout = await getProcessTableSnapshot()
const rows = parsePsRows(stdout)
const rows = await getProcessTableSnapshot()
const root = rows.find((row) => row.pid === pid)
const candidates = collectDescendants(rows, pid).sort(
(a, b) => candidateScore(b) - candidateScore(a)

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { createProcessTableSnapshotReader } from './process-table-snapshot'
import { createProcessTableSnapshotReader, parseProcessTableRows } from './process-table-snapshot'
function deferred<T>(): {
promise: Promise<T>
@ -120,4 +120,46 @@ describe('process-table-snapshot reader', () => {
expect(await reader.getSnapshot()).toBe('recovered')
expect(scans).toBe(2)
})
it('shares one parsed-rows array across a burst so panes do not each re-parse', async () => {
// Mirrors the POSIX default reader: runPs parses inside the deduped scan, so
// every caller in the TTL window gets the SAME ProcessTableRow[] instance
// instead of re-tokenizing identical stdout per pane.
let parses = 0
const gate = deferred<ReturnType<typeof parseProcessTableRows>>()
const reader = createProcessTableSnapshotReader<ReturnType<typeof parseProcessTableRows>>({
runPs: () => {
parses += 1
return gate.promise
},
now: () => 0
})
const a = reader.getSnapshot()
const b = reader.getSnapshot()
gate.resolve(parseProcessTableRows('100 1 Ss+ /bin/zsh'))
const rowsA = await a
const rowsB = await b
expect(parses).toBe(1)
// Reference identity: the burst reuses one parse, not one-per-caller.
expect(rowsA).toBe(rowsB)
})
})
describe('parseProcessTableRows', () => {
it('parses pid/ppid/stat and keeps the full command (including spaces)', () => {
const rows = parseProcessTableRows(
['501 1 S /bin/zsh', '600 501 S+ node /path/bin/codex --flag'].join('\n')
)
expect(rows).toEqual([
{ pid: 501, ppid: 1, stat: 'S', command: '/bin/zsh' },
{ pid: 600, ppid: 501, stat: 'S+', command: 'node /path/bin/codex --flag' }
])
})
it('tolerates CRLF and skips header/blank/non-matching lines', () => {
const rows = parseProcessTableRows(' PID PPID STAT COMMAND\r\n42 1 Ss /sbin/launchd\r\n\r\n')
expect(rows).toEqual([{ pid: 42, ppid: 1, stat: 'Ss', command: '/sbin/launchd' }])
})
})

View File

@ -21,6 +21,35 @@ const PS_TIMEOUT_MS = 3000
// identically to a fresh fork.
const DEFAULT_SNAPSHOT_TTL_MS = 500
export type ProcessTableRow = {
pid: number
ppid: number
stat: string
command: string
}
/**
* Parse `ps -axo pid=,ppid=,stat=,command=` output into rows. Tolerates CRLF so
* a snapshot parsed on any host stays correct; `command` (last field) keeps its
* internal spaces because the regex is anchored and greedy on the tail.
*/
export function parseProcessTableRows(stdout: string): ProcessTableRow[] {
const rows: ProcessTableRow[] = []
for (const line of stdout.split(/\r?\n/)) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
stat: match[3],
command: match[4]
})
}
return rows
}
type Snapshot<T> = { value: T; capturedAtMs: number }
type ProcessTableSnapshotReaderDeps<T> = {
@ -83,23 +112,26 @@ export function createProcessTableSnapshotReader<T = string>(
}
}
const defaultReader = createProcessTableSnapshotReader({
const defaultReader = createProcessTableSnapshotReader<ProcessTableRow[]>({
runPs: async () => {
const { stdout } = await execFile('ps', [...PS_ARGS], {
encoding: 'utf-8',
timeout: PS_TIMEOUT_MS
})
return stdout
// Why: parse once inside the deduped scan so a burst of panes sharing the
// TTL window reuse one ProcessTableRow[] instead of each re-tokenizing the
// identical stdout — matches the Windows reader, which already caches rows.
return parseProcessTableRows(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.
* its parsed rows. Per-process singleton: the relay and local main processes
* each dedupe their own scans and share a single parse per TTL window.
*/
export function getProcessTableSnapshot(): Promise<string> {
export function getProcessTableSnapshot(): Promise<ProcessTableRow[]> {
return defaultReader.getSnapshot()
}