fix(tooling): verify Darwin zombie groups after EPERM

Darwin killpg excludes zombies and returns EPERM when no signalable group
member remains. Strict normal-exit cleanup mistook this terminal state for
a surviving process group, even when later drainage observed termination.

Reuse the existing zombie census for Darwin, including BSD state flags,
and reconcile a group reaped during the census with a fresh ESRCH probe.
Both observation and termination require positive completion evidence;
live, mixed, and uninspectable groups retain their cleanup failures.
Keep snapshot work inside the existing escalation and drainage deadlines.

Native same-uid Z and ZN groups reproduce EPERM for signal 0 and SIGKILL.
The original owner fails four regression cases; the repair passes all 13.
Shared owner/output tests pass (146 passed, one platform skip). The full
mac-elevation-host shard passes all 123 cases in 383.63s; final metadata
replay passes all 13 selected cases. Independent review is scoped-clean.
Production delta: +44 lines for bounded Darwin termination evidence.

Test cost: node scripts/run-vitest.mjs test/scripts/managed-child-process.termination.test.ts --maxWorkers=1: 5.83s wall; node scripts/run-vitest.mjs test/scripts/managed-child-process.tree.test.ts --maxWorkers=1: 5.05s wall.
This commit is contained in:
Peter Steinberger 2026-09-30 17:58:03 -07:00
parent 56a1ac4b06
commit 8a981770fe
No known key found for this signature in database
3 changed files with 130 additions and 15 deletions

View file

@ -53,6 +53,7 @@ type TaskkillRunner = (
}
| undefined;
type ManagedChildTerminationOptions = {
deadlineAt?: number;
onChildSignalError?: (error: unknown) => void;
onProcessGroupSignalError?: (error: unknown) => void;
platform?: NodeJS.Platform;
@ -194,6 +195,7 @@ export function terminateManagedChild(
child: ManagedProcessGroupChild & { kill(signal: NodeJS.Signals): unknown },
signal: NodeJS.Signals = "SIGTERM",
{
deadlineAt,
onChildSignalError,
onProcessGroupSignalError,
platform = process.platform,
@ -227,6 +229,9 @@ export function terminateManagedChild(
return { processTreeState: "signaled" };
}
} catch (error) {
if (isExitedDarwinGroup(child, platform, error, deadlineAt)) {
return { processTreeState: "terminated" };
}
processGroupIsMissing = isMissingProcessError(error);
if (!processGroupIsMissing) {
onProcessGroupSignalError?.(error);
@ -372,7 +377,7 @@ export function inspectManagedProcessGroup(
try {
process.kill(-pid, 0);
if (platform === "linux" && (child.exitCode != null || child.signalCode != null)) {
if (isLinuxZombieProcessGroup(pid, deadlineAt)) {
if (isZombieProcessGroup(pid, platform, deadlineAt)) {
return "dead";
}
// The group may be reaped while ps runs. Recheck kernel existence without
@ -384,13 +389,47 @@ export function inspectManagedProcessGroup(
if (isMissingProcessError(error)) {
return "dead";
}
if (isExitedDarwinGroup(child, platform, error, deadlineAt)) {
return "dead";
}
return errorPolicy === "alive-on-eperm" && hasProcessErrorCode(error, "EPERM")
? "live"
: "indeterminate";
}
}
function isLinuxZombieProcessGroup(pid: number, deadlineAt?: number): boolean {
function isExitedDarwinGroup(
child: ManagedProcessGroupChild,
platform: NodeJS.Platform,
error: unknown,
deadlineAt?: number,
): boolean {
if (
platform !== "darwin" ||
!hasProcessErrorCode(error, "EPERM") ||
!child.pid ||
(child.exitCode == null && child.signalCode == null)
) {
return false;
}
// XNU killpg skips zombies and returns EPERM when none are signalable.
// Require a zombie-only census or kernel-confirmed disappearance during ps.
if (isZombieProcessGroup(child.pid, platform, deadlineAt)) {
return true;
}
try {
process.kill(-child.pid, 0);
} catch (probeError) {
return isMissingProcessError(probeError);
}
return false;
}
function isZombieProcessGroup(
pid: number,
platform: NodeJS.Platform,
deadlineAt?: number,
): boolean {
const timeout =
deadlineAt === undefined
? PROCESS_GROUP_DRAIN_TIMEOUT_MS
@ -404,13 +443,16 @@ function isLinuxZombieProcessGroup(pid: number, deadlineAt?: number): boolean {
// which cannot write or respond to signals while awaiting their parent's reap.
// Enumerate threads (-L): a process row reports only the group leader's state,
// and a pthread_exit leader reads Z while sibling threads still run and write.
const result = spawnSync("ps", ["-s", String(pid), "-L", "-o", "pgid=,state="], {
const selection = platform === "darwin" ? ["-g", String(pid)] : ["-s", String(pid), "-L"];
const result = spawnSync("ps", [...selection, "-o", "pgid=,state="], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout,
killSignal: "SIGKILL",
});
const zombie = new RegExp(`^\\s*${pid}\\s+Z\\s*$`, "u");
// BSD ps appends flags (for example ZN for a niced zombie); Linux state is one letter.
const state = platform === "darwin" ? "Z[+<>AELNSsVWX]*" : "Z";
const zombie = new RegExp(`^\\s*${pid}\\s+${state}\\s*$`, "u");
// Missing, failed or unrecognized snapshots never certify completion.
return (
!result.error &&
@ -796,6 +838,8 @@ export async function finalizeManagedChild(
}
};
const terminationOptions = {
// Cancellation's loop owns observation time; do not delay its force-kill boundary here.
deadlineAt: signal ? startedAt : startedAt + drainTimeoutMs,
platform,
runTaskkill,
onChildSignalError: recordSignalError,
@ -910,7 +954,7 @@ export async function finalizeManagedChild(
if (!forced && (now >= forceAt || (forceKillOnLeaderExit && exited))) {
forced = true;
if (groupState !== "dead") {
terminateManagedChild(child, "SIGKILL", terminationOptions);
terminateManagedChild(child, "SIGKILL", { ...terminationOptions, deadlineAt: deadline });
}
}
if (now >= deadline) {