/** * Process-tree termination that CONFIRMS, instead of a single best-effort * `taskkill /T` (ES-P0-ACTION-CREDENTIAL-SCOPE, RH-20260820-003V finding 1). * * Why a single tree-kill is not a kill. Both `taskkill /PID

/T /F` and a * POSIX process-group signal are point-in-time: they act on the tree as it was * when the snapshot was taken (or on the group as it is now), and a process * created a moment later — or one that left the group with `setsid` / * `detached: true` — is not in the set. On Windows there is a structural * window on top of the timing one: libuv assigns a spawned child to the * parent's job object only AFTER `CreateProcess` returns, so a parent that is * terminated while it is in the middle of spawning leaves a running grandchild * that belongs to no job, that `taskkill`'s snapshot never saw, and that the * parent's death therefore does not take with it. That orphan is exactly the * `grandchild-alive.txt` the independent verifier reproduced. * * What this module does instead: enumerate the process table, attribute * descendants by parent-pid relation (Windows keeps `ParentProcessId` after the * parent dies, so a late orphan is still attributable), pin each tree member's * identity by creation time where the backend reports one (a reused pid is a * different process, never a target), kill everything alive, settle, and * RE-ENUMERATE — repeating until two consecutive snapshots find no living * member, or a deadline passes. The caller receives a report that says * `confirmed: true` only in the first case; anything else is reported as * unconfirmed with the surviving pids, and `spawnTree` turns that into a * fail-closed result rather than a normal timeout. * * Windows backend: one PowerShell helper per termination, kept alive for the * duration and queried for `Win32_Process` snapshots over stdin/stdout (one * CIM query per round instead of one PowerShell start per round). Kills are * `TerminateProcess` through `process.kill(pid, 'SIGKILL')`; the root is killed * through its own handle (`ChildProcess.kill`), which cannot hit a reused pid. * POSIX backend: FREEZE first — SIGSTOP the root (and its process group), then * `ps -A -o pid=,ppid=,stat=` until two consecutive snapshots show every * member stopped and no new descendant, stopping each newly found member as it * appears — and only then the strike (group SIGKILL, every frozen member, * the root through its handle) and the confirm sweep. A stopped process cannot * fork and cannot exit, so its children stay attributable by parent pid for as * long as the freeze holds; killing the root FIRST (the pre-2026-09-02 order) * reparented its `detached: true` grandchildren to init before the first * snapshot could tie them to the tree, and the sweep confirmed over a * survivor that then wrote its marker (five Linux CI failures at f1b275e). * Both backends are injectable so the loops are tested without real processes * (`sweepProcessTree`, `freezePosixTree`, `terminatePosixTree`) and with them * (`terminateProcessTree` through tree-spawn). */ export interface ProcessRecord { pid: number; ppid: number; /** Creation time as epoch milliseconds when the backend reports one; null otherwise. */ createdAt: number | null; /** * The scheduler state as the backend reports it (`ps -o stat`: `T` stopped, * `Z` zombie, `R`/`S`/`D` running or sleeping ...), when it reports one. The * POSIX freeze needs it: a snapshot is a boundary only when every member in * it is inert. */ state?: string; } export type ProcessLister = () => Promise; export interface TreeTerminationReport { /** True ONLY when two consecutive snapshots found no living member of the tree. */ confirmed: boolean; method: 'win32-job-cim-sweep' | 'win32-cim-sweep' | 'posix-freeze-ps-sweep' | 'injected' | 'unavailable'; rounds: number; /** Pids this sweep sent a kill to (deduplicated, in first-kill order). */ killed: number[]; /** Pids still alive when the sweep gave up (empty when confirmed). */ survivors: number[]; durationMs: number; detail: string; /** How membership was decided — for the ledger and for tests. */ diagnostics?: SweepDiagnostics; /** 'job' when the root lived inside a kernel job object that was terminated; 'attribution' when only the sweep applied. */ boundary?: 'job' | 'attribution'; /** Kernel count of job members still alive after TerminateJobObject (0 when the job boundary held). */ jobActiveAfterKill?: number | null; /** * How many processes the kernel listed as job members immediately BEFORE * TerminateJobObject (null when the list could not be read). Each of them is * a known member of the sweep and must be observed dead before confirmation. */ jobMembersAtKill?: number | null; /** Why the boundary is what it is (e.g. the job assignment error) — for the ledger. */ boundaryDetail?: string; } /** * A process the caller KNOWS is in the tree: on Windows one the kernel listed * as a job member (creation time read while it was alive); on POSIX one the * freeze stopped and attributed before the strike. */ export interface KnownMember { pid: number; createdAt: number | null; /** How the caller knows — for the report. Default 'job'. */ via?: 'job' | 'frozen'; } export interface SweepOptions { rootPid: number; /** * Epoch ms recorded immediately BEFORE the root was spawned. A process created * earlier cannot be a member of this tree whatever its parent pid says (pid * reuse); the tolerance below absorbs clock granularity. */ floorMs: number; list: ProcessLister; /** Kill one pid; throws when it cannot. Defaults to process.kill(pid, 'SIGKILL'). */ kill?: (pid: number) => void; /** Kill the root through its own handle (ChildProcess.kill). Falls back to `kill`. */ killRoot?: () => void; /** Liveness probe. Defaults to process.kill(pid, 0) semantics. */ isAlive?: (pid: number) => boolean; /** Root liveness from the caller's own knowledge (ChildProcess exit state). */ isRootAlive?: () => boolean; /** Give up after this long (ms). */ deadlineMs?: number; /** Pause between rounds (ms). */ settleMs?: number; /** Run once before the first kill round (POSIX group kill). */ preKill?: () => void; now?: () => number; sleep?: (ms: number) => Promise; method?: TreeTerminationReport['method']; /** * A snapshot taken BEFORE the strike: members found here are seeded with * exact creation times, so a descendant that dies quickly still leaves its * children attributable through its recorded life. */ seed?: ProcessRecord[]; /** * The root's kernel creation time (epoch ms) when the caller captured it while * the root was certainly alive (the Windows helper reads it at job assignment). * Pins the root's identity from the start: no snapshot can ever re-identify it. */ rootCreatedAt?: number | null; /** * Members the caller KNOWS are in the tree regardless of what any snapshot * says about their parentage — on Windows, the kernel's own list of job * members read immediately before TerminateJobObject, each pinned by its * creation time. Every one of them must be observed dead before the sweep * confirms; none is ever re-identified from a later record under its pid. */ known?: KnownMember[]; } export declare function defaultIsAlive(pid: number): boolean; export interface SweepDiagnostics { /** Members by pid, with the interval used to attribute them. */ members: Array<{ pid: number; created: number | null; diedBy: number | null; via: string; }>; /** Pids attributed only through a parent that was already dead (late orphans). */ orphansAttributed: number[]; /** Records that wore a member's pid but fell outside its life (pid reuse refused). */ reusedPidsRefused: number[]; } /** * The confirm-loop. Pure over its injected primitives; `terminateProcessTree` * wires the platform backends in. * * Confirmation requires TWO consecutive snapshots in which no member is alive * AND no new member is discovered. "Member" is decided by life-interval * attribution (see TreeMember), never by bare pid equality, so an escaped * descendant whose parent has since died (or whose parent's pid has since been * reused) is still found, still killed, and still blocks confirmation until it * is dead. An enumeration the backend cannot deliver, or a member still alive * at the deadline, is reported as NOT confirmed. */ export declare function sweepProcessTree(options: SweepOptions): Promise; export declare const TREE_HELPER_SCRIPT: string; export interface TreeHelper { /** Resolves true once the job exists (READY), false if the helper failed to start or build the job. */ ready(): Promise; /** Why ready() is/was false (start error, FAIL line, exit), or null. */ readyDetail(): string | null; list: ProcessLister; /** * Assign a pid to the job. `code` is 0 on success, a negative Win32 error * otherwise; `createdAt` is the kernel's creation time of that pid (epoch ms, * read while the process is certainly alive — the root's durable identity), * or null when it could not be read. Rejects if the helper is gone. */ assign(pid: number): Promise<{ code: number; createdAt: number | null; }>; /** Kernel count of live job members. */ active(): Promise; /** * The kernel's list of job members at this instant, each with its creation * time (epoch ms) read while alive — read BEFORE `kill()` so every process * the job held is known by identity, not only by count. Rejects if the * helper is gone or the kernel refuses the query. */ members(): Promise; /** TerminateJobObject, then poll ActiveProcesses to 0 (bounded); resolves the count left. */ kill(): Promise; close(): void; } /** * Start the Windows tree helper. Never throws; a helper that cannot start * reports `ready() === false` and rejects every later request, which the * callers treat as "no job boundary" (attribution only) and, on a kill, as * enumeration failure (ETREEKILL). Returns null on non-Windows platforms. */ export declare function startTreeHelper(): TreeHelper | null; /** Backwards-compatible name: the helper is now the job holder + snapshot source. */ export declare const prepareSnapshotHelper: typeof startTreeHelper; export type SnapshotHelper = TreeHelper; export interface PosixFreezeOptions { rootPid: number; list: ProcessLister; /** SIGSTOP one pid; throws when it cannot (not ours, already gone). */ stop: (pid: number) => void; /** SIGSTOP the root's process group — a cheap accelerator for everything that stayed in it; the per-pid stop is the mechanism. */ stopGroup?: () => void; deadlineMs?: number; settleMs?: number; now?: () => number; sleep?: (ms: number) => Promise; } export interface PosixFreezeResult { /** Every pid the freeze stopped and attributed, root first, in discovery order. */ members: number[]; /** The fixpoint snapshot (or the last one read), for seeding the confirm sweep. */ snapshot: ProcessRecord[]; rounds: number; /** True only when two consecutive snapshots showed every member inert and no new descendant. */ complete: boolean; /** Why `complete` is false (enumeration failure, deadline, a member that never stopped), or null. */ failure: string | null; } /** * The POSIX boundary: stop the tree, then read it. SIGSTOP is delivered before * the target executes another user-mode instruction, so once a member is * stopped its set of children is final and every one of them is visible with * `ppid` = the member (a stopped parent is alive, so nothing is reparented to * init). The loop stops the root and its group, snapshots, stops every record * attributed to a member (transitively within the snapshot), and repeats until * TWO consecutive snapshots find no new member with every member inert: two, * because `ps` reads /proc entries one at a time, and a child forked an * instant before its parent's stop can be linked after the reader passed its * slot — the next read sees it. Anything short of that fixpoint (a backend * that cannot enumerate, a member that will not stop, the deadline) is * reported `complete: false`; the caller still strikes every member it has, * and must not confirm. * * Stated limit (unchanged, documented in tree-spawn.test.ts): a descendant * whose parent DIED before the freeze was already reparented to init and is * not attributable by parent pid; the Windows job object closes that gap, the * POSIX freeze does not. */ export declare function freezePosixTree(options: PosixFreezeOptions): Promise; /** The real POSIX primitives; injectable for the deterministic tests. */ export interface PosixTreeBackend { list: ProcessLister; stop: (pid: number) => void; stopGroup: () => void; kill: (pid: number) => void; killGroup: () => void; isAlive?: (pid: number) => boolean; sleep?: (ms: number) => Promise; now?: () => number; } export interface PosixTerminateOptions { rootPid: number; floorMs: number; killRoot?: () => void; isRootAlive?: () => boolean; deadlineMs?: number; freezeDeadlineMs?: number; settleMs?: number; seed?: ProcessRecord[]; rootCreatedAt?: number | null; backend?: PosixTreeBackend; } /** * FREEZE, STRIKE, CONFIRM — the POSIX termination. Never throws. * * 1. FREEZE (`freezePosixTree`): the tree is stopped and enumerated to its * fixpoint; every member found is a KNOWN member of the sweep. * 2. STRIKE: the process group, every frozen member by pid, and the root * through the caller's handle — whether or not the freeze completed. An * incomplete freeze still leaves nothing it found running. * 3. CONFIRM (`sweepProcessTree`): every known member observed dead in two * consecutive snapshots, plus anything still attributable. The result is * `confirmed: true` ONLY when the freeze completed AND the sweep confirmed: * a freeze that did not reach its fixpoint cannot prove the membership it * is confirming over, and is reported unconfirmed (ETREEKILL upstream). */ export declare function terminatePosixTree(options: PosixTerminateOptions): Promise; export interface TerminateTreeOptions { rootPid: number; floorMs: number; killRoot?: () => void; isRootAlive?: () => boolean; deadlineMs?: number; /** The tree helper started at spawn (job holder + snapshot source); closed by this call. */ helper?: TreeHelper | null; /** True when the root was assigned to the helper's job object (the enforceable boundary). */ jobAssigned?: boolean; /** A process-table snapshot taken BEFORE the strike (identities captured before termination). */ seed?: ProcessRecord[]; /** The root's kernel creation time captured at job assignment (see SweepOptions.rootCreatedAt). */ rootCreatedAt?: number | null; /** Test seam: how replacement helpers are started when one dies (default: the platform helper). */ helperFactory?: () => TreeHelper | null; } /** * Terminate `rootPid` and every descendant, and say whether that was CONFIRMED. * Never throws: an unavailable backend is reported as `confirmed: false`. */ export declare function terminateProcessTree(options: TerminateTreeOptions): Promise; //# sourceMappingURL=process-tree.d.ts.map