/** True if a process with `pid` is currently alive (signal-0 probe) AND not a * zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means * it exists but isn't ours — still alive (a foreign process is never OUR * zombie, so the `isZombie` check is skipped for that branch — see below). A * null/undefined pid (legacy / never-booted) reads dead. * * The zombie check matters because EVERY broker this module supervises is * spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a * dead broker whose spawning process hasn't reaped it yet is a zombie, and * `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard * (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an * already-live-looking row, so a refreshed/crashed node whose broker zombied * out under a long-lived spawning process (see `isZombie`'s doc) never comes * back on its own. */ export declare function isPidAlive(pid: number | null | undefined): boolean; /** THE single precision-aware identity compare, used EVERYWHERE two * `composeIdentity` fingerprints are compared (this module's own * `recordedPidLiveness`, `captureTeardownSnapshot`'s reuse check, and * `killProcessTreePids`'s reuse check) — centralized so no call site can * drift from this rule (review Major #1, Round 2 of the pid-reuse-liveness * fix). * * Two hazards this closes, both in the false-DEAD direction (the dangerous * one — a false DEAD lets `reviveNode`'s double-launch guard relaunch a * SECOND broker onto a still-live session): * * 1. `composeIdentity` appends the `#` Linux discriminator only when * `/proc//stat` happens to be readable at capture time — so the SAME * live process can be recorded plain `lstart` on one capture and * `lstart#ticks` on another (a transient `/proc` read failure, or * capturing on a platform/container without procfs). A naive `===` reads * that as a reuse (different string) and reports `dead` for a broker that * never died. * 2. `lstart` is NOT stable for a live process on every host. It is derived * from `/proc/stat` btime (wall-clock boot epoch) + starttime jiffies, so * a clock correction / suspend-resume shifts btime and re-anchors the * SAME live process's `lstart` by seconds between two captures. Observed * on Blaxel unikraft guests: pid+ticks identical across probes, `lstart` * drifting +81s — a base-first compare then reads the live home-node * broker as reused/dead every cycle, driving an uncapped revive loop. * * A third hazard the base itself once carried: `ticks` (jiffies-since-boot) * RESET on every boot and can repeat across boots, so ticks-alone is only * safe WITHIN one boot. `composeIdentity` therefore boot-SCOPES the base on * Linux-with-procfs — the base IS the kernel `boot_id` UUID — so a * new-format identity is `#` and equality means same boot AND * same process. This closes the cross-boot false match at the root, without * plumbing boot reconciliation ahead of every caller. * * Rule: split both on `#`. * - BOTH sides carry `#ticks` AND both bases are `boot_id` UUIDs * (new-format vs new-format): match iff base AND ticks are equal. A * different boot_id with equal ticks is NOT a match (the cross-boot * collision). * - BOTH sides carry `#ticks` but a base is NOT a boot_id UUID on EITHER * side — a LEGACY `lstart#ticks` row recorded before this repo boot-scoped * the base, or a Linux host that has procfs ticks but no readable boot_id: * ticks DECIDE it alone (the prior landed semantics). This is a NARROW * format-migration path so an in-place upgrade never false-kills a live * broker whose row still holds `lstart#ticks`; it is NOT a general * fallback — new-format vs new-format always demands base+ticks above. * - EITHER side lacks a valid `#ticks` suffix (macOS / no procfs / transient * `/proc` miss / malformed suffix): fall back to the coarse base — * matching iff the bases agree (fail-open to same-process, matching this * module's universal discipline: only a POSITIVE mismatch counts as reuse, * never an absence of evidence). */ export declare function identitiesMatch(a: string, b: string): boolean; /** Three-valued liveness verdict for a RECORDED node pid, identity-guarded * against PID REUSE using the launch-time baseline (`pi_pid_identity`, * captured by `recordPid`). On a heavily-forking host — esp. Linux, where * low pids recycle fast — a broker's recorded pid can be reused by an * unrelated process (or the `ps` probe confirming it can itself * intermittently fail) within milliseconds of the broker dying; collapsing * "confirmed alive", "confirmed dead/reused", and "couldn't confirm either * way" into one boolean forces every caller to fail open to ALIVE on the * third case — which, upstream in the daemon's grace-clock bookkeeping, is * worse than misreporting one tick: a single such read WIPES the daemon's * memory that the node had already been observed dead, restarting the whole * grace window from scratch (the confirmed Round-2 crash-revive wedge, in * the pre-fleet daemon's liveness bookkeeping). Exposing the third state lets callers * decide for themselves whether "can't confirm" should act like alive or * dead for their own direction of risk. * * Without a snapshot this preserves the historical per-pid probes. With a * snapshot it classifies process presence, zombie state, and identity from * that one authoritative table read: * - absent or zombie pid → `'dead'`. Load-bearing: a SIGKILLed detached * broker awaiting reap must still be revived rather than read as live. * - `expectedIdentity == null` (legacy row / launch capture failed) → * `'alive'` (preserve pre-identity behavior — no baseline to check * against). * - `ps` probe itself failed (`capturePidIdentities` → `null`) → * `'indeterminate'`. THIS is the branch the Round-2 fix changes: it used * to fail open to `true` (alive) here, which is the confirmed root cause * of the wedge. * - probe succeeded but has no row for this pid (alive per signal-0 a * moment ago, gone now) → `'dead'`. * - probe succeeded and the identity is present → `'alive'` iff * `identitiesMatch` says so, else `'dead'` (positively reused). */ export type RecordedPidLiveness = 'alive' | 'dead' | 'indeterminate'; export declare function recordedPidLiveness(pid: number | null | undefined, expectedIdentity: string | null | undefined, snapshot?: PsLivenessSnapshot | null): RecordedPidLiveness; /** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort, * swallowing ESRCH (already gone). Used by cron cancellation, timeout, and * stale-lease recovery. Lives beside isPidAlive: canvas/ is * the lowest shared layer every process-liveness/-teardown primitive sits at. * Guards `pid` before signaling (review nit, crouter#98): never hit pid 0 * (which means "my own group") or a negative/non-integer value. */ export declare function killProcessGroup(pid: number, signal?: NodeJS.Signals): void; /** The kernel `boot_id` (`/proc/sys/kernel/random/boot_id`) is stable for the * whole lifetime of this process — it only changes on a kernel boot, which * necessarily kills this process — so once a real value is read it is cached * for good, rather than re-read for every row of a whole-process-table `ps` * scan. Only SUCCESSFUL reads are cached forever: a `null` read is negative * for `BOOT_ID_NEGATIVE_TTL_MS`, after which the next call retries — so a * transient `/proc` miss on a boot_id-capable host still recovers (never * permanently downgraded to legacy `lstart#ticks`), while a genuinely * no-boot_id host is not hammered with a synchronous throwing read on every * identity composition. `now` is injectable for tests. */ export declare function makeBootIdCache(read: () => string | null, now?: () => number, negativeTtlMs?: number): () => string | null; /** Is `identity` a LEGACY (pre-boot-scoped) baseline — a `#` row * whose base is NOT a boot_id UUID but which DOES carry ticks? These are the * rows the one-time startup migration re-records once this platform can * compose boot-scoped identities (see `migrateLegacyPidIdentities`). */ export declare function isLegacyTicksIdentity(identity: string): boolean; /** Is `identity` a NEW-format boot-scoped baseline — `#` with a * real boot_id UUID base AND ticks? The startup migration only re-records a * legacy row when the pid's CURRENT identity comes out in this shape (i.e. the * platform now composes new-format), never otherwise. */ export declare function isBootScopedIdentity(identity: string): boolean; /** Compose the stable, BOOT-SCOPED process-identity fingerprint used * everywhere in this module. ALWAYS used to build an identity string, never a * raw field directly, so every identity captured anywhere (launch-time * `recordPid`, the teardown snapshot, and the escalation-window re-check) is * comparable apples-to-apples. * * Platform capability, not layered fallbacks — the base is the finest STABLE * per-boot anchor available: * - Linux-with-procfs (the production guest shape): `#` — the * per-boot `boot_id` UUID plus the per-process jiffies-since-boot. Ticks * reset per boot and can repeat, so scoping the base to the boot is what * makes an equal-ticks compare safe ACROSS boots (see `identitiesMatch`). * - Linux-with-procfs but no readable `boot_id` (if that combination * exists): `#` — the prior landed behavior. * - No procfs ticks (macOS / containers without procfs): `` alone — * unchanged coarse behavior. The base is only ever a `boot_id` when a * `ticks` discriminator is ALSO present; a bare `boot_id` (shared by every * process on the boot) would be a useless per-process identity. */ export declare function composeIdentity(pid: number, lstart: string, deps?: { ticks?: string | null; bootId?: string | null; }): string; /** One authoritative process-table sample. Supervision, broker census, * teardown tree walks, identity guards, and zombie detection can all classify * from this one read instead of spawning a `ps` probe per pid. `null` means the * probe itself failed; callers preserve that as unknown rather than absence. */ export interface PsLivenessProcess { zombie: boolean; identity: string; command: string; } export interface PsLivenessSnapshot { processes: ReadonlyMap; childrenOf: ReadonlyMap; } export declare function captureLivenessSnapshot(): PsLivenessSnapshot | null; /** Every transitive descendant of `rootPid`, discovered by walking `ps`'s * pid/ppid table (BFS) — NOT process-GROUP membership. This is the fix for * crouton-labs/crouter#98: the pi SDK's bash tool spawns its shell child with * `detached: true` (verified in `@earendil-works/pi-agent-core`'s * `harness/env/nodejs.js`), which calls `setsid()` and puts that child in a * brand-new process GROUP of its own — so `kill(-rootPid)` never reaches it. * `setsid()` changes only SID/PGID, NEVER ppid, so the child (and its own * descendants) is still discoverable by walking parentage from `rootPid`, as * long as `rootPid`'s OS process is still alive when we walk (a fully-dead * parent is reparented away by the kernel immediately on exit, severing this * link — the wedged-but-alive broker this fix targets never hits that). Best- * effort: returns `[]` (never throws) on any `ps` failure, exactly like * `killProcessGroup`'s ESRCH-swallowing. */ export declare function descendantPids(rootPid: number): number[]; /** Capture a portable process-identity fingerprint — `ps`'s `lstart` column, * the process's full wall-clock start time — for each of `pids`, in ONE * batched `ps -p ` call. `lstart` is a standard format keyword on BOTH * BSD ps (macOS) and procps-ng (Linux), so this needs no `/proc` parsing or * other Linux-only machinery. Used to guard against PID REUSE during the * multi-second teardown escalation ladder (crouter#98 review finding 3): a * captured pid can exit and the OS can recycle it for an unrelated process * before the SIGKILL rung fires, and a bare `kill(pid, 0)` / `kill(pid, sig)` * cannot tell the difference. * * Returns `null` — NOT an empty map — when the `ps` PROBE ITSELF failed (it * could not be spawned, or was killed by the timeout): a probe failure means * we have NO information, which is a fundamentally different outcome from a * probe that SUCCEEDED and simply found no row for a given pid (that pid is * genuinely gone). Collapsing these two into the same empty map was the * final-review bug (crouter#98 signal-safety finding): `killProcessTreePids` * cannot tell "pid confirmed gone" from "we don't know" without this * distinction, and misreading a failed probe as positive proof of * absence/reuse either signals a reused stranger or — worse — silently skips * reaping a real live orphan. A successful call may still return a map with * no entry for some/all of `pids` (those pids have no current row — genuinely * gone); that is the valid empty-or-partial case and is NOT a failure. * * EXIT STATUS IS NOT THE FAILURE SIGNAL. `ps` exits 1 — on BOTH BSD ps and * procps-ng — when it matched no process at all, which is precisely the * "probe succeeded, that pid is gone" case, and exits 1 with PARTIAL stdout * when only some of a batch's pids still exist. Reading a non-zero exit as a * probe failure therefore turned every confirmed-dead pid into * `'indeterminate'` (`recordedPidLiveness`), so a just-killed broker read as * "can't confirm" — never as dead. Callers that fail open on indeterminate * (`ensureAttach`'s revive decision, the daemon's exit policy) then treat a * dead broker as live forever. Discriminate on whether the PROCESS RAN * (`r.error`/`r.signal`) and whether it complained (`stderr`), never on what * it exited with: a real argument/usage failure writes a diagnostic to stderr * (`ps: process id too large`), while "matched nothing" is silent. */ export declare function capturePidIdentities(pids: readonly number[]): Map | null; /** Read one pid's current full command line for a destructive caller that * must freshly re-confirm process ownership immediately before signaling. * Failure and absence both return null, so uncertainty never authorizes a * signal. */ export declare function capturePidCommand(pid: number): string | null; /** The full pre-signal snapshot `teardown()` needs, taken ONCE, from ONE `ps` * table (crouter#98 final review, Fix 1): the process TREE rooted at `rootPid` * (itself + every transitive descendant) alongside each member's identity * fingerprint — PLUS, load-bearing, a launch-time-identity REUSE check on * `rootPid` itself before the tree is even walked. * * The hazard this closes: the PID-reuse guard elsewhere in this module (see * `killProcessTreePids`) only ever compared identities captured across the * teardown ESCALATION window (a few seconds) — it had no idea whether * `rootPid` (the broker pid recorded on the node row at LAUNCH time, possibly * hours or days earlier) had ALREADY been recycled for an unrelated process * before `teardown()` was ever called. In that case the naive snapshot would * capture the STRANGER's current identity as the "expected" baseline and * cheerfully SIGTERM/SIGKILL it and whatever the stranger's OWN process tree * turns out to be — exactly the wrong-victim risk finding 1 targets. * * `expectedIdentity` is the LAUNCH-time identity recorded on the node row * (`recordPid`'s `capturePidIdentities([pid])` snapshot, taken the instant the * broker was spawned/bound) — `null` for a node booted before this field * existed, or if that original capture itself failed. When `expectedIdentity` * is present AND the CURRENT probe for `rootPid` succeeds AND finds a DIFFERENT * identity, `rootPid` is provably a different OS process than the broker this * node ever launched: `reused: true`, `tree: []`, `identities: null` — the * caller must treat this exactly like "nothing to tear down", and must NOT * walk `descendantPids` from this poisoned root (those "descendants" would be * the STRANGER's children, not this node's). Every other case (no baseline, * the pid is simply gone, the probe itself failed) falls through to the * normal walk — fail-open, matching every other guard in this module: a * MISMATCH is the only thing that skips, never an absence of evidence. */ export interface TeardownSnapshot { /** `[rootPid, ...descendants]`, or `[]` when `reused` is true (nothing safe * to signal) or `rootPid` itself is invalid. */ tree: number[]; /** Identity fingerprint per pid in `tree`, from the SAME `ps` table read as * the tree walk — or `null` when that probe failed outright (fail-open: the * caller's downstream `killProcessTreePids` signals unguarded in that case, * same as an omitted `identities` arg). Always `null` when `reused` is true * (there is no tree to guard). */ identities: Map | null; /** True iff `rootPid`'s CURRENT identity is POSITIVELY known to differ from * `expectedIdentity` — the pid was already reused by an unrelated process * before this snapshot was taken. Callers must skip ALL signaling when true. */ reused: boolean; } export declare function captureTeardownSnapshot(rootPid: number, expectedIdentity: string | null): TeardownSnapshot; /** Best-effort signal against every pid in an ALREADY-CAPTURED tree snapshot * (typically `[rootPid, ...descendantPids(rootPid)]`, taken ONCE by the * caller before sending any signal — see the doc on `isProcessTreeAlive` for * why re-deriving via `ps` after signaling starts is unsafe). Signals EACH * pid's OWN process group (a detached SDK-spawned descendant is its own * group leader — the pi bash tool's shell child, crouton-labs/crouter#98) * PLUS a plain `kill(pid)` on each (belt-and-suspenders for a pid that is * NOT its own group leader). Every signal is best-effort — `killProcessGroup` * swallows ESRCH and the plain `kill` here does too — so firing this against * an already-fully-dead snapshot is a harmless no-op. * * `identities`, when supplied (crouter#98 review finding 3), is a * `capturePidIdentities` snapshot taken alongside the pid list at the SAME * moment — `null` if that INITIAL capture's `ps` probe itself failed (see * `capturePidIdentities`'s doc). Before signaling EITHER the group or the * plain kill for a pid, this re-derives CURRENT identities in one fresh * batched call and compares — but ONLY skips on POSITIVE proof of reuse; any * failure or absence of evidence falls through to the normal signal path * (fail-open, matching this module's pre-guard behavior — final review, * crouter#98 signal-safety finding): * * - baseline identity present AND the current probe SUCCEEDED AND the * current identity for this pid is present AND DIFFERENT — the OS has * recycled this pid for an unrelated process since the snapshot — SKIP * (the sole skip condition; this is the actual reused-pid hazard finding * 3 targets). * - baseline identity present but the pid is now simply GONE (current * probe succeeded, no row for this pid) — it died naturally in the * meantime, not reused — SIGNAL as normal (harmless no-op on the plain * kill; load-bearing for the group kill, which can still reach a * surviving process that shares this now-dead pid's process GROUP). * - baseline had NO identity at all for this pid (the pid was ALREADY dead * at snapshot time — e.g. a broker that crashed before `teardown()` ever * ran) — SIGNAL as normal regardless of the current probe: this is * exactly how `killProcessGroup` on an already-dead root still reaches a * live process sharing its process group (the pgid persists after the * group leader exits; see `host-teardown-process-group.test.ts`'s "dead * broker" case). * - the INITIAL capture's probe failed outright (`identities === null`) — * we have NO baseline to compare against for ANY pid — SIGNAL as normal * for the whole list; the CURRENT probe is not even attempted (nothing * to compare it to). * - the baseline succeeded but the CURRENT probe (taken here) fails — we * have NO current information — SIGNAL as normal for the whole list; a * failed probe must never be read as "gone" (would risk reaching a * reused stranger, the residual risk this guard cannot close) NOR as * "reused" (would wrongly disable the reap for every real live pid). * * Callers that omit `identities` entirely (pre-existing call sites) keep the * prior unguarded behavior. */ export declare function killProcessTreePids(pids: readonly number[], signal?: NodeJS.Signals, identities?: ReadonlyMap | null): void; /** Is `rootPid`'s process TREE — itself or any CURRENT descendant — still * alive, re-deriving the descendant set fresh via `ps` on every call? Safe * ONLY before any signal has been sent to this tree. Once a signal kills the * root (it's typically its own process-group leader — see `launch()` in * host.ts), the kernel reparents any surviving child away from `rootPid` * IMMEDIATELY (standard orphan handling, confirmed empirically: a killed * parent's child shows `ppid=1` within the same tick, not lazily) — * silently "losing" that child from a fresh `descendantPids` walk even * though it is still very much alive. Use this ONLY to watch a tree you have * not touched yet (e.g. waiting out a graceful-shutdown grace window); once * you start signaling, capture the pid list ONCE and check it with * `isAnyPidAlive` instead — see `killProcessTreePids`. */ export declare function isProcessTreeAlive(rootPid: number): boolean; /** Is any pid in an ALREADY-CAPTURED pid list (see `killProcessTreePids`) * still alive? Pure signal-0 check against the fixed list — NEVER re-walks * `ps` — the correct liveness check once you've started signaling a tree * (see `isProcessTreeAlive`'s doc for why re-deriving after that point would * silently drop a still-alive orphan). */ export declare function isAnyPidAlive(pids: readonly number[]): boolean;