mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
5a3f0c8923
* fix(daemon): archive owned Codex threads after terminal completion * fix(daemon): archive owned Codex threads after interrupted close --------- Co-authored-by: lefarcen <935902669@qq.com>
235 lines
8.6 KiB
TypeScript
235 lines
8.6 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
collectProcessTreePids,
|
|
selectOwnedProcessTree,
|
|
processCommandExactlyRunsExecutable,
|
|
stopProcesses,
|
|
type ProcessStampContract,
|
|
type ProcessSnapshot,
|
|
waitForProcessExit,
|
|
} from "../src/index.js";
|
|
import { parseWindowsProcessSnapshots, selectStampedProcessesAtInvocation } from "../src/process.js";
|
|
|
|
function snapshot(pid: number, ppid: number, command = `pid-${pid}`): ProcessSnapshot {
|
|
return { command, pid, ppid };
|
|
}
|
|
|
|
type TestStamp = { namespace: string };
|
|
|
|
const stampContract: ProcessStampContract<TestStamp> = {
|
|
normalizeStamp(input) {
|
|
const namespace = (input as Partial<TestStamp>).namespace;
|
|
if (typeof namespace !== "string" || namespace.length === 0) throw new Error("invalid namespace");
|
|
return { namespace };
|
|
},
|
|
normalizeStampCriteria(input = {}) {
|
|
const namespace = (input as Partial<TestStamp>).namespace;
|
|
return namespace == null ? {} : { namespace };
|
|
},
|
|
stampFields: ["namespace"],
|
|
stampFlags: { namespace: "--test-namespace" },
|
|
};
|
|
|
|
describe("collectProcessTreePids", () => {
|
|
it("returns an empty array when no roots are supplied", () => {
|
|
expect(collectProcessTreePids([snapshot(100, 1), snapshot(101, 100)], [])).toEqual([]);
|
|
expect(collectProcessTreePids([], [null, undefined])).toEqual([]);
|
|
});
|
|
|
|
it("returns a single root with no descendants", () => {
|
|
expect(collectProcessTreePids([snapshot(101, 1)], [100])).toEqual([100]);
|
|
});
|
|
|
|
it("walks two levels of descendants and sorts pids descending", () => {
|
|
const processes = [
|
|
snapshot(100, 1),
|
|
snapshot(200, 100),
|
|
snapshot(201, 100),
|
|
snapshot(300, 200),
|
|
];
|
|
expect(collectProcessTreePids(processes, [100])).toEqual([300, 201, 200, 100]);
|
|
});
|
|
|
|
it("returns the root even when no matching ppid exists in the process list", () => {
|
|
expect(collectProcessTreePids([snapshot(500, 1)], [100])).toEqual([100]);
|
|
});
|
|
|
|
it("dedupes repeated root pids", () => {
|
|
expect(collectProcessTreePids([snapshot(200, 100)], [100, 100])).toEqual([200, 100]);
|
|
});
|
|
|
|
it("terminates on parent-child cycles instead of looping forever", () => {
|
|
const processes = [snapshot(100, 200), snapshot(200, 100)];
|
|
expect(collectProcessTreePids(processes, [100])).toEqual([200, 100]);
|
|
});
|
|
});
|
|
|
|
describe("selectStampedProcessesAtInvocation", () => {
|
|
const command = "node fixture.js --test-namespace=alpha";
|
|
|
|
it("keeps only matching Windows processes created before the invocation boundary", () => {
|
|
expect(selectStampedProcessesAtInvocation([
|
|
{ command, pid: 100, ppid: 1, startedAtMs: 900 },
|
|
{ command, pid: 200, ppid: 1, startedAtMs: 1_100 },
|
|
{ command: "node unrelated.js", pid: 300, ppid: 1 },
|
|
], { namespace: "alpha" }, stampContract, 1_000, "win32")).toEqual([
|
|
{ command, pid: 100, ppid: 1, startedAtMs: 900 },
|
|
]);
|
|
});
|
|
|
|
it("quick-fails when a matching Windows process has an ambiguous creation boundary", () => {
|
|
expect(() => selectStampedProcessesAtInvocation([
|
|
{ command, pid: 100, ppid: 1 },
|
|
], { namespace: "alpha" }, stampContract, 1_000, "win32")).toThrow(
|
|
"cannot establish process generation boundary for pid 100",
|
|
);
|
|
|
|
expect(() => selectStampedProcessesAtInvocation([
|
|
{ command, pid: 200, ppid: 1, startedAtMs: 1_000 },
|
|
], { namespace: "alpha" }, stampContract, 1_000, "win32")).toThrow(
|
|
"cannot establish process generation boundary for pid 200",
|
|
);
|
|
});
|
|
|
|
it("does not require Windows creation metadata from unrelated processes", () => {
|
|
expect(selectStampedProcessesAtInvocation([
|
|
{ command: "node unrelated.js", pid: 300, ppid: 1 },
|
|
], { namespace: "alpha" }, stampContract, 1_000, "win32")).toEqual([]);
|
|
});
|
|
|
|
});
|
|
|
|
describe("parseWindowsProcessSnapshots", () => {
|
|
it("retains the OS creation time used by the generation boundary", () => {
|
|
expect(parseWindowsProcessSnapshots(JSON.stringify({
|
|
CommandLine: "node fixture.js",
|
|
ParentProcessId: 10,
|
|
ProcessId: 20,
|
|
StartedAtMs: "1724490000123",
|
|
}))).toEqual([{
|
|
command: "node fixture.js",
|
|
pid: 20,
|
|
ppid: 10,
|
|
startedAtMs: 1_724_490_000_123,
|
|
}]);
|
|
});
|
|
|
|
it("leaves invalid creation metadata explicit for boundary quick-fail", () => {
|
|
expect(parseWindowsProcessSnapshots(JSON.stringify({
|
|
CommandLine: "node fixture.js",
|
|
ParentProcessId: 10,
|
|
ProcessId: 20,
|
|
StartedAtMs: null,
|
|
}))).toEqual([{ command: "node fixture.js", pid: 20, ppid: 10 }]);
|
|
});
|
|
});
|
|
|
|
describe("processCommandExactlyRunsExecutable", () => {
|
|
it("accepts exact POSIX and quoted Windows executable commands", () => {
|
|
expect(processCommandExactlyRunsExecutable(
|
|
"/Applications/Open Design.app/Contents/MacOS/Open Design",
|
|
"/Applications/Open Design.app/Contents/MacOS/Open Design",
|
|
"darwin",
|
|
)).toBe(true);
|
|
expect(processCommandExactlyRunsExecutable(
|
|
'"C:\\Program Files\\Open Design\\Open Design.exe"',
|
|
"C:\\Program Files\\Open Design\\Open Design.exe",
|
|
"win32",
|
|
)).toBe(true);
|
|
});
|
|
|
|
it("rejects arguments and lookalike executable prefixes", () => {
|
|
const executable = "/Applications/Open Design.app/Contents/MacOS/Open Design";
|
|
expect(processCommandExactlyRunsExecutable(`${executable} --inspect`, executable, "darwin")).toBe(false);
|
|
expect(processCommandExactlyRunsExecutable(`${executable} Helper`, executable, "darwin")).toBe(false);
|
|
|
|
const windowsExecutable = "C:\\Program Files\\Open Design\\Open Design.exe";
|
|
expect(processCommandExactlyRunsExecutable(
|
|
`"${windowsExecutable}" od://project/123`,
|
|
windowsExecutable,
|
|
"win32",
|
|
)).toBe(false);
|
|
expect(processCommandExactlyRunsExecutable(
|
|
`"${windowsExecutable}.old"`,
|
|
windowsExecutable,
|
|
"win32",
|
|
)).toBe(false);
|
|
});
|
|
|
|
it("compares Windows executable paths case-insensitively", () => {
|
|
expect(processCommandExactlyRunsExecutable(
|
|
'"C:\\PROGRAM FILES\\OPEN DESIGN\\OPEN DESIGN.EXE"',
|
|
"c:\\Program Files\\Open Design\\Open Design.exe",
|
|
"win32",
|
|
)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("stopProcesses", () => {
|
|
it.skipIf(process.platform === "win32")(
|
|
"escalates to SIGKILL when a child ignores SIGTERM",
|
|
async () => {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[
|
|
"-e",
|
|
"process.on('SIGTERM',()=>{});process.stdout.write('ready\\n');setInterval(()=>{},1000)",
|
|
],
|
|
{ stdio: ["ignore", "pipe", "ignore"] },
|
|
);
|
|
const pid = child.pid;
|
|
if (pid == null) throw new Error("test child did not start");
|
|
await new Promise<void>((resolve, reject) => {
|
|
child.once("error", reject);
|
|
child.stdout?.once("data", () => resolve());
|
|
});
|
|
|
|
try {
|
|
const result = await stopProcesses([pid], {
|
|
termGraceMs: 100,
|
|
killGraceMs: 1_000,
|
|
});
|
|
expect(result.forcedPids).toContain(pid);
|
|
expect(result.remainingPids).toEqual([]);
|
|
expect(result.stoppedPids).toContain(pid);
|
|
} finally {
|
|
child.kill("SIGKILL");
|
|
await waitForProcessExit(pid, 1_000);
|
|
}
|
|
},
|
|
5_000,
|
|
);
|
|
});
|
|
|
|
|
|
describe("generation-fenced owned process trees", () => {
|
|
const processAt = (pid: number, ppid: number, startedAtMs?: number): ProcessSnapshot => ({
|
|
pid, ppid, command: "same executable", ...(startedAtMs === undefined ? {} : { startedAtMs }),
|
|
});
|
|
const root = processAt(10, 1, 100);
|
|
const child = processAt(11, 10, 101);
|
|
it("extends only a proven live ancestor and ignores same-name siblings", () => {
|
|
expect(selectOwnedProcessTree([root], [root, child, processAt(12, 11, 102), processAt(20, 1, 101)])
|
|
.map(entry => entry.pid)).toEqual([10, 11, 12]);
|
|
});
|
|
it("retains a known child after the wrapper exits, without trusting a reused wrapper PID", () => {
|
|
const replacement = processAt(10, 1, 200);
|
|
expect(selectOwnedProcessTree([root, child], [replacement, child, processAt(12, 11, 102), processAt(21, 10, 201)])
|
|
.map(entry => entry.pid)).toEqual([11, 12]);
|
|
});
|
|
it("rejects a reused descendant PID and that replacement's children", () => {
|
|
expect(selectOwnedProcessTree([root, child], [processAt(11, 1, 200), processAt(12, 11, 201)])).toEqual([]);
|
|
});
|
|
it("rejects unknown creation times and children older than their claimed parent", () => {
|
|
expect(selectOwnedProcessTree([root], [root, processAt(11, 10), processAt(12, 10, 99)])).toEqual([root]);
|
|
expect(selectOwnedProcessTree([processAt(10, 1)], [root, child])).toEqual([]);
|
|
});
|
|
it("does not discover an orphan from a root PID that was never captured", () => {
|
|
expect(selectOwnedProcessTree([], [child])).toEqual([]);
|
|
expect(selectOwnedProcessTree([root], [child])).toEqual([]);
|
|
});
|
|
});
|