Files
PaperClipAI/tests/runner-e2e/process-tree-owner.test.ts
T

67 lines
3.1 KiB
TypeScript

import type { ChildProcess } from "node:child_process";
import { expect, it } from "vitest";
import { createProcessTreeOwner } from "./process-tree-owner.js";
import type { ProcessObservation } from "./process-tree.js";
function row(pid: number, parentPid: number, processGroupId: number, started = `start-${pid}`, state = "S"): ProcessObservation {
return { pid, parentPid, processGroupId, started, state, kind: "node" };
}
function fixture(initial: ProcessObservation[]) {
let table = [row(process.pid, 1, 10), ...initial];
const root = { pid: 100, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null };
const signals: Array<[number, NodeJS.Signals]> = [];
const owner = createProcessTreeOwner(root as ChildProcess, {
readTable: async () => table,
signalGroup: (pid, signal) => { signals.push([pid, signal]); },
});
return { root, owner, signals, replace: (rows: ProcessObservation[]) => { table = [row(process.pid, 1, 10), ...rows]; } };
}
it.skipIf(process.platform === "win32")("revalidates PID/start before signaling and refuses a recycled group", async () => {
const f = fixture([row(100, process.pid, 100), row(200, 100, 200)]);
try {
await f.owner.observe();
f.replace([row(100, process.pid, 100), row(200, 1, 200, "new-start")]);
await expect(f.owner.signal("SIGKILL")).rejects.toThrow("identity became uncertain");
expect(f.signals).toEqual([]);
} finally { f.owner.stopObserving(); }
});
it.skipIf(process.platform === "win32")("retains observed descendants when the root exits and excludes the caller group", async () => {
const f = fixture([row(100, process.pid, 100), row(200, 100, 200), row(300, 100, 10), row(999, process.pid, 999)]);
try {
await f.owner.observe();
f.root.exitCode = 1;
f.replace([row(200, 1, 200), row(300, 1, 10), row(999, process.pid, 999)]);
await f.owner.signal("SIGTERM");
expect(f.signals).toEqual([[200, "SIGTERM"]]);
} finally { f.owner.stopObserving(); }
});
it.skipIf(process.platform === "win32")("treats an unreaped zombie as stopped without signaling a replacement", async () => {
const f = fixture([row(100, process.pid, 100), row(200, 100, 200)]);
try {
await f.owner.observe();
f.root.exitCode = 1;
f.replace([row(200, 1, 200, "start-200", "Z")]);
await f.owner.signal("SIGKILL");
expect(f.owner.liveGroups()).toEqual([]);
expect(f.signals).toEqual([]);
} finally { f.owner.stopObserving(); }
});
it.skipIf(process.platform === "win32")("sends launcher grace only to the root while retaining nested groups for escalation", async () => {
const f = fixture([row(100, process.pid, 100), row(200, 100, 200), row(300, 200, 300)]);
try {
await f.owner.observe();
await f.owner.signal("SIGTERM", f.owner.gracefulRoots());
expect(f.signals).toEqual([[100, "SIGTERM"]]);
f.root.exitCode = 1;
f.replace([row(200, 1, 200), row(300, 200, 300)]);
await f.owner.observe();
expect([...f.owner.gracefulRoots()]).toEqual([200]);
await f.owner.signal("SIGKILL");
expect(f.signals.slice(1).map(([pid]) => pid).sort()).toEqual([200, 300]);
} finally { f.owner.stopObserving(); }
});