mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
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:
parent
56a1ac4b06
commit
8a981770fe
3 changed files with 130 additions and 15 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue