Files
orca/src/shared/libproc-ps-format.ts
T
Neil 525719789f perf(terminal): read the macOS process table in process instead of forking setuid ps (#35987)
* perf(terminal): read the macOS process table in process instead of forking setuid ps (#31783)

/bin/ps is setuid root, so every exec costs an uncached syspolicyd
assessment and an EDR inspection. The terminal host forked ~7-10 ps/lsof
per second with 4 agent panes, and each stale daemon multiplied that.

node-pty's patched pty.node now exports processTable (sysctl
KERN_PROC_ALL/KERN_PROCARGS2 on a worker thread), processCwd
(PROC_PIDVNODEPATHINFO) and processPath (proc_pidpath). runPsInProcess
renders the same ps column text for the invocations Orca issues, so the
existing parsers and callers are unchanged; anything it cannot reproduce,
non-macOS hosts, and builds without the exports keep running ps/lsof.

Measured with 4 anchored panes polled every 750 ms for 60 s: ~440
ps/lsof children before, 0 after.

* fix(daemon): roll the in-process macOS process table into a fresh v54 daemon (#31783)

* fix(terminal): bound, escape and complete the in-process macOS process table like ps (#31783)

Review follow-ups for the libproc reader:

- Timeouts: every caller passes its ps timeout; a read stalled behind a
  saturated libuv pool resolves timed out (code null, timedOut) at that
  budget, exactly as the killed ps would. Identical native reads share one
  worker, each caller racing its own deadline; past 8 distinct reads in
  flight callers run ps. execFile-shaped callers reject like a failed child.
- tty naming shares one slot with captureDarwinProcessTable's guard, so a
  draining naming pass sends both paths to ps instead of naming again.
- Commands: the addon renders argv with adv_cmds' getproclline and strnvis
  (VIS_TAB|VIS_NL|VIS_NOSLASH) under the caller's LC_CTYPE via a
  thread-scoped uselocale, so newlines print as \012 instead of splitting
  tokens; zombies print <defunct>.
- Other users' processes: unreadable argv is marked commandIncomplete. Only
  -p selections and processes on a terminal under the user's own readable
  process there (sudo in a pane) get a targeted `ps -p ... -o pid=,command=`,
  cached by (locale, pid, start time); root daemons and a pane's root login
  never spawn ps.
- -t selects with KERN_PROC_TTY after ps's addelem_tty path resolution, so
  per-terminal reads skip argv for every other process (~0.3 ms vs 4 ms ps).
- -p deduplicates; rows sort by terminal then pid as pscomp(); stat adds
  ps's < N X V L modifiers. The export is renamed readProcessTable so an
  older pty.node falls back to ps rather than misreading the new query.

* test(terminal): diff the real libproc addon against /bin/ps on macOS (#31783)

Spawns a pty holder whose argv carries a newline, tab, invalid UTF-8 and é,
then compares Orca's ps invocations column by column: pid/ppid/pgid/tpgid/
lstart/tdev/tty exactly, commands for the user's own processes, and stat
modulo running/idle (thread state needs setuid ps). Rows are compared only
when two in-process reads around the ps run agree, so churn cannot flake it.

* fix(terminal): never serve a native process table read begun before the request (#31783)

A timed-out read stayed shareable, so a later descendant capture could join
an earlier poll's abandoned worker and take its rows as fresh; a descendant
that exited meanwhile could have its reused pid SIGTERMed. The addon now
reports when its worker actually began reading (startedAtMs), and
runPsInProcess refuses a shared read that began before its own request and
reads again, so every consumer that signals from rows (descendant
teardown, foreground-group signalling, identity probes) only sees rows
scanned after its stamp. Regression: pty-descendant-termination-libproc.

Also from review:
- argv recovered with setuid ps is keyed by pid, start time to the
  microsecond, comm and executable path (exec changes the latter two) with
  a 10 min TTL and 512-entry bound.
- Recovery covers a sudo use_pty tree: the unreadable chain must start on
  the terminal of the user's own readable process, so the root shell and
  agent on sudo's own pty qualify while root daemons, GUI helpers and a
  pane's root login (own pty, under a terminal-less or tty-inheriting host)
  do not.
- Concurrent tty= readers wait out the single naming pass, and readers past
  the 8-read cap queue behind one, within their deadlines, instead of
  bursting /bin/ps at startup restore (24 panes polled at once: 0 ps).
- darwin-terminal-naming-slot.ts is now darwin-terminal-naming-single-flight.ts.

* test(terminal): require /bin/ps row order in the libproc parity test (#31783)

Parsers that take the first matching row depend on ps's terminal-then-pid
order, so compare the order of the rows both captures share, not just their
contents.

* test(terminal): pin libproc read start times instead of reading the wall clock (#31783)

* fix(terminal): match native process table reads to requests by sequence, not wall clock (#31783)

Freshness compared the worker's gettimeofday with Date.now(), so a clock
step back let an abandoned read's rows through (wrong-pid SIGTERM) and a
step forward could reject fresh reads until the deadline. Each read now
takes a JS-side sequence number when created and is submitted a microtask
later; a caller joins only a read not yet submitted or created after it
registered, so callers in one turn share a worker and nothing submitted
before a request serves it. The addon drops startedAtMs again. Deadlines,
timeouts and the argv cache use performance.now().

Callers past the 8-read cap wait on one slot notifier and each joined read
has one settle notifier; both unsubscribe on timeout, so a stalled pool
retains no per-caller reactions. If the pool is still at the cap when an
overflow caller's deadline passes, the reader is marked stalled for 30s
and callers run ps, as before this branch.

Recovered argv is trusted for 30s: exec can keep pid, start time, comm and
executable (node -> node with another script) and no cheap field shows it,
so a sudo pane costs one targeted ps per process per 30s.

* fix(terminal): keep ps fallbacks off a stuck terminal naming pass (#31783)

- captureDarwinProcessTable's canonical fallback, taken while the naming
  slot is busy, now runs /bin/ps directly instead of re-entering the
  in-process reader and waiting on that same slot (a held lstat timed out
  every strict capture without one subprocess fallback).
- In-process tty= readers wait out a short pass (startup restore) but hand
  a pass older than 500ms to ps, which names terminals out of process.
- The naming single flight keeps one settle notifier per pass; waiters sit
  in a Set and drop off on timeout, so timed-out callers leave no reactions
  on a stuck pass (was one per call). The notifier moves to
  deadline-settle-notifier.ts, shared with the native read scheduler.
- Once the native read breaker has tripped, a caller finding the cap still
  held after the cool-down returns to ps at once instead of waiting out its
  timeout.
- Descendant teardown and foreground-group signalling give the native read
  half their budget and fall back to a fresh ps on a native timeout, so a
  partly stalled pool cannot cost a kill.

* fix(terminal): give signalling ps fallbacks their full baseline timeout (#31783)

Splitting the budget in half shrank ps's window behind a stalled native
read: a 750ms ps lost descendant SIGTERMs and an 80ms foreground ps lost
group SIGWINCH. Descendant teardown and foreground-group signalling now try
the native reader for min(150ms, timeout/4) (inProcessSliceMs), then give
ps the whole original timeout; readProcessTableBeforeDeadline extends the
production reader's outer deadline by that slice, so ps succeeds exactly
when it did on main. The stale-read regression test now also asserts the
fresh rows' real descendant is signalled.

* fix(terminal): carry the in-process deadline allowance through every descendant reader (#31783)

readProcessTableBeforeDeadline granted the native slice only when the
reader was readProcessTable by identity, so the daemon shutdown sweep's
sharing wrapper kept a bare 250ms deadline: a stalled native read plus a
200ms ps missed it on every retry and descendants survived shutdown.
ProcessTableReader now declares deadlineAllowanceMs; the fresh reader sets
it (0 without the addon or with a stalled pool, so Linux and ps-only hosts
keep their exact deadlines), createProcessTableSnapshotReader and the
shutdown wrapper inherit it, and injected readers default to none. The
fresh reader moves to pty-descendant-fresh-table.ts. The real-ps parity
test gets a 30s per-case timeout, since whole-table /bin/ps runs slow
under suite load.

* chore(terminal): move the descendant reader's child_process baseline entry with it (#31783)

* test(terminal): pin the libproc test locale so lstart doesn't follow the runner's LANG (#31783)
2026-10-29 09:51:53 -07:00

140 lines
4.5 KiB
TypeScript

import type { LibprocProcessRow } from './libproc-addon'
const SIDL = 1
const SSTOP = 4
const SZOMB = 5
const P_CONTROLT = 0x2
const P_PPWAIT = 0x10
const P_SYSTEM = 0x200
const P_TRACED = 0x800
const P_WEXIT = 0x2000
export const NODEV = -1
/** ps's state letters, except running vs sleeping: that needs another task's thread states (ps is setuid for it), so live processes read `S`. */
export function formatLibprocStat(row: LibprocProcessRow): string {
const state =
row.status === SZOMB ? 'Z' : row.status === SSTOP ? 'T' : row.status === SIDL ? 'R' : 'S'
const nice = row.nice ?? 0
// adv_cmds ps/print.c state().
return (
state +
(nice < 0 ? '<' : nice > 0 ? 'N' : '') +
(row.flags & P_TRACED ? 'X' : '') +
(row.flags & P_WEXIT && row.status !== SZOMB ? 'E' : '') +
(row.flags & P_PPWAIT ? 'V' : '') +
(row.flags & P_SYSTEM ? 'L' : '') +
(row.sessionLeader ? 's' : '') +
(row.flags & P_CONTROLT && row.pgid === row.tpgid ? '+' : '')
)
}
export function formatDarwinDevice(tdev: number): string | null {
return tdev === NODEV ? null : `${(tdev >>> 24) & 0xff}/${tdev & 0xffffff}`
}
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
/** ps `lstart` (`%c` in the C and en_US locales): `Thu Sep 3 16:02:01 2026`. */
export function formatLstart(startSec: number, utc: boolean): string {
const date = new Date(startSec * 1000)
const [weekday, month, day, hours, minutes, seconds, year] = utc
? [
date.getUTCDay(),
date.getUTCMonth(),
date.getUTCDate(),
date.getUTCHours(),
date.getUTCMinutes(),
date.getUTCSeconds(),
date.getUTCFullYear()
]
: [
date.getDay(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes(),
date.getSeconds(),
date.getFullYear()
]
const two = (value: number): string => String(value).padStart(2, '0')
return `${WEEKDAYS[weekday]} ${MONTHS[month]} ${String(day).padStart(2, ' ')} ${two(hours)}:${two(minutes)}:${two(seconds)} ${year}`
}
const COLUMN_ALIASES: Readonly<Record<string, Column>> = {
pid: 'pid',
ppid: 'ppid',
pgid: 'pgid',
tpgid: 'tpgid',
stat: 'stat',
state: 'stat',
tdev: 'tdev',
tty: 'tty',
lstart: 'lstart',
command: 'command',
args: 'command'
}
type Column = 'pid' | 'ppid' | 'pgid' | 'tpgid' | 'stat' | 'tdev' | 'tty' | 'lstart' | 'command'
export type PsQuery = {
columns: Column[]
pids: number[] | null
tty: string | null
}
/** The `ps` invocations Orca issues (`-axo`, `-p`, `-t`, empty headers); anything else returns null and keeps the real ps. */
export function parsePsQuery(args: readonly string[]): PsQuery | null {
const columns: Column[] = []
let all = false
let pids: number[] | null = null
let tty: string | null = null
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
const value = (): string | undefined => args[++index]
if (arg === '-axo' || arg === '-o') {
all ||= arg === '-axo'
const spec = value()
if (spec === undefined) {
return null
}
for (const field of spec.split(',')) {
const match = /^([a-z]+)=$/.exec(field)
const column = match ? COLUMN_ALIASES[match[1]] : undefined
if (!column) {
return null
}
columns.push(column)
}
} else if (arg === '-ax' || arg === '-A') {
all = true
} else if (arg === '-ww') {
continue
} else if (arg === '-p') {
const list = value()?.split(',').map(Number)
if (!list || list.some((pid) => !Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff)) {
return null
}
// ps keeps each matching process once however often -p names it.
pids = [...new Set([...(pids ?? []), ...list])]
} else if (arg === '-t') {
const name = value()
// ps -t also takes lists; Orca names one terminal.
if (!name || tty !== null || /[\s,]/.test(name)) {
return null
}
tty = name
} else {
return null
}
}
// ps unions selections; Orca never combines them, so keep the real ps for that.
const selections = Number(all) + Number(pids !== null) + Number(tty !== null)
return columns.length > 0 && selections === 1 ? { columns, pids, tty } : null
}
/** ps's pscomp(): terminal, then pid (int subtraction, as C wraps it). */
export function compareLikePs(a: LibprocProcessRow, b: LibprocProcessRow): number {
return (a.tdev - b.tdev) | 0 || a.pid - b.pid
}