diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0d01e..c2ce7fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +### One reader for the process table + +- Process facts now come from one module. On Linux it reads `/proc` and spawns + nothing at all. On macOS `ps` remains, but it is read once per operation + rather than once per process per poll — the difference between 240 spawns + inside a 1500 ms deadline and 60. +- Every query separates three answers: the fact, "the table was read and this + process is not in it", and "I could not find out". A `ps` that is slow, + truncated or silent now produces the third rather than the second. There is + no default and no conversion that turns silence into absence by accident. +- A listing that does not contain the process that read it is treated as + truncated rather than as an empty machine. `ps` always lists at least itself. +- `recovery.processStartToken` is unchanged and still comes from + `ps -o lstart=`. Its exact text, including the two spaces before a + single-digit day, is a contract with the Rust tool through a shared registry. + The in-memory identity used by the teardown is a separate branded type so the + two cannot be compared by accident. +- Production `ps` call sites: six down to three, and none of them inside a + per-process poll loop. + ### `pty kill` finishes the job - After the daemon has gone, `pty kill` re-reads the process table. If anything diff --git a/src/cli.ts b/src/cli.ts index 50f91a6..03b194b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,9 +49,9 @@ import { import { snapshotDescendantProcesses } from "./process-tree.ts"; import { aftermathOf, allGone, killOutcomeLines, verifiedEmpty } from "./kill-report.ts"; import { - groupsInTree, listProcessesWithGroups, membersOfGroups, ownProcessGroup, - parseRows, signalGroup, sweepGroups, + groupsInTree, membersOfGroups, ownProcessGroup, signalGroup, sweepGroups, } from "./process-groups.ts"; +import { openSource, valueOf } from "./proc-table.ts"; import { spawnDaemon, resolveCommand } from "./spawn.ts"; import { acquireEventLock, appendEventSyncLocked, EventFollower, EventWriter, EventType, releaseEventLock, @@ -2641,7 +2641,7 @@ const ESCALATE_KILL_WAIT_MS = 1_000; * table. Returns the pids still alive in those groups. */ async function escalateOverGroups(groups: number[]): Promise { return sweepGroups(groups, ownProcessGroup(), ESCALATE_TERM_WAIT_MS, ESCALATE_KILL_WAIT_MS, { - live: (targets) => membersOfGroups(targets, parseRows(listProcessesWithGroups())), + live: (targets) => membersOfGroups(targets, openSource()), signal: signalGroup, sleep: (ms) => new Promise((r) => setTimeout(r, ms)), }); @@ -2677,7 +2677,7 @@ async function cmdKill(name: string): Promise { // Groups come from the raw listing, NOT from `before`. The snapshot drops a // descendant whose start token cannot be read, and that process is then never // signalled. A group needs no identity, so this reaches it anyway. - const groups = groupsInTree(session.pid, parseRows(listProcessesWithGroups())); + const groups = groupsInTree(session.pid, openSource()); try { process.kill(session.pid, "SIGTERM"); @@ -2710,7 +2710,12 @@ async function cmdKill(name: string): Promise { escalated = await escalateOverGroups(groups); // Re-measure. The report must describe the machine now, not the signals // that were sent at it. - after = aftermathOf(before, readProcessStartToken, hasProcessExitedForReap); + const again = openSource(); + after = aftermathOf( + before, + (pid) => valueOf(again.identity(pid)), + hasProcessExitedForReap, + ); } const outcome = killOutcomeLines(name, after, escalated); for (const line of outcome.out) console.log(line); diff --git a/src/kill-report.ts b/src/kill-report.ts index c5bf3d5..39253cb 100644 --- a/src/kill-report.ts +++ b/src/kill-report.ts @@ -53,14 +53,14 @@ export function verifiedEmpty(after: Aftermath, escalated?: number[]): boolean { */ export function aftermathOf( before: ProcessIdentity[], - readStartToken: (pid: number) => string | null, + readIdentity: (pid: number) => string | null, exited: (pid: number) => boolean, ): Aftermath { const after: Aftermath = { survived: [], unknown: [] }; for (const identity of before) { if (exited(identity.pid)) continue; - const token = readStartToken(identity.pid); - if (token === identity.processStartToken) after.survived.push(identity.pid); + const token = readIdentity(identity.pid); + if (token === identity.identity) after.survived.push(identity.pid); // A different token is a PID the kernel handed to somebody else. else if (token === null) after.unknown.push(identity.pid); } diff --git a/src/proc-table.ts b/src/proc-table.ts new file mode 100644 index 0000000..56da86f --- /dev/null +++ b/src/proc-table.ts @@ -0,0 +1,349 @@ +/** One reader for the process table. + * + * Three wrong answers came from the same shape: a caller ran its own `ps` and + * treated whatever came back as fact. `ps` is a subprocess. It can be slow, + * truncated, or silent, and each of those looked exactly like "the process is + * gone". + * + * Node cannot make the syscalls the Rust tool uses, so this does what Node can: + * + * **On Linux there is no subprocess at all.** `/proc` carries ppid, pgid, state + * and starttime, which is every fact the callers ask for. + * + * **On macOS `ps` stays, but it is read once per operation** rather than once + * per process per poll. That is the difference between 240 spawns inside a + * 1500 ms deadline and 60. + * + * **And silence is its own answer everywhere.** Every query returns an + * {@link Answer}, which separates "the table says this process is not there" + * from "I could not find out". There is deliberately no default and no + * conversion that lets the second quietly become the first. + */ + +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; + +/** How long `ps` gets before the table is declared unreadable. Under + * contention `ps` is exactly the thing that goes quiet. */ +const PS_TIMEOUT_MS = 2_000; + +/** Why a fact is not available. None of these mean the process is gone. */ +export type Unknown = + /** The table could not be read: `ps` failed, timed out, returned nothing, or + * returned something that did not contain this very process. */ + | "table-unreadable" + /** The table has the process, but this column was empty. */ + | "field-empty"; + +/** What the process table said about one thing. + * + * **`unknown` is not `not-present`.** Folding them together is the defect this + * type exists to prevent, so there is no default and no direct unwrap. A + * caller that genuinely wants silence to mean death calls + * {@link orAbsentWhenUnknown}, which is named so it shows up in a review and + * in a grep. */ +export type Answer = + | { readonly kind: "known"; readonly value: T } + | { readonly kind: "not-present" } + | { readonly kind: "unknown"; readonly reason: Unknown }; + +export const known = (value: T): Answer => ({ kind: "known", value }); +export const notPresent = (): Answer => ({ kind: "not-present" }); +export const unknown = (reason: Unknown): Answer => ({ kind: "unknown", reason }); + +/** The value if the table knew it. Silence and absence both yield null, so + * this is for callers that have decided the difference does not matter. */ +export function valueOf(a: Answer): T | null { + return a.kind === "known" ? a.value : null; +} + +/** Is the process definitely gone? Only `not-present` says so. An unreadable + * table never does. */ +export function isDefinitelyAbsent(a: Answer): boolean { + return a.kind === "not-present"; +} + +/** Deliberately treat silence as absence. Sometimes that is right. It is never + * the right default, which is why it has a long name. */ +export function orAbsentWhenUnknown(a: Answer): Answer { + return a.kind === "unknown" ? notPresent() : a; +} + +/** A process identity that is only ever compared with another one taken from + * the same run. + * + * **This is deliberately not the same type as the registry's + * `recovery.processStartToken`, and it must never be compared with it.** That + * token is written into session metadata, read by the Rust tool from the same + * registry, and its exact text — including the two spaces `ps` puts before a + * single-digit day — is a contract between the two. This one is private to a + * single command's lifetime. The brand is what stops them meeting. */ +export type LiveIdentity = string & { readonly __liveIdentity: unique symbol }; +export const liveIdentity = (value: string): LiveIdentity => value as LiveIdentity; + +/** One process, as the table saw it. */ +export interface Row { + pid: number; + ppid: number; + pgid: number; + /** `ps` state letters or the `/proc` state character. Empty if unknown. */ + state: string; + rssKb: number | null; + cpuPercent: number | null; + identity: LiveIdentity | null; +} + +export const isZombie = (row: Row): boolean => row.state.startsWith("Z"); + +/** Where process facts come from. + * + * Two implementations, chosen by which is cheaper on the platform. On Linux a + * single-process read is a small file read, so asking per process beats + * re-reading the machine. On macOS every read is a subprocess, so one snapshot + * serves the whole iteration. */ +export interface ProcessSource { + row(pid: number): Answer; + identity(pid: number): Answer; + /** Alive and not a corpse awaiting reaping. */ + isRunning(pid: number): Answer; + /** Every row, for callers that need the whole tree. */ + rows(): Answer; +} + +const onLinux = process.platform === "linux"; + +/** Open a source for one iteration of work. + * + * On macOS this spawns `ps` once. On Linux it spawns nothing and reads + * lazily. Call it once per iteration, not once per question. */ +export function openSource(): ProcessSource { + return onLinux ? new DirectSource() : new SnapshotSource(readPsTable()); +} + +/** One process, without reading the whole table. + * + * A poll loop asking about a single pid should not pay for every process on + * the machine. On Linux this is one small file read and no subprocess. + * + * **On macOS it is still one `ps` per call, and that is Node's floor.** The + * Rust tool makes a syscall here; Node cannot without a native module. What + * this avoids is the larger whole-table listing, not the spawn. + */ +export function processOf(pid: number): Answer { + if (!Number.isSafeInteger(pid) || pid <= 0) return notPresent(); + if (onLinux) return new DirectSource().row(pid); + let out: string; + try { + out = execFileSync( + "ps", + ["-o", "pid=,ppid=,pgid=,state=,rss=,pcpu=,lstart=", "-p", String(pid)], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: PS_TIMEOUT_MS }, + ); + } catch { + // `ps` exits non-zero when the pid is not there, which is indistinguishable + // from `ps` failing. Ask the kernel, which does distinguish them. + return processExists(pid) ? unknown("table-unreadable") : notPresent(); + } + // **One parser, not two.** An earlier version handed the subprocess's raw + // stdout to the single-line parser, which rejected the trailing newline every + // `ps` prints — so a live process read as "field-empty" and a zombie read as + // NOT EXITED, which would make the teardown wait out its whole budget for a + // corpse. That is the corpse defect again, reintroduced on macOS by a second + // parsing path that Linux never exercised. Found on a real Mac by + // `Silber.pty` on 2026-09-03. + // + // `parsePsListing` splits lines first and checks that the row for the pid we + // asked about is actually there, so both jobs are done by the code that was + // already tested. + const rows = parsePsListing(out, pid); + const row = rows?.find((r) => r.pid === pid); + if (row) return known(row); + // It ran and said nothing about this pid. + return processExists(pid) ? unknown("field-empty") : notPresent(); +} + +function processExists(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return (err as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** Linux: answer each question from `/proc` as it is asked. */ +class DirectSource implements ProcessSource { + row(pid: number): Answer { + if (!Number.isSafeInteger(pid) || pid <= 0) return notPresent(); + let stat: string; + try { + stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ESRCH") return notPresent(); + // Permission denied, or /proc not mounted. We did not find out. + return unknown("table-unreadable"); + } + const row = parseProcStat(pid, stat); + return row ? known(row) : unknown("field-empty"); + } + + identity(pid: number): Answer { + return mapAnswer(this.row(pid), (r) => r.identity); + } + + isRunning(pid: number): Answer { + const r = this.row(pid); + return r.kind === "known" ? known(!isZombie(r.value)) : (r as Answer); + } + + rows(): Answer { + let names: string[]; + try { + names = fs.readdirSync("/proc"); + } catch { + return unknown("table-unreadable"); + } + const out: Row[] = []; + for (const name of names) { + if (!/^\d+$/.test(name)) continue; + const pid = Number(name); + try { + const row = parseProcStat(pid, fs.readFileSync(`/proc/${pid}/stat`, "utf8")); + if (row) out.push(row); + } catch { + // Exited between the readdir and the read. That is a real absence. + } + } + if (!out.some((r) => r.pid === process.pid)) return unknown("table-unreadable"); + return known(out); + } +} + +/** macOS and elsewhere: one `ps` listing, answered from memory. */ +class SnapshotSource implements ProcessSource { + private readonly byPid: Map | null; + + constructor(rows: Row[] | null) { + this.byPid = rows === null ? null : new Map(rows.map((r) => [r.pid, r])); + } + + row(pid: number): Answer { + if (this.byPid === null) return unknown("table-unreadable"); + const r = this.byPid.get(pid); + return r ? known(r) : notPresent(); + } + + identity(pid: number): Answer { + return mapAnswer(this.row(pid), (r) => r.identity); + } + + isRunning(pid: number): Answer { + const r = this.row(pid); + return r.kind === "known" ? known(!isZombie(r.value)) : (r as Answer); + } + + rows(): Answer { + return this.byPid === null ? unknown("table-unreadable") : known([...this.byPid.values()]); + } +} + +function mapAnswer(a: Answer, f: (v: T) => U | null): Answer { + if (a.kind !== "known") return a as Answer; + const v = f(a.value); + return v === null ? unknown("field-empty") : known(v); +} + +/** `ps -axo pid=,ppid=,pgid=,state=,rss=,pcpu=,lstart=`, or null if the table + * could not be read. + * + * **An empty or self-omitting listing is an unreadable table, not an empty + * machine.** `ps` always lists at least the process that ran it, so a listing + * without our own pid was truncated or never produced. That one comparison is + * what turns a silent `ps` into "unknown" instead of "everything is dead". */ +export function parsePsListing(listing: string, mustContain = process.pid): Row[] | null { + const rows: Row[] = []; + for (const line of listing.split("\n")) { + const row = parsePsRow(line); + if (row) rows.push(row); + } + return rows.some((r) => r.pid === mustContain) ? rows : null; +} + +function readPsTable(): Row[] | null { + let listing: string; + try { + listing = execFileSync("ps", ["-axo", "pid=,ppid=,pgid=,state=,rss=,pcpu=,lstart="], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: PS_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + }); + } catch { + return null; + } + return parsePsListing(listing); +} + +function parsePsRow(line: string): Row | null { + // The tail is taken as raw text rather than re-joined from split fields. + // `ps -o lstart=` pads a single-digit day with two spaces, and re-joining + // would quietly rewrite `Wed Sep 3` as `Wed Sep 3`. + const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)(?:\s+(\S+))?(?:\s+(\S+))?(?:\s+(\S+))?(?:\s+(.*))?$/); + if (!m) return null; + const lstart = (m[7] ?? "").trim(); + return { + pid: Number(m[1]), + ppid: Number(m[2]), + pgid: Number(m[3]), + state: m[4] ?? "", + rssKb: m[5] !== undefined && /^\d+$/.test(m[5]) ? Number(m[5]) : null, + cpuPercent: m[6] !== undefined && !Number.isNaN(Number(m[6])) ? Number(m[6]) : null, + identity: lstart ? liveIdentity(`darwin:${lstart}`) : null, + }; +} + +/** `/proc//stat`. Field 2 is the comm in parentheses and may contain + * spaces and brackets, so everything is read relative to the LAST `)`. */ +export function parseProcStat(pid: number, stat: string): Row | null { + const close = stat.lastIndexOf(")"); + if (close < 0) return null; + const f = stat.slice(close + 1).trim().split(/\s+/); + // f[0] is field 3 (state), so field N is f[N - 3]. + if (f.length < 20) return null; + const ppid = Number(f[1]); + const pgid = Number(f[2]); + const startTime = f[19]; + if (!Number.isSafeInteger(ppid) || !Number.isSafeInteger(pgid) || !startTime) return null; + return { + pid, + ppid, + pgid, + state: f[0] ?? "", + rssKb: null, + cpuPercent: null, + identity: liveIdentity(`linux:${startTime}`), + }; +} + +/** A source built from `pid ppid pgid [state] [identity]` lines, for tests that + * care about tree shape rather than about reading a real machine. A literal + * `-` in the identity column means the table had the process but could not + * name it. */ +export function sourceFromShape(spec: string): ProcessSource { + const rows: Row[] = []; + for (const line of spec.split("\n")) { + const f = line.trim().split(/\s+/).filter(Boolean); + if (f.length < 3) continue; + rows.push({ + pid: Number(f[0]), + ppid: Number(f[1]), + pgid: Number(f[2]), + state: f[3] ?? "S", + rssKb: null, + cpuPercent: null, + identity: f[4] === "-" ? null : liveIdentity(f[4] ?? `tok:${f[0]}`), + }); + } + return new SnapshotSource(rows); +} diff --git a/src/process-groups.ts b/src/process-groups.ts index 152285f..4ef2527 100644 --- a/src/process-groups.ts +++ b/src/process-groups.ts @@ -13,44 +13,17 @@ import { execFileSync } from "node:child_process"; -/** One row of `ps -axo pid=,ppid=,pgid=,stat=`. */ -export interface ProcessRow { - pid: number; - ppid: number; - pgid: number; - /** Process state. `ps` lists a zombie with its process group, so without - * this the sweep counts a corpse as a member and reports a group it has - * already emptied. Measured on Linux 2026-09-03: ` Z`. */ - state: string; -} - -export function isZombie(row: ProcessRow): boolean { - return row.state.startsWith("Z"); -} - -export function listProcessesWithGroups(): string { - try { - return execFileSync("ps", ["-axo", "pid=,ppid=,pgid=,stat="], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 2_000, - }); - } catch { - return ""; - } -} +import { + isZombie, + openSource, + valueOf, + type ProcessSource, + type Row, +} from "./proc-table.ts"; +import { walkTree } from "./process-tree.ts"; -export function parseRows(listing: string): ProcessRow[] { - const rows: ProcessRow[] = []; - for (const line of listing.split("\n")) { - const fields = line.trim().split(/\s+/); - if (fields.length < 3 || fields.length > 4) continue; - const [pid, ppid, pgid] = fields.map(Number); - if (!Number.isInteger(pid) || !Number.isInteger(ppid) || !Number.isInteger(pgid)) continue; - rows.push({ pid, ppid, pgid, state: fields[3] ?? "" }); - } - return rows; -} +export type { Row as ProcessRow } from "./proc-table.ts"; +export { openSource } from "./proc-table.ts"; /** Every distinct process group inside `rootPid`'s tree. * @@ -60,24 +33,14 @@ export function parseRows(listing: string): ProcessRow[] { * * Deliberately not filtered by start token: that is the whole point. */ -export function groupsInTree(rootPid: number, rows: ProcessRow[]): number[] { - const children = new Map(); - const pgidOf = new Map(); - for (const r of rows) { - children.set(r.ppid, [...(children.get(r.ppid) ?? []), r.pid]); - pgidOf.set(r.pid, r.pgid); - } +export function groupsInTree(rootPid: number, source: ProcessSource): number[] { + const rows = valueOf(source.rows()) ?? ([] as Row[]); + const pgidOf = new Map(rows.map((r) => [r.pid, r.pgid])); const rootGroup = pgidOf.get(rootPid); const groups: number[] = []; - const seen = new Set([rootPid]); - const queue = [...(children.get(rootPid) ?? [])]; - while (queue.length > 0) { - const pid = queue.shift()!; - if (seen.has(pid)) continue; - seen.add(pid); + for (const { pid } of walkTree(rootPid, source)) { const g = pgidOf.get(pid); if (g !== undefined && g !== rootGroup && g > 1 && !groups.includes(g)) groups.push(g); - for (const c of children.get(pid) ?? []) queue.push(c); } return groups.sort((a, b) => a - b); } @@ -85,7 +48,8 @@ export function groupsInTree(rootPid: number, rows: ProcessRow[]): number[] { /** The live pids that still belong to any of `groups`. A zombie is excluded: * `ps` still lists it with its group, and counting it would make the sweep * report a group it has already emptied. */ -export function membersOfGroups(groups: number[], rows: ProcessRow[]): number[] { +export function membersOfGroups(groups: number[], source: ProcessSource): number[] { + const rows = valueOf(source.rows()) ?? ([] as Row[]); return rows .filter((r) => !isZombie(r) && groups.includes(r.pgid)) .map((r) => r.pid) @@ -98,19 +62,11 @@ export function signalGroup(pgid: number, signal: NodeJS.Signals): void { try { process.kill(-pgid, signal); } catch {} } -export function ownProcessGroup(): number { - // `pgid` of self. Node has no getpgrp binding, so ask ps about our own pid. - try { - const out = execFileSync("ps", ["-o", "pgid=", "-p", String(process.pid)], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 1_000, - }).trim(); - const n = Number(out); - return Number.isInteger(n) ? n : -1; - } catch { - return -1; - } +export function ownProcessGroup(source: ProcessSource = openSource()): number { + // Node has no `getpgrp` binding, so this comes out of the table like every + // other process fact. On Linux that is a `/proc` read and no subprocess. + const row = valueOf(source.row(process.pid)); + return row ? row.pgid : -1; } export interface SweepDeps { diff --git a/src/process-tree.ts b/src/process-tree.ts index 73402c1..39e5916 100644 --- a/src/process-tree.ts +++ b/src/process-tree.ts @@ -1,27 +1,28 @@ -import { execFileSync } from "node:child_process"; -import { readProcessStartToken } from "./recovery.ts"; +import { + isZombie, + openSource, + valueOf, + type LiveIdentity, + type ProcessSource, + type Row, +} from "./proc-table.ts"; export interface ProcessIdentity { pid: number; - processStartToken: string; + /** Proof of identity for the length of one command. Not the registry's + * `recovery.processStartToken`: see `proc-table.ts`. */ + identity: LiveIdentity; depth: number; } interface ProcessTreeDeps { - listProcesses?: () => string; - readStartToken?: (pid: number) => string | null; + /** Where process facts come from. Defaults to one source per iteration, + * which on Linux reads `/proc` and on macOS is one `ps` call. */ + source?: () => ProcessSource; signal?: (pid: number, signal: NodeJS.Signals) => void; sleep?: (ms: number) => Promise; } -function listProcesses(): string { - return execFileSync("ps", ["-axo", "pid=,ppid="], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 2_000, - }); -} - /** Take one parent-chain snapshot before the PTY leader can exit and lose its * descendants to init or a subreaper. Every PID is bound to its process start * identity so later signals cannot target a reused PID. */ @@ -29,42 +30,54 @@ export function snapshotDescendantProcesses( rootPid: number, deps: ProcessTreeDeps = {}, ): ProcessIdentity[] { - const output = (deps.listProcesses ?? listProcesses)(); - const readStartToken = deps.readStartToken ?? readProcessStartToken; - const children = new Map(); - for (const line of output.split("\n")) { - const match = line.trim().match(/^(\d+)\s+(\d+)$/); - if (!match) continue; - const pid = Number(match[1]); - const ppid = Number(match[2]); - const siblings = children.get(ppid) ?? []; - siblings.push(pid); - children.set(ppid, siblings); + const source = (deps.source ?? openSource)(); + const descendants: ProcessIdentity[] = []; + for (const { pid, depth } of walkTree(rootPid, source)) { + const identity = valueOf(source.identity(pid)); + if (identity !== null) descendants.push({ pid, identity, depth }); } + return descendants.sort((a, b) => b.depth - a.depth || b.pid - a.pid); +} - const descendants: ProcessIdentity[] = []; +/** Walk a tree from `rootPid`, breadth first, recording depth. */ +export function walkTree( + rootPid: number, + source: ProcessSource, +): Array<{ pid: number; depth: number }> { + const rows = valueOf(source.rows()) ?? ([] as Row[]); + const children = new Map(); + for (const r of rows) children.set(r.ppid, [...(children.get(r.ppid) ?? []), r.pid]); + const out: Array<{ pid: number; depth: number }> = []; const seen = new Set([rootPid]); const queue = (children.get(rootPid) ?? []).map((pid) => ({ pid, depth: 1 })); while (queue.length > 0) { const current = queue.shift()!; if (seen.has(current.pid)) continue; seen.add(current.pid); - const processStartToken = readStartToken(current.pid); - if (processStartToken !== null) { - descendants.push({ ...current, processStartToken }); - } + out.push(current); for (const pid of children.get(current.pid) ?? []) { queue.push({ pid, depth: current.depth + 1 }); } } - return descendants.sort((a, b) => b.depth - a.depth || b.pid - a.pid); + return out; } -function isSameProcess( - identity: ProcessIdentity, - readStartToken: (pid: number) => string | null, -): boolean { - return readStartToken(identity.pid) === identity.processStartToken; +/** Is this still the same process, and still running? + * + * **A corpse is not a survivor.** On Linux an unreaped descendant keeps its + * `/proc` row and its identity, so matching on identity alone counted it as + * alive: the teardown would wait out its whole TERM budget for a process that + * had already died, then report it as having survived a SIGKILL. That is the + * kill over-claiming again, in the other direction. macOS never had this, + * because `ps` stops listing a process the moment it exits. + * + * **An unreadable source answers false, and that is deliberate**: it says + * "do not signal", never "it is gone". Every caller here wants the safe + * direction for a signal. */ +function isSameProcess(identity: ProcessIdentity, source: ProcessSource): boolean { + const row = valueOf(source.row(identity.pid)); + if (row === null || isZombie(row)) return false; + return row.identity === identity.identity; } /** Signal only identities that still match their snapshot. A token mismatch @@ -74,11 +87,11 @@ export function signalProcessIdentities( signal: NodeJS.Signals, deps: ProcessTreeDeps = {}, ): number[] { - const readStartToken = deps.readStartToken ?? readProcessStartToken; + const source = (deps.source ?? openSource)(); const sendSignal = deps.signal ?? ((pid, value) => process.kill(pid, value)); const signalled: number[] = []; for (const identity of identities) { - if (!isSameProcess(identity, readStartToken)) continue; + if (!isSameProcess(identity, source)) continue; try { sendSignal(identity.pid, signal); signalled.push(identity.pid); @@ -92,13 +105,21 @@ async function waitForIdentitiesToExit( timeoutMs: number, deps: ProcessTreeDeps, ): Promise { - const readStartToken = deps.readStartToken ?? readProcessStartToken; + const openIteration = deps.source ?? openSource; const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); const deadline = Date.now() + timeoutMs; - let survivors = identities.filter((identity) => isSameProcess(identity, readStartToken)); + // **One source per iteration, not one question per process.** On macOS every + // question used to be a `ps` spawn: at 25 ms polling inside a 1500 ms budget, + // four descendants cost 240 spawns, and at the 10.9 ms a spawn was measured to + // take that is 2.6 seconds of spawning inside a 1.5 second deadline. The loop + // could not meet its own deadline on an idle machine. It is now one `ps` per + // iteration there, and no subprocess at all on Linux. + let source = openIteration(); + let survivors = identities.filter((identity) => isSameProcess(identity, source)); while (survivors.length > 0 && Date.now() < deadline) { await sleep(25); - survivors = survivors.filter((identity) => isSameProcess(identity, readStartToken)); + source = openIteration(); + survivors = survivors.filter((identity) => isSameProcess(identity, source)); } return survivors; } diff --git a/src/recovery.ts b/src/recovery.ts index ed396bb..b2013cc 100644 --- a/src/recovery.ts +++ b/src/recovery.ts @@ -144,6 +144,21 @@ export function readProcessStartToken(pid: number): string | null { return startTime ? `linux:${startTime}` : null; } if (process.platform === "darwin") { + // **THIS `ps` STAYS, AND IT IS NOT AN OVERSIGHT.** + // + // Everything else moved to `proc-table.ts`, which reads `/proc` on Linux + // and calls `ps` once per operation elsewhere. This one cannot, because + // the text it produces is written into session metadata as + // `recovery.processStartToken` and the Rust tool reads it back from the + // same registry. `ps -o lstart=` output — including the two spaces it + // puts before a single-digit day — is therefore a contract between two + // programs, not an implementation detail. + // + // It is safe where it is: one call per session lookup, never inside a + // poll loop, and a failure here already means "cannot confirm" rather + // than "gone". `LiveIdentity` in `proc-table.ts` is a separate branded + // type so the cheap identity used by the teardown can never be compared + // with this one. const started = execFileSync( "ps", ["-o", "lstart=", "-p", String(pid)], diff --git a/src/server.ts b/src/server.ts index 709d4f8..348c264 100644 --- a/src/server.ts +++ b/src/server.ts @@ -216,6 +216,10 @@ export interface ProcessResources { /** Query CPU and memory usage for a process via ps. Returns null on failure. */ function queryProcessResources(pid: number): ProcessResources | null { try { + // The only per-pid `ps` left in the daemon, and only off Linux. Resident + // set and CPU are not in `/proc//stat` in the form this wants, and a + // stats query is one call for one session rather than one per descendant + // inside a loop. const output = execFileSync("ps", ["-o", "rss=,pcpu=", "-p", String(pid)], { encoding: "utf-8", timeout: 1000, diff --git a/src/sessions.ts b/src/sessions.ts index 237485f..8e4e0ef 100644 --- a/src/sessions.ts +++ b/src/sessions.ts @@ -5,6 +5,7 @@ import * as os from "node:os"; import * as net from "node:net"; import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; +import { isZombie, processOf } from "./proc-table.ts"; import { assertPrivateRecoveryPaths, atomicWritePrivate, @@ -805,20 +806,16 @@ type ReapObservedResult = * has a readable start token, so the cheap predicates call a corpse a survivor. */ export function hasProcessExitedForReap(pid: number): boolean { if (!isProcessAlive(pid)) return true; - try { - if (process.platform === "linux") { - const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); - const stateOffset = stat.lastIndexOf(") ") + 2; - return stateOffset >= 2 && stat[stateOffset] === "Z"; - } - const state = execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { - encoding: "utf8", - timeout: 1000, - }).trim(); - return reapedFromPsState(state, () => isProcessAlive(pid)); - } catch { - return !isProcessAlive(pid); - } + // One `/proc` read on Linux and no subprocess. On macOS this is still one + // `ps` per call, which is Node's floor without a native module — but it asks + // about ONE pid rather than reading the whole table, because this sits inside + // poll loops that run every 25 ms. + const row = processOf(pid); + if (row.kind === "known") return isZombie(row.value); + if (row.kind === "not-present") return true; + // We did not find out. Ask the kernel once more rather than reading our own + // silence as a death. + return !isProcessAlive(pid); } /** Read a `ps -o stat=` field. `stillAlive` is asked only when the field is diff --git a/tests/kill-report.test.ts b/tests/kill-report.test.ts index 24c3801..e31fdd8 100644 --- a/tests/kill-report.test.ts +++ b/tests/kill-report.test.ts @@ -12,12 +12,13 @@ import { type Aftermath, } from "../src/kill-report.ts"; import { hasProcessExitedForReap, reapedFromPsState } from "../src/sessions.ts"; -import { readProcessStartToken } from "../src/recovery.ts"; +import { openSource, valueOf } from "../src/proc-table.ts"; import type { ProcessIdentity } from "../src/process-tree.ts"; +import { liveIdentity } from "../src/proc-table.ts"; const identity = (pid: number, token: string): ProcessIdentity => ({ pid, - processStartToken: token, + identity: liveIdentity(token), depth: 1, }); @@ -61,19 +62,24 @@ describe("classifying against the real process table", () => { it("sees a running process, then stops seeing it", async () => { const child = spawn("sleep", ["30"], { stdio: "ignore" }); const pid = child.pid!; - const token = readProcessStartToken(pid); + const token = valueOf(openSource().identity(pid)); expect(token).not.toBeNull(); const before = [identity(pid, token!)]; - expect(aftermathOf(before, readProcessStartToken, hasProcessExitedForReap).survived) - .toEqual([pid]); + expect( + aftermathOf(before, (p) => valueOf(openSource().identity(p)), hasProcessExitedForReap) + .survived, + ).toEqual([pid]); child.kill("SIGKILL"); await new Promise((r) => child.once("exit", r)); await sleep(50); - expect(allGone(aftermathOf(before, readProcessStartToken, hasProcessExitedForReap))) - .toBe(true); + expect( + allGone( + aftermathOf(before, (p) => valueOf(openSource().identity(p)), hasProcessExitedForReap), + ), + ).toBe(true); }); // A zombie answers kill(pid, 0) and keeps a readable start token, so the two @@ -88,7 +94,7 @@ describe("classifying against the real process table", () => { const pid = await new Promise((resolve) => sh.stdout!.once("data", (d) => resolve(Number(String(d).trim()))), ); - const token = readProcessStartToken(pid); + const token = valueOf(openSource().identity(pid)); expect(token).not.toBeNull(); const before = [identity(pid, token!)]; @@ -96,9 +102,12 @@ describe("classifying against the real process table", () => { for (let i = 0; i < 100 && !hasProcessExitedForReap(pid); i++) await sleep(10); expect(hasProcessExitedForReap(pid)).toBe(true); - expect(readProcessStartToken(pid)).toBe(token); - expect(allGone(aftermathOf(before, readProcessStartToken, hasProcessExitedForReap))) - .toBe(true); + expect(valueOf(openSource().identity(pid))).toBe(token); + expect( + allGone( + aftermathOf(before, (p) => valueOf(openSource().identity(p)), hasProcessExitedForReap), + ), + ).toBe(true); } finally { sh.kill("SIGKILL"); } diff --git a/tests/proc-table.test.ts b/tests/proc-table.test.ts new file mode 100644 index 0000000..76d50b2 --- /dev/null +++ b/tests/proc-table.test.ts @@ -0,0 +1,185 @@ +// One reader for the process table. These tests are about the three answers +// and about a `ps` that misbehaves, because that is what produced the wrong +// answers this module exists to stop. + +import { describe, expect, it } from "vitest"; +import { hasProcessExitedForReap } from "../src/sessions.ts"; +import { + isDefinitelyAbsent, + openSource, + orAbsentWhenUnknown, + parseProcStat, + parsePsListing, + sourceFromShape, + unknown, + valueOf, +} from "../src/proc-table.ts"; +import { execFileSync, spawn } from "node:child_process"; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +const me = process.pid; + +describe("the truncation guard", () => { + // `ps` always lists at least the process that ran it. A listing without our + // own pid was truncated or never produced, and reading it as "the machine + // has no processes" is the defect this whole module exists for. + it("treats a listing without our own pid as unreadable, not empty", () => { + expect(parsePsListing("4242 1 4242 S 100 0.0 Wed Sep 3 11:00:00 2026\n")).toBeNull(); + }); + + it("treats an empty listing as unreadable, not empty", () => { + expect(parsePsListing("")).toBeNull(); + expect(parsePsListing(" \n\n")).toBeNull(); + }); + + it("accepts a listing that contains us", () => { + const rows = parsePsListing(`${me} 1 ${me} S 100 0.5 Wed Sep 3 11:00:00 2026\n`); + expect(rows).not.toBeNull(); + expect(rows![0]).toMatchObject({ pid: me, ppid: 1, pgid: me, rssKb: 100, cpuPercent: 0.5 }); + }); + + // `ps -o lstart=` pads a single-digit day with two spaces, and that text is + // the registry's on-disk token. Re-joining split fields would rewrite it. + it("keeps the lstart text exactly as ps printed it", () => { + const rows = parsePsListing(`${me} 1 ${me} S 100 0.5 Wed Sep 3 11:00:00 2026\n`); + expect(rows![0].identity).toBe("darwin:Wed Sep 3 11:00:00 2026"); + }); +}); + +describe("the shape a subprocess actually returns", () => { + // `ps` prints a trailing newline. An earlier version passed raw stdout to the + // single-line parser, whose `$` does not match before one, so a live process + // read as "field-empty" and a zombie read as not exited. There is now one + // parser and these pin its input shape. + const row = "94908 94907 94576 Z 0 0.0 Thu Sep 3 14:05:44 2026 "; + + it("parses a row that ends in a newline", () => { + const rows = parsePsListing(`${row}\n`, 94908); + expect(rows).not.toBeNull(); + expect(rows![0]).toMatchObject({ pid: 94908, ppid: 94907, pgid: 94576, state: "Z" }); + }); + + it("parses a row with no trailing newline", () => { + expect(parsePsListing(row, 94908)).not.toBeNull(); + }); + + it("keeps the lstart text through a trailing newline", () => { + const rows = parsePsListing(`${row}\n`, 94908); + expect(rows![0].identity).toBe("darwin:Thu Sep 3 14:05:44 2026"); + }); + + // **The seam this bug lived in.** Every other test builds its input the way + // the parser expects, so all of them agreed with each other and none of them + // agreed with `ps`. This one runs the real command with the real arguments + // and feeds the parser exactly what the subprocess wrote, newline and all. + it("parses what the ps subprocess actually writes", () => { + const out = execFileSync( + "ps", + ["-o", "pid=,ppid=,pgid=,state=,rss=,pcpu=,lstart=", "-p", String(me)], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }, + ); + expect(out.endsWith("\n"), "precondition: ps ends its output with a newline").toBe(true); + + const rows = parsePsListing(out, me); + expect(rows, `parser rejected real ps output: ${JSON.stringify(out)}`).not.toBeNull(); + const row = rows!.find((r) => r.pid === me); + expect(row).toBeDefined(); + expect(row!.ppid).toBeGreaterThan(0); + expect(row!.pgid).toBeGreaterThan(0); + expect(row!.identity).toBeTruthy(); + }); +}); + +describe("three answers, never two", () => { + const source = sourceFromShape(`${me} 1 ${me} Ss`); + + it("separates a missing process from an unreadable table", () => { + expect(isDefinitelyAbsent(source.isRunning(999_999))).toBe(true); + expect(isDefinitelyAbsent(unknown("table-unreadable"))).toBe(false); + }); + + it("makes treating silence as death something you have to ask for by name", () => { + const silent = unknown("table-unreadable"); + expect(isDefinitelyAbsent(silent)).toBe(false); + expect(isDefinitelyAbsent(orAbsentWhenUnknown(silent))).toBe(true); + }); + + // An empty column is its own answer too: the process is there, `ps` just did + // not say. This is the defect that shipped in `hasProcessExitedForReap`. + it("does not turn an unnamed process into an absent one", () => { + const unnamed = sourceFromShape(`${me} 1 ${me} Ss -`); + const answer = unnamed.identity(me); + expect(answer.kind).toBe("unknown"); + expect(isDefinitelyAbsent(answer)).toBe(false); + }); +}); + +describe("parsing /proc//stat", () => { + // Field 2 is the comm in parentheses and may contain spaces and brackets. + it("reads relative to the last close paren", () => { + const row = parseProcStat(7, `7 (a b) c) S 3 5 ${Array.from({ length: 40 }, (_, i) => i).join(" ")}`); + expect(row).not.toBeNull(); + expect(row!.ppid).toBe(3); + expect(row!.pgid).toBe(5); + expect(row!.state).toBe("S"); + }); + + it("returns null rather than guessing at a short line", () => { + expect(parseProcStat(7, "7 (x) S 1 2")).toBeNull(); + expect(parseProcStat(7, "no parens here")).toBeNull(); + }); +}); + +describe("against the real machine", () => { + it("knows this very process", () => { + const source = openSource(); + const row = valueOf(source.row(me)); + expect(row).not.toBeNull(); + expect(row!.pid).toBe(me); + expect(row!.ppid).toBeGreaterThan(0); + expect(valueOf(source.isRunning(me))).toBe(true); + expect(valueOf(source.identity(me))).toBeTruthy(); + }); + + it("calls a pid that cannot exist definitely absent", () => { + expect(isDefinitelyAbsent(openSource().isRunning(0x7fffffff))).toBe(true); + }); + + // An unreaped child must never read as running. **The two platforms reach + // that answer differently, and this asserts the answer.** On Linux the corpse + // keeps a row with state Z. On macOS `ps` stops listing it the moment it + // exits. Measured on a real Mac by Silber.pty on 2026-09-03. + it("never reports an unreaped child as running", async () => { + const sh = spawn("sh", ["-c", "sleep 0.1 & echo $! ; kill -STOP $$"], { + stdio: ["ignore", "pipe", "ignore"], + }); + try { + const pid = await new Promise((resolve) => + sh.stdout!.once("data", (d) => resolve(Number(String(d).trim()))), + ); + let settled: string | null = null; + for (let i = 0; i < 500; i++) { + const source = openSource(); + const answer = source.isRunning(pid); + // Linux: still listed, but a corpse. + if (answer.kind === "known" && answer.value === false) { + settled = "listed as not running"; + break; + } + // macOS: gone from the listing entirely. + if (answer.kind === "not-present") { + settled = "no longer listed"; + break; + } + await sleep(10); + } + expect(settled, "an exited child still read as running").not.toBeNull(); + // Whichever route, the conclusion callers depend on is the same. + expect(hasProcessExitedForReap(pid)).toBe(true); + } finally { + sh.kill("SIGKILL"); + } + }, 20_000); +}); diff --git a/tests/process-groups.test.ts b/tests/process-groups.test.ts index 9003bd8..e0e8f38 100644 --- a/tests/process-groups.test.ts +++ b/tests/process-groups.test.ts @@ -5,29 +5,28 @@ import { describe, expect, it } from "vitest"; import { spawn } from "node:child_process"; import { groupsInTree, - listProcessesWithGroups, membersOfGroups, ownProcessGroup, - parseRows, signalGroup, sweepGroups, - type ProcessRow, } from "../src/process-groups.ts"; +import { openSource, sourceFromShape } from "../src/proc-table.ts"; import { snapshotDescendantProcesses } from "../src/process-tree.ts"; // daemon 100 (its own group), pty child 200 (setsid: its own group and // session), 300 under the child, and 400 in a background group of its own. // 900 is unrelated. This is the shape measured on Linux for both tools on // 2026-09-03. -const ROWS = ["100 1 100 Ss", "200 100 200 Ss", "300 200 200 S", "400 300 400 S", "900 1 900 S"].join("\n"); +const SHAPE = ["100 1 100 Ss", "200 100 200 Ss", "300 200 200 S", "400 300 400 S", "900 1 900 S"].join("\n"); +const shaped = () => sourceFromShape(SHAPE); const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); -const live = (groups: number[]) => membersOfGroups(groups, parseRows(listProcessesWithGroups())); +const live = (groups: number[]) => membersOfGroups(groups, openSource()); describe("choosing which process groups to sweep", () => { it("never targets the daemon's own group", () => { - const groups = groupsInTree(100, parseRows(ROWS)); + const groups = groupsInTree(100, shaped()); // The pty child calls setsid, so the daemon sits alone in its group and // signalling it would reach the daemon and nothing else. expect(groups).not.toContain(100); @@ -35,35 +34,33 @@ describe("choosing which process groups to sweep", () => { }); it("never targets an unrelated group", () => { - expect(groupsInTree(100, parseRows(ROWS))).not.toContain(900); + expect(groupsInTree(100, shaped())).not.toContain(900); }); // The reason to sweep groups at all. it("still targets the group of a descendant whose start token cannot be read", () => { - const snapshot = snapshotDescendantProcesses(100, { - listProcesses: () => ROWS.split("\n").map((l) => l.split(/\s+/).slice(0, 2).join(" ")).join("\n"), - readStartToken: (pid) => (pid === 400 ? null : `tok:${pid}`), - }); + // 400 is the one the table cannot name. + const unnamed = sourceFromShape( + ["100 1 100 Ss", "200 100 200 Ss", "300 200 200 S", "400 300 400 S -", "900 1 900 S"].join("\n"), + ); + const snapshot = snapshotDescendantProcesses(100, { source: () => unnamed }); expect(snapshot.some((i) => i.pid === 400)).toBe(false); - expect(groupsInTree(100, parseRows(ROWS))).toContain(400); + expect(groupsInTree(100, unnamed)).toContain(400); }); it("reads members back by group", () => { - const rows = parseRows(ROWS); - expect(membersOfGroups([200], rows)).toEqual([200, 300]); - expect(membersOfGroups([], rows)).toEqual([]); + expect(membersOfGroups([200], shaped())).toEqual([200, 300]); + expect(membersOfGroups([], shaped())).toEqual([]); }); // `ps` lists a zombie with its process group. Counting it would make the // sweep report a group it has already emptied, and then signal it again. it("does not count a zombie as a group member", () => { - const rows: ProcessRow[] = parseRows("100 1 100 Ss\n200 100 200 Sl\n300 200 200 Z"); - expect(membersOfGroups([200], rows)).toEqual([200]); + const withCorpse = sourceFromShape("100 1 100 Ss\n200 100 200 Sl\n300 200 200 Z"); + expect(membersOfGroups([200], withCorpse)).toEqual([200]); }); - it("ignores a malformed listing row rather than guessing", () => { - expect(parseRows("1 2\nx y z\n7 8 9 S\n")).toEqual([{ pid: 7, ppid: 8, pgid: 9, state: "S" }]); - }); + }); describe("the sweep", () => { @@ -117,8 +114,15 @@ describe("the sweep against real processes", () => { // real processes: it builds a real group whose members ignore SIGTERM, runs // the real sweep, and checks the process table afterwards. it("kills a real group that ignores SIGTERM and verifies it is gone", async () => { - const child = spawn("setsid", ["sh", "-c", "trap '' TERM; sleep 60 & sleep 60"], { + // `detached: true` gives the child its own process group. + // + // **macOS has the `setsid` system call but no `setsid` executable.** An + // earlier version of this test spawned the binary, so on the one platform + // where process groups are the whole escalation story, the test could not + // run at all. Reported from a real Mac by Silber.pty on 2026-09-03. + const child = spawn("sh", ["-c", "trap '' TERM; sleep 60 & sleep 60"], { stdio: "ignore", + detached: true, }); const leader = child.pid!; try { diff --git a/tests/process-tree.test.ts b/tests/process-tree.test.ts index 3d0b568..85c773c 100644 --- a/tests/process-tree.test.ts +++ b/tests/process-tree.test.ts @@ -5,41 +5,80 @@ import { terminateProcessIdentities, type ProcessIdentity, } from "../src/process-tree.ts"; +import { + liveIdentity, + sourceFromShape, + valueOf, + type ProcessSource, +} from "../src/proc-table.ts"; + +/** A source whose identities can change under the test, so a reused PID can be + * simulated without touching a real machine. */ +function mutableSource(live: Map, shape: string): () => ProcessSource { + const base = sourceFromShape(shape); + const rowOf = (pid: number) => { + const v = live.get(pid); + if (v === undefined) return null; + const row = valueOf(base.row(pid)); + return row ? { ...row, identity: liveIdentity(v) } : null; + }; + return () => ({ + rows: () => base.rows(), + row: (pid) => { + const r = rowOf(pid); + return r === null + ? { kind: "not-present" as const } + : { kind: "known" as const, value: r }; + }, + isRunning: (pid) => { + const r = rowOf(pid); + return r === null + ? { kind: "not-present" as const } + : { kind: "known" as const, value: true }; + }, + identity: (pid) => { + const r = rowOf(pid); + return r === null || r.identity === null + ? { kind: "not-present" as const } + : { kind: "known" as const, value: r.identity }; + }, + }); +} describe("exact descendant process shutdown", () => { it("snapshots only descendants and records depth plus process-start identity", () => { - const tokens = new Map([ - [11, "start-11"], - [12, "start-12"], - [13, "start-13"], - ]); + // 10 is the root; 14 hangs off an unrelated parent and must not appear. const snapshot = snapshotDescendantProcesses(10, { - listProcesses: () => [ - "10 1", - "11 10", - "12 11", - "13 10", - "14 99", - ].join("\n"), - readStartToken: (pid) => tokens.get(pid) ?? null, + source: () => + sourceFromShape( + [ + "10 1 10 Ss start-10", + "11 10 10 S start-11", + "12 11 10 S start-12", + "13 10 10 S start-13", + "14 99 99 S start-14", + ].join("\n"), + ), }); expect(snapshot).toEqual([ - { pid: 12, processStartToken: "start-12", depth: 2 }, - { pid: 13, processStartToken: "start-13", depth: 1 }, - { pid: 11, processStartToken: "start-11", depth: 1 }, + { pid: 12, identity: liveIdentity("start-12"), depth: 2 }, + { pid: 13, identity: liveIdentity("start-13"), depth: 1 }, + { pid: 11, identity: liveIdentity("start-11"), depth: 1 }, ]); }); it("never signals a PID whose process-start identity changed", () => { const identities: ProcessIdentity[] = [ - { pid: 20, processStartToken: "original-20", depth: 1 }, - { pid: 21, processStartToken: "original-21", depth: 1 }, + { pid: 20, identity: liveIdentity("original-20"), depth: 1 }, + { pid: 21, identity: liveIdentity("original-21"), depth: 1 }, ]; const signals: Array<[number, NodeJS.Signals]> = []; + // 20 has been reused by something else; 21 is still itself. + const live = new Map([[20, "reused-20"], [21, "original-21"]]); const signalled = signalProcessIdentities(identities, "SIGTERM", { - readStartToken: (pid) => pid === 20 ? "reused-20" : "original-21", + source: mutableSource(live, "20 1 20\n21 1 21"), signal: (pid, signal) => { signals.push([pid, signal]); }, }); @@ -49,17 +88,17 @@ describe("exact descendant process shutdown", () => { it("uses exact TERM then exact KILL without a process-group signal", async () => { const identities: ProcessIdentity[] = [ - { pid: 30, processStartToken: "start-30", depth: 2 }, - { pid: 31, processStartToken: "start-31", depth: 1 }, + { pid: 30, identity: liveIdentity("start-30"), depth: 2 }, + { pid: 31, identity: liveIdentity("start-31"), depth: 1 }, ]; - const live = new Map(identities.map((identity) => [identity.pid, identity.processStartToken])); + const live = new Map([[30, "start-30"], [31, "start-31"]]); const signals: Array<[number, NodeJS.Signals]> = []; const survivors = await terminateProcessIdentities( identities, { termWaitMs: 0, killWaitMs: 1 }, { - readStartToken: (pid) => live.get(pid) ?? null, + source: mutableSource(live, "30 1 30\n31 1 31"), signal: (pid, signal) => { signals.push([pid, signal]); if (signal === "SIGKILL") live.delete(pid);