mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 10:13:11 +02:00
Part 6 of the opt-in indexed history draft series. Validated at the integrated tip; activation remains disabled by default. Co-Authored-By: Paperclip <noreply@paperclip.ing>
195 lines
7.3 KiB
TypeScript
195 lines
7.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { spawn } from "node:child_process";
|
|
import { once } from "node:events";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import {
|
|
observeDescendantProcessTree,
|
|
OwnedProcessTree,
|
|
readProcessTable,
|
|
refreshContinuouslyLiveProcessGroups,
|
|
revalidateObservedProcessGroups,
|
|
safeProcessGroupTerminationOrder,
|
|
type ProcessObservation,
|
|
} from "./process-tree.js";
|
|
|
|
function process(
|
|
pid: number,
|
|
parentPid: number,
|
|
processGroupId: number,
|
|
started = `start-${pid}`,
|
|
): ProcessObservation {
|
|
return { pid, parentPid, processGroupId, started, kind: "node" };
|
|
}
|
|
|
|
describe("runner E2E process-tree cleanup", () => {
|
|
it.skipIf(globalThis.process.platform === "win32")("revalidates and stops a real detached child after its wrapper exits", async () => {
|
|
const wrapper = spawn(globalThis.process.execPath, ["-e", `
|
|
const { spawn } = require('node:child_process');
|
|
const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {
|
|
detached: true, stdio: 'ignore'
|
|
});
|
|
child.unref();
|
|
process.on('message', () => process.exit(0));
|
|
process.send(child.pid);
|
|
`], { detached: true, stdio: ["ignore", "ignore", "ignore", "ipc"] });
|
|
const owned = new OwnedProcessTree(wrapper.pid!);
|
|
try {
|
|
const [pid] = await once(wrapper, "message");
|
|
expect(typeof pid).toBe("number");
|
|
const initial = await readProcessTable();
|
|
expect(initial).not.toBeNull();
|
|
owned.observe(initial!, true);
|
|
expect(owned.groups.some((group) => group.processGroupId === pid)).toBe(true);
|
|
const exited = once(wrapper, "exit");
|
|
wrapper.send("exit");
|
|
await exited;
|
|
const orphaned = await readProcessTable();
|
|
expect(orphaned?.find((candidate) => candidate.pid === pid)?.parentPid).not.toBe(wrapper.pid);
|
|
const groups = owned.observe(orphaned!, false);
|
|
expect(groups.map((group) => group.processGroupId)).toEqual([pid]);
|
|
const launcherGroup = orphaned!.find((candidate) => candidate.pid === globalThis.process.pid)!.processGroupId;
|
|
const order = safeProcessGroupTerminationOrder({ rootProcessGroupId: wrapper.pid!, currentProcessGroupId: launcherGroup, groups });
|
|
expect(order).toEqual([pid]);
|
|
for (const group of order) globalThis.process.kill(-group, "SIGTERM");
|
|
const deadline = Date.now() + 5000;
|
|
while (Date.now() < deadline) {
|
|
const table = await readProcessTable();
|
|
if (table && owned.observe(table, false).length === 0) break;
|
|
await delay(25);
|
|
}
|
|
expect(owned.groups).toEqual([]);
|
|
} finally {
|
|
wrapper.kill("SIGKILL");
|
|
const table = await readProcessTable();
|
|
if (table) {
|
|
const groups = owned.observe(table, false);
|
|
const launcherGroup = table.find((candidate) => candidate.pid === globalThis.process.pid)?.processGroupId ?? null;
|
|
for (const pid of safeProcessGroupTerminationOrder({ rootProcessGroupId: wrapper.pid!, currentProcessGroupId: launcherGroup, groups })) {
|
|
try { globalThis.process.kill(-pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("retains detached runner/provider identities after both parent and wrapper exit", () => {
|
|
const owned = new OwnedProcessTree(100);
|
|
owned.observe([
|
|
process(100, 10, 100), process(101, 100, 100),
|
|
process(200, 101, 200), process(300, 200, 300),
|
|
], true);
|
|
const groups = owned.observe([
|
|
process(200, 1, 200), process(300, 1, 300), process(500, 1, 500),
|
|
], false);
|
|
expect(safeProcessGroupTerminationOrder({
|
|
rootProcessGroupId: 100, currentProcessGroupId: 10, groups,
|
|
})).toEqual([300, 200]);
|
|
expect(owned.observe([], false)).toEqual([]);
|
|
});
|
|
|
|
it("does not adopt a reused wrapper pid or detached process group", () => {
|
|
const owned = new OwnedProcessTree(100);
|
|
owned.observe([process(100, 10, 100), process(200, 100, 200)], true);
|
|
expect(owned.observe([
|
|
process(100, 10, 100, "new-wrapper"),
|
|
process(201, 100, 201), process(200, 1, 200, "new-runner"),
|
|
], true)).toEqual([]);
|
|
});
|
|
|
|
it("tracks new descendants of a verified orphan with stable ownership depths", () => {
|
|
const owned = new OwnedProcessTree(100);
|
|
const table = [process(100, 10, 100), process(200, 100, 200), process(201, 200, 200)];
|
|
const first = structuredClone(owned.observe(table, true));
|
|
for (let i = 0; i < 20; i += 1) expect(owned.observe(table, true)).toEqual(first);
|
|
const orphanTable = [process(200, 1, 200), process(201, 200, 200), process(300, 201, 300)];
|
|
const groups = owned.observe(orphanTable, false);
|
|
expect(safeProcessGroupTerminationOrder({ rootProcessGroupId: 100, currentProcessGroupId: 10, groups })).toEqual([300, 200]);
|
|
});
|
|
|
|
it("orders verified nested groups before the outer group and excludes unsafe groups", () => {
|
|
const table = [
|
|
process(100, 10, 100),
|
|
process(101, 100, 100),
|
|
process(200, 101, 200),
|
|
process(300, 200, 300),
|
|
process(301, 300, 300),
|
|
process(400, 101, 10),
|
|
process(500, 999, 500),
|
|
];
|
|
const observed = observeDescendantProcessTree(table, 100);
|
|
expect(
|
|
safeProcessGroupTerminationOrder({
|
|
rootProcessGroupId: 100,
|
|
currentProcessGroupId: 10,
|
|
groups: observed.groups,
|
|
}),
|
|
).toEqual([300, 200, 100]);
|
|
});
|
|
|
|
it("rejects a reused pid whose start identity no longer matches", () => {
|
|
const observed = observeDescendantProcessTree(
|
|
[process(100, 10, 100), process(200, 100, 200)],
|
|
100,
|
|
);
|
|
expect(
|
|
revalidateObservedProcessGroups(observed.groups, [
|
|
process(100, 10, 100),
|
|
process(200, 1, 200, "reused-process"),
|
|
]).map((group) => group.processGroupId),
|
|
).toEqual([100]);
|
|
});
|
|
|
|
it("refuses every group when launcher identity is unavailable", () => {
|
|
const observed = observeDescendantProcessTree(
|
|
[process(100, 10, 100), process(200, 100, 200)],
|
|
100,
|
|
);
|
|
expect(
|
|
safeProcessGroupTerminationOrder({
|
|
rootProcessGroupId: 100,
|
|
currentProcessGroupId: null,
|
|
groups: observed.groups,
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("does not add an unobserved root process group after revalidation", () => {
|
|
expect(
|
|
safeProcessGroupTerminationOrder({
|
|
rootProcessGroupId: 100,
|
|
currentProcessGroupId: 10,
|
|
groups: [
|
|
{
|
|
processGroupId: 200,
|
|
depth: 1,
|
|
members: [{ pid: 200, started: "start-200" }],
|
|
},
|
|
],
|
|
}),
|
|
).toEqual([200]);
|
|
});
|
|
|
|
it("retains a continuously live group when a cleanup helper replaces its original member", () => {
|
|
const observed = observeDescendantProcessTree(
|
|
[process(100, 10, 100), process(200, 100, 200)],
|
|
100,
|
|
);
|
|
const verified = revalidateObservedProcessGroups(observed.groups, [
|
|
process(100, 10, 100),
|
|
process(200, 100, 200),
|
|
]);
|
|
const refreshed = refreshContinuouslyLiveProcessGroups(verified, [
|
|
process(100, 10, 100),
|
|
process(201, 1, 200),
|
|
]);
|
|
|
|
expect(
|
|
refreshed.find((group) => group.processGroupId === 200)?.members,
|
|
).toEqual([{ pid: 201, started: "start-201" }]);
|
|
expect(
|
|
refreshContinuouslyLiveProcessGroups(refreshed, [
|
|
process(100, 10, 100),
|
|
]).map((group) => group.processGroupId),
|
|
).toEqual([100]);
|
|
});
|
|
});
|