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:
parent
8348443719
commit
27ba95cf31
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' }])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue