Files
open-design/packages/platform/tests/process-tree.test.ts
Ray XiandGitHub 8145e9120b fix(daemon): keep ACP turns succeeded when the daemon force-closes the agent (#8514)
* fix(daemon): keep ACP turns succeeded when the daemon force-closes the agent

After a clean ACP end_turn the daemon waits 500ms and then terminates the
agent. On Windows a child killed by pid reports exit code 1 with no signal,
so the close classifier (which only forgave SIGTERM or code 130) marked
the completed turn failed with exit_nonzero and suppressed retry. In the
last 7 days this hit 143 AMR runs from 65 Windows users on 0.24.1; every
sampled bundle (76/76) had already returned stopReason end_turn.

Treat any exit as succeeded once the daemon itself began shutting down a
cleanly completed ACP attempt.

* fix(daemon): only forgive a completed ACP exit the daemon itself caused

Review follow-up. terminateProcessTree waits gracefulWaitMs before it
signals anything, so a vela that exits 1 on its own during that wait
used to count as a daemon shutdown and was reclassified as succeeded.

terminateProcessTree now takes onChildSignal, called just before the
first signal to a direct child that is still alive (POSIX group,
pid-less and Windows pid-tree paths). server.ts sets
acpCompletionShutdownRequested from it for the completed-turn
termination only, so a non-zero exit during the grace wait stays failed.

* fix(daemon): report a daemon close only after the signal was delivered

Review follow-up. `onChildSignal` ran before the stop helpers dispatched
anything. If that dispatch then failed (a non-ESRCH error from the Windows
stop path) or found the child already gone (ESRCH), the flag was still set,
and a child that later exited 1 on its own was reported as succeeded.

- platform: `stopProcesses` takes `onSignal`, called for each PID a signal
  was actually delivered to. ESRCH and thrown dispatches are not reported.
- runs: the completion-shutdown callback fires once, after the first
  delivered signal to the still-owned direct child, on the Windows pid-tree,
  POSIX group and pid-less paths alike.
2026-10-12 07:34:30 +00:00

255 lines
9.4 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([]);
});
});
describe("stopProcesses onSignal", () => {
it("reports only the PIDs a signal was delivered to", async () => {
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
const livePid = child.pid as number;
const exited = new Promise((resolve) => child.once("exit", resolve));
// A PID far above any live one: process.kill answers ESRCH.
const gonePid = 2 ** 22 - 7;
const delivered: Array<[number, NodeJS.Signals]> = [];
await stopProcesses([livePid, gonePid], {
termGraceMs: 2000,
killGraceMs: 2000,
onSignal: (pid, signal) => delivered.push([pid, signal]),
});
await exited;
expect(delivered).toEqual([[livePid, "SIGTERM"]]);
});
});