mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 09:07:40 +00:00
* 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)
140 lines
4.5 KiB
TypeScript
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
|
|
}
|