/**
* 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