mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 10:10:01 +00:00
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.
395 lines
14 KiB
TypeScript
395 lines
14 KiB
TypeScript
import { ChildProcess, spawnSync } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { PassThrough } from "node:stream";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import {
|
|
hasUnjoinedWork,
|
|
inspectManagedProcessGroup,
|
|
runManagedCommand,
|
|
terminateManagedChild,
|
|
waitForManagedProcessGroupExit,
|
|
} from "../../scripts/lib/managed-child-process.mts";
|
|
import { createVitestResourceOwner } from "../../scripts/lib/vitest-resource-ownership.mts";
|
|
import { createDeferred } from "../helpers/promise.js";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const mocks = vi.hoisted(() => ({ spawn: vi.fn(), spawnWindowsJobChild: vi.fn() }));
|
|
vi.mock("node:child_process", async (original) => ({
|
|
...(await original<typeof import("node:child_process")>()),
|
|
spawn: mocks.spawn,
|
|
spawnSync: vi.fn(),
|
|
}));
|
|
vi.mock("../../scripts/lib/managed-windows-job.mts", () => ({
|
|
spawnWindowsJobChild: mocks.spawnWindowsJobChild,
|
|
}));
|
|
const dirs = useAutoCleanupTempDirTracker(afterEach);
|
|
afterEach(() => vi.restoreAllMocks());
|
|
|
|
it("cancels admission while Windows platform code loads without spawning or retaining a claim", async () => {
|
|
const root = dirs.make("managed-platform-cancel-");
|
|
const owner = createVitestResourceOwner(root);
|
|
mocks.spawn.mockClear();
|
|
mocks.spawnWindowsJobChild.mockClear();
|
|
const abort = new AbortController();
|
|
const command = runManagedCommand({
|
|
bin: "fixture",
|
|
platform: "win32",
|
|
signal: abort.signal,
|
|
env: { TMPDIR: root },
|
|
});
|
|
abort.abort();
|
|
await expect(command).rejects.toMatchObject({ name: "AbortError" });
|
|
expect(mocks.spawn).not.toHaveBeenCalled();
|
|
expect(mocks.spawnWindowsJobChild).not.toHaveBeenCalled();
|
|
owner.assertReleased();
|
|
});
|
|
|
|
it("preserves deferred spawn errors and releases ownership when output streams were never created", async () => {
|
|
const root = dirs.make("managed-spawn-no-streams-");
|
|
const owner = createVitestResourceOwner(root);
|
|
const child = new ChildProcess();
|
|
const failure = Object.assign(new Error("file descriptor limit reached"), { code: "EMFILE" });
|
|
// Node returns before creating stdout/stderr on EMFILE/ENFILE, then emits error.
|
|
const emitted = once(child, "error");
|
|
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
|
|
const listeners = signals.map((signal) => process.listenerCount(signal));
|
|
mocks.spawn.mockImplementation(() => {
|
|
process.nextTick(() => child.emit("error", failure));
|
|
return child;
|
|
});
|
|
const outcome = runManagedCommand({
|
|
bin: "fixture",
|
|
platform: "darwin",
|
|
shell: false,
|
|
stdio: "pipe",
|
|
env: { TMPDIR: root },
|
|
}).catch((error: unknown) => error);
|
|
await emitted;
|
|
expect(await outcome).toBe(failure);
|
|
owner.assertReleased();
|
|
expect(signals.map((signal) => process.listenerCount(signal))).toEqual(listeners);
|
|
});
|
|
|
|
it("preserves requested command inputs across Windows platform loading", async () => {
|
|
const root = dirs.make("managed-platform-inputs-");
|
|
const child = new ChildProcess();
|
|
Object.defineProperties(child, {
|
|
pid: { value: 12345 },
|
|
exitCode: { value: 0 },
|
|
stdout: { value: null, writable: true },
|
|
stderr: { value: null, writable: true },
|
|
});
|
|
let launched: { argument?: string; value?: string } | undefined;
|
|
mocks.spawnWindowsJobChild.mockImplementation((_command, args, options) => {
|
|
launched = { argument: args[0], value: options.env.VALUE };
|
|
return {
|
|
child,
|
|
job: { inspect: () => [], beginStop() {}, stop() {}, close() {} },
|
|
};
|
|
});
|
|
const args = ["original"];
|
|
const env = { TMPDIR: root, VALUE: "original" };
|
|
const command = runManagedCommand({
|
|
bin: "fixture",
|
|
args,
|
|
env,
|
|
platform: "win32",
|
|
shell: false,
|
|
onReady: () => {
|
|
child.emit("exit", 0, null);
|
|
child.emit("close", 0, null);
|
|
},
|
|
});
|
|
args[0] = "mutated";
|
|
env.VALUE = "mutated";
|
|
await expect(command).resolves.toBe(0);
|
|
expect(launched).toEqual({ argument: "original", value: "original" });
|
|
});
|
|
|
|
it("does not certify an unavailable POSIX group observation when its wait expires", async () => {
|
|
vi.spyOn(process, "kill").mockImplementation(() => {
|
|
throw Object.assign(new Error("group observation unavailable"), { code: "EIO" });
|
|
});
|
|
await expect(
|
|
waitForManagedProcessGroupExit({ pid: 12345 }, 0, {
|
|
platform: "darwin",
|
|
errorPolicy: "indeterminate",
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
it.each(["returned false", "ESRCH"])(
|
|
"does not promote POSIX leader disappearance (%s) after denied group signaling",
|
|
(result) => {
|
|
const child = {
|
|
pid: 12345,
|
|
kill: vi.fn(() => {
|
|
if (result === "ESRCH") {
|
|
throw Object.assign(new Error("leader is gone"), { code: "ESRCH" });
|
|
}
|
|
return false;
|
|
}),
|
|
};
|
|
vi.spyOn(process, "kill").mockImplementation(() => {
|
|
throw Object.assign(new Error("group signal denied"), { code: "EPERM" });
|
|
});
|
|
expect(terminateManagedChild(child, "SIGTERM", { platform: "darwin" })).toEqual({
|
|
processTreeState: "indeterminate",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([false, true])(
|
|
"retains signal failures after strict POSIX cleanup joins (leader signal fails: %s)",
|
|
async (leaderSignalFails) => {
|
|
const root = dirs.make("managed-joined-diagnostics-");
|
|
const owner = createVitestResourceOwner(root);
|
|
const child = new ChildProcess();
|
|
Object.defineProperties(child, { pid: { value: 12345 }, exitCode: { value: 0 } });
|
|
// spawn with ignored stdio returns null streams, unlike an unspawned ChildProcess.
|
|
child.stdout = null;
|
|
child.stderr = null;
|
|
const groupError = Object.assign(new Error("group signal denied"), { code: "EPERM" });
|
|
const leaderError = Object.assign(new Error("leader signal denied"), { code: "EACCES" });
|
|
mocks.spawn.mockReturnValue(child);
|
|
let fallbackAttempted = false;
|
|
vi.mocked(spawnSync).mockReturnValue({
|
|
pid: 12346,
|
|
output: [],
|
|
status: 1,
|
|
signal: null,
|
|
stdout: "",
|
|
stderr: "",
|
|
});
|
|
const leaderSignal = vi.spyOn(child, "kill").mockImplementation(() => {
|
|
fallbackAttempted = true;
|
|
if (leaderSignalFails) {
|
|
throw leaderError;
|
|
}
|
|
return false;
|
|
});
|
|
const groupSignal = vi.spyOn(process, "kill").mockImplementation((_pid, received) => {
|
|
if (received === 0) {
|
|
throw Object.assign(new Error("group observation"), {
|
|
code: fallbackAttempted ? "ESRCH" : "EPERM",
|
|
});
|
|
}
|
|
throw groupError;
|
|
});
|
|
|
|
await expect(
|
|
runManagedCommand({
|
|
bin: "fixture",
|
|
platform: "darwin",
|
|
shell: false,
|
|
stdio: "ignore",
|
|
requireProcessTreeExit: true,
|
|
env: { TMPDIR: root },
|
|
onReady: () => {
|
|
child.emit("exit", 0, null);
|
|
child.emit("close", 0, null);
|
|
},
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: "EPROCESSGROUP_CLEANUP_FAILED",
|
|
processGroupId: 12345,
|
|
processTreeState: "terminated",
|
|
cause: expect.objectContaining({
|
|
name: "AggregateError",
|
|
errors: leaderSignalFails ? [groupError, leaderError] : [groupError],
|
|
}),
|
|
});
|
|
expect(groupSignal).toHaveBeenCalledWith(-12345, "SIGKILL");
|
|
expect(leaderSignal).toHaveBeenCalledExactlyOnceWith("SIGKILL");
|
|
owner.assertReleased();
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["win32", true, true],
|
|
["win32", false, true],
|
|
["darwin", true, true],
|
|
["darwin", false, true],
|
|
["win32", true, false],
|
|
] as const)(
|
|
"finalizes normal leader exit on %s (termination succeeds: %s, handle closes: %s)",
|
|
async (platform, terminates, closes) => {
|
|
const root = dirs.make("managed-normal-exit-");
|
|
const owner = createVitestResourceOwner(root);
|
|
const child = new ChildProcess();
|
|
Object.defineProperties(child, { pid: { value: 12345 }, exitCode: { value: 0 } });
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
const closed = Promise.all([once(child.stdout, "close"), once(child.stderr, "close")]);
|
|
const descendantOutput = new PassThrough();
|
|
const stopSurvivor = () => {
|
|
if (!terminates) {
|
|
throw Object.assign(new Error("survivor termination denied"), { code: "EPERM" });
|
|
}
|
|
descendantOutput.destroy();
|
|
};
|
|
let jobClosed = false;
|
|
const job = {
|
|
inspect: vi.fn(() => {
|
|
if (jobClosed) {
|
|
throw new Error("Job handle is closed");
|
|
}
|
|
return descendantOutput.destroyed ? [] : [23456];
|
|
}),
|
|
beginStop: vi.fn(),
|
|
stop: vi.fn(stopSurvivor),
|
|
close: vi.fn(() => {
|
|
if (!closes) {
|
|
throw new Error("CloseHandle failed");
|
|
}
|
|
jobClosed = true;
|
|
}),
|
|
};
|
|
mocks.spawn.mockReturnValue(child);
|
|
mocks.spawnWindowsJobChild.mockReturnValue(platform === "win32" ? { child, job } : undefined);
|
|
vi.spyOn(child, "kill").mockReturnValue(false);
|
|
const signal = vi.spyOn(process, "kill").mockImplementation((pid, received) => {
|
|
expect(pid).toBe(-12345);
|
|
if (descendantOutput.destroyed) {
|
|
throw Object.assign(new Error("group gone"), { code: "ESRCH" });
|
|
}
|
|
if (received !== 0) {
|
|
stopSurvivor();
|
|
}
|
|
return true;
|
|
});
|
|
const warning = vi.spyOn(process, "emitWarning").mockImplementation(() => {});
|
|
try {
|
|
const outcome = await runManagedCommand({
|
|
bin: "fixture",
|
|
platform,
|
|
shell: false,
|
|
stdio: "pipe",
|
|
requireProcessTreeExit: platform !== "win32",
|
|
cleanupDrainTimeoutMs: 0,
|
|
env: { TMPDIR: root },
|
|
onReady: () => {
|
|
// Real spawn returns before pipe close events. Close before leader exit,
|
|
// but only after the command has acquired and observed its output.
|
|
child.stdout?.destroy();
|
|
child.stderr?.destroy();
|
|
void closed.then(() => {
|
|
child.emit("exit", 0, null);
|
|
child.emit("close", 0, null);
|
|
});
|
|
},
|
|
}).catch((error: unknown) => error);
|
|
if (platform === "win32") {
|
|
expect(job.stop).toHaveBeenCalledOnce();
|
|
expect(job.close).toHaveBeenCalledOnce();
|
|
if (!terminates) {
|
|
expect(outcome).toMatchObject({ survivingPids: [23456] });
|
|
expect(warning).toHaveBeenCalledWith(
|
|
expect.objectContaining({ survivingPids: [23456], processTreeState: "indeterminate" }),
|
|
);
|
|
}
|
|
expect(inspectManagedProcessGroup(child, { platform, errorPolicy: "indeterminate" })).toBe(
|
|
terminates ? "dead" : "indeterminate",
|
|
);
|
|
} else {
|
|
expect(signal).toHaveBeenCalledWith(-12345, "SIGKILL");
|
|
}
|
|
if (terminates && closes) {
|
|
expect(descendantOutput.destroyed).toBe(true);
|
|
owner.assertReleased();
|
|
// POSIX strict normal-exit policy still reports unexpected group survivors.
|
|
if (platform === "win32") {
|
|
expect(outcome).toBe(0);
|
|
} else {
|
|
expect(outcome).toMatchObject({ processTreeState: "terminated" });
|
|
}
|
|
} else {
|
|
expect(hasUnjoinedWork(outcome)).toBe(true);
|
|
expect(outcome).toMatchObject({ code: "EPROCESSGROUP_CLEANUP_FAILED" });
|
|
expect(() => owner.assertReleased()).toThrow("Unreleased Vitest resource claim");
|
|
}
|
|
} finally {
|
|
descendantOutput.destroy();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
[255, "live"],
|
|
[255, "unavailable"],
|
|
[0, "live"],
|
|
] as const)(
|
|
"retains a Windows Job after taskkill status %i until independent-output descendants exit (%s observation)",
|
|
async (status, state) => {
|
|
const root = dirs.make("managed-job-retention-");
|
|
const owner = createVitestResourceOwner(root);
|
|
const child = new ChildProcess();
|
|
let exitCode: number | null = null;
|
|
Object.defineProperties(child, {
|
|
pid: { value: 12345 },
|
|
exitCode: { get: () => exitCode },
|
|
});
|
|
child.stdout = new PassThrough();
|
|
child.stderr = new PassThrough();
|
|
const descendantOutput = new PassThrough();
|
|
const closed = Promise.all([once(child.stdout, "close"), once(child.stderr, "close")]);
|
|
const observed = createDeferred();
|
|
const job = {
|
|
inspect: vi.fn(() => {
|
|
observed.resolve();
|
|
if (state === "unavailable") {
|
|
throw new Error("Job observation unavailable");
|
|
}
|
|
return [23456];
|
|
}),
|
|
beginStop: vi.fn(),
|
|
stop: vi.fn(),
|
|
close: vi.fn(),
|
|
};
|
|
mocks.spawn.mockReturnValue(child);
|
|
mocks.spawnWindowsJobChild.mockReturnValue({ child, job });
|
|
vi.spyOn(child, "kill").mockReturnValue(true);
|
|
const warning = vi.spyOn(process, "emitWarning").mockImplementation(() => {});
|
|
const abort = new AbortController();
|
|
const taskkill = vi.fn(() => {
|
|
exitCode = 0;
|
|
child.emit("exit", 0, null);
|
|
child.stdout?.destroy();
|
|
child.stderr?.destroy();
|
|
return { status, stdout: "leader exited", stderr: status ? "taskkill failed" : "" };
|
|
});
|
|
const completed = runManagedCommand({
|
|
bin: "fixture",
|
|
platform: "win32",
|
|
shell: false,
|
|
stdio: "pipe",
|
|
env: { TMPDIR: root },
|
|
signal: abort.signal,
|
|
runTaskkill: taskkill,
|
|
onReady: () => abort.abort(),
|
|
});
|
|
const outcome = completed.catch((error: unknown) => error);
|
|
try {
|
|
await closed;
|
|
await Promise.race([observed.promise, outcome]);
|
|
expect(descendantOutput.destroyed).toBe(false);
|
|
expect(() => owner.assertReleased()).toThrow("Unreleased Vitest resource claim");
|
|
expect(warning).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
processTreeState: "indeterminate",
|
|
survivingPids: state === "live" ? [23456] : undefined,
|
|
}),
|
|
);
|
|
expect(job.close).not.toHaveBeenCalled();
|
|
expect(job.stop).toHaveBeenCalledOnce();
|
|
} finally {
|
|
// This is the independent descendant's completion fact, not elapsed time or leader EOF.
|
|
descendantOutput.destroy();
|
|
job.inspect.mockReturnValue([]);
|
|
await outcome;
|
|
}
|
|
expect(await outcome).toMatchObject({ code: "ABORT_ERR" });
|
|
owner.assertReleased();
|
|
expect(job.close).toHaveBeenCalledOnce();
|
|
},
|
|
);
|