openclaw/test/scripts/managed-child-process.termination.test.ts
Peter Steinberger 8a981770fe
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.
2026-09-30 18:04:04 -07:00

219 lines
7.4 KiB
TypeScript

import { ChildProcess, spawnSync } from "node:child_process";
import { once } from "node:events";
import { PassThrough } from "node:stream";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runManagedCommand } from "../../scripts/lib/managed-child-process.mts";
const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() }));
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawn,
spawnSync: vi.fn(),
}));
afterEach(() => vi.restoreAllMocks());
function createChild() {
const child = new ChildProcess();
let exitCode: number | null = null;
Object.defineProperties(child, {
pid: { value: 12345 },
exitCode: { get: () => exitCode },
stdout: { value: null, writable: true },
stderr: { value: null, writable: true },
});
return {
child,
exit: () => {
exitCode = 0;
child.emit("exit", 0, null);
},
};
}
describe("managed child termination facts", () => {
it.each([
{ phase: "inspection", rows: "12345 Z\n12345 ZN\n", accepted: true },
{ phase: "signal", rows: "12345 Z\n", accepted: true },
{ phase: "inspection", rows: "", reaped: true, accepted: true },
{ phase: "signal", rows: "", failed: true, reaped: true, accepted: true },
{ phase: "inspection", rows: "12345 Z\n12345 S\n", accepted: false },
{ phase: "inspection", rows: "", accepted: false },
{ phase: "inspection", rows: "23456 Z\n", accepted: false },
{ phase: "inspection", rows: "12345 Z\n", failed: true, accepted: false },
])(
"verifies Darwin EPERM at $phase against all group members ($rows, failed=$failed, reaped=$reaped)",
async ({ phase, rows, failed, reaped, accepted }) => {
const { child, exit } = createChild();
spawn.mockReturnValue(child);
vi.spyOn(child, "kill").mockReturnValue(false);
let signaled = false;
let inspected = false;
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
vi.spyOn(process, "kill").mockImplementation((_pid, signal) => {
if (signal !== 0) {
signaled = true;
if (!accepted) {
now += 100;
}
}
if (signal === 0 && signaled && !inspected) {
now += 100;
}
if (phase === "signal" && !signaled) {
return true;
}
throw Object.assign(new Error("group signal denied"), {
code: reaped && inspected ? "ESRCH" : "EPERM",
});
});
vi.mocked(spawnSync).mockImplementation(() => {
inspected = true;
return {
pid: 12346,
output: [],
stdout: rows,
stderr: "",
status: failed ? 1 : 0,
signal: null,
};
});
const command = runManagedCommand({
bin: "fixture",
platform: "darwin",
shell: false,
stdio: "ignore",
env: { TMPDIR: process.cwd() },
requireProcessTreeExit: true,
cleanupDrainTimeoutMs: 50,
onReady: exit,
});
if (accepted) {
await expect(command).resolves.toBe(0);
} else {
await expect(command).rejects.toMatchObject({ code: "EPROCESSGROUP_CLEANUP_FAILED" });
}
},
);
it.each(["SIGTERM", "SIGKILL"])(
"retains POSIX %s failures through cleanup",
async (failedSignal) => {
const { child } = createChild();
const groupError = Object.assign(new Error("group signal denied"), { code: "EPERM" });
const childError = Object.assign(new Error("child signal denied"), { code: "EACCES" });
const kill = vi.spyOn(child, "kill").mockImplementation((signal) => {
if (signal === failedSignal) {
throw childError;
}
return true;
});
const signalProcess = vi.spyOn(process, "kill").mockImplementation((_pid, signal) => {
if (signal === failedSignal) {
throw groupError;
}
return true;
});
spawn.mockReturnValue(child);
const abort = new AbortController();
await expect(
runManagedCommand({
bin: "fixture",
platform: "linux",
shell: false,
stdio: "ignore",
env: { TMPDIR: process.cwd() },
signal: abort.signal,
abortKillGraceMs: 0,
cleanupDrainTimeoutMs: 0,
onReady: () => abort.abort(),
}),
).rejects.toMatchObject({
code: "EPROCESSGROUP_CLEANUP_FAILED",
cause: { errors: [groupError, childError] },
});
expect(kill).toHaveBeenCalledWith(failedSignal);
expect(signalProcess).toHaveBeenCalledWith(-12345, "SIGKILL");
},
);
it.each(["exit", "missing PID", "alive"] as const)(
"retains an unowned Windows tree after taskkill status 255 (%s)",
async (state) => {
const { child, exit } = createChild();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
const kill = vi.spyOn(child, "kill").mockReturnValue(true);
vi.spyOn(process, "kill").mockImplementation(() => {
if (state === "missing PID") {
throw Object.assign(new Error("process is gone"), { code: "ESRCH" });
}
return true;
});
spawn.mockReturnValue(child);
const abort = new AbortController();
const outputClosed = Promise.all([once(child.stdout, "close"), once(child.stderr, "close")]);
const runTaskkill = vi.fn(() => {
if (state === "exit") {
exit();
}
return {
status: 255,
stdout: Buffer.from("taskkill attempted the owned tree"),
stderr: Buffer.from("taskkill could not find the task"),
};
});
try {
const completed = runManagedCommand({
bin: "fixture",
platform: "win32",
shell: false,
// IPC callers bypass Job admission and exercise the unowned-tree contract.
stdio: ["ignore", "pipe", "pipe", "ipc"],
// The synthetic child has no filesystem resources to retain on failure.
env: { TMPDIR: process.cwd() },
runTaskkill,
signal: abort.signal,
onReady: () => abort.abort(),
});
// Output may close after the synchronous termination attempt returns.
if (state !== "alive") {
setImmediate(() => {
if (state === "missing PID") {
exit();
}
child.stdout?.destroy();
child.stderr?.destroy();
child.emit("close", 0, null);
});
await expect(completed).rejects.toMatchObject({
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "indeterminate",
});
expect(runTaskkill).toHaveBeenCalledOnce();
expect(kill).not.toHaveBeenCalled();
} else {
await expect(completed).rejects.toMatchObject({
code: "EPROCESSGROUP_CLEANUP_FAILED",
processTreeState: "indeterminate",
cause: {
message: expect.stringContaining('"status":255'),
taskkill: expect.arrayContaining([
expect.objectContaining({
status: 255,
stdout: "taskkill attempted the owned tree",
stderr: "taskkill could not find the task",
}),
]),
},
});
}
} finally {
child.stdout.destroy();
child.stderr.destroy();
await outputClosed;
}
},
);
});