openclaw / src /process /exec-spawn.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3144483 verified
Raw
History Blame Contribute Delete
8.48 kB
import type { ChildProcess } from "node:child_process";
import { once } from "node:events";
import process from "node:process";
import { describe, expect, it, vi } from "vitest";
import * as processIdentity from "../shared/pid-alive.js";
import { killPidIfAlive, waitForPidToExit } from "../test-utils/process-tree.js";
import { runCommandWithTimeout } from "./exec-runner.js";
import { spawnCommand, withCommandProcessScope } from "./exec-spawn.js";
type ScopeCase = {
name: string;
exitParent: boolean;
completion: "explicit" | "resolve" | "reject";
identity?: "initially-missing" | "reused-after-exit";
};
const endings: ScopeCase[] = [false, true].flatMap((exitParent) =>
(["explicit", "resolve", "reject"] as const).map((completion) => ({
name: `stops owned descendants on ${completion} without stopping another command (parent exited: ${exitParent})`,
exitParent,
completion,
})),
);
endings.push(
{
name: "stops a live child when its initial start-time probe failed",
exitParent: false,
completion: "explicit",
identity: "initially-missing",
},
{
name: "preserves a retained group when an exited root has a different identity",
exitParent: true,
completion: "explicit",
identity: "reused-after-exit",
},
);
describe("command process scope cancellation", () => {
it.each([false, true])(
"cancels a running command through its scope (nested: %s)",
async (nested) => {
const controller = new AbortController();
const caller = new AbortController();
let child: ChildProcess | undefined;
let childResult: Promise<unknown> | undefined;
const run = async () => {
const command = spawnCommand(
[process.execPath, "-e", "process.send('ready');setInterval(()=>{},1000)"],
{
cancelSignal: caller.signal,
ipc: true,
reject: false,
stdio: "ignore",
timeout: 3_000,
},
);
child = command.nodeChildProcess;
childResult = command;
await once(child, "message", { signal: AbortSignal.timeout(3_000) });
controller.abort();
expect(await command).toMatchObject({ isCanceled: true, timedOut: false });
expect(caller.signal.aborted).toBe(false);
expect(processIdentity.isPidAlive(child.pid!)).toBe(false);
expect(() => spawnCommand([process.execPath, "-e", ""])).toThrow(
"Command process scope is closed",
);
};
try {
await withCommandProcessScope(
() => (nested ? withCommandProcessScope(run) : run()),
controller.signal,
);
} finally {
killPidIfAlive(child?.pid);
await childResult;
}
},
);
it("preserves caller cancellation without stopping a sibling command", async () => {
const controller = new AbortController();
const caller = new AbortController();
await withCommandProcessScope(async () => {
const sibling = spawnCommand([process.execPath, "-e", "setInterval(()=>{},1000)"], {
reject: false,
stdio: "ignore",
});
const child = spawnCommand(
[process.execPath, "-e", "process.send('ready');setInterval(()=>{},1000)"],
{ cancelSignal: caller.signal, ipc: true, reject: false, stdio: "ignore", timeout: 3_000 },
);
try {
await once(child.nodeChildProcess, "message", { signal: AbortSignal.timeout(3_000) });
caller.abort();
expect(await child).toMatchObject({ isCanceled: true, timedOut: false });
expect(controller.signal.aborted).toBe(false);
expect(processIdentity.isPidAlive(sibling.pid!)).toBe(true);
} finally {
killPidIfAlive(child.pid);
killPidIfAlive(sibling.pid);
await Promise.all([child, sibling]);
}
}, controller.signal);
});
it.each(["scope", "caller"] as const)(
"joins command-runner termination after nested %s cancellation",
async (source) => {
const controller = new AbortController();
const caller = new AbortController();
let childPid: number | undefined;
try {
await withCommandProcessScope(
() =>
withCommandProcessScope(async () => {
let output = "";
const result = await runCommandWithTimeout(
[
process.execPath,
"-e",
"process.on('SIGTERM',()=>{});console.log(process.pid);setInterval(()=>{},1000)",
],
{
signal: caller.signal,
killProcessTree: true,
timeoutMs: 3_000,
onOutputChunk: (chunk) => {
output += chunk.toString();
if (output.includes("\n") && childPid === undefined) {
childPid = Number(output.trim());
(source === "scope" ? controller : caller).abort();
}
},
},
);
expect(result).toMatchObject({ termination: "signal", cleanup: "forced" });
expect(Number.isSafeInteger(childPid)).toBe(true);
expect(processIdentity.isPidAlive(childPid!)).toBe(false);
}),
controller.signal,
);
} finally {
killPidIfAlive(childPid);
}
},
);
});
describe.skipIf(process.platform === "win32")("terminal command process ownership", () => {
it.each(endings)("$name", async ({ exitParent, completion, identity }) => {
const unrelated = spawnCommand([process.execPath, "-e", "setInterval(()=>{},1000)"], {
stdio: "ignore",
reject: false,
});
let child: ChildProcess | undefined;
let childResult: Promise<unknown> | undefined;
let descendantPid: number | undefined;
const failure = new Error("scope fixture failure");
const identityProbe = vi.spyOn(processIdentity, "getFileLockProcessStartTime");
if (identity === "initially-missing") {
identityProbe.mockReturnValueOnce(null);
} else if (identity === "reused-after-exit") {
identityProbe.mockReturnValue(1);
}
try {
const running = withCommandProcessScope(async (stop) => {
const descendant =
"process.on('SIGTERM',()=>{});setInterval(()=>{},1000);process.send('ready');";
const command = spawnCommand(
[
process.execPath,
"-e",
`const {spawn}=require('node:child_process');
process.on('SIGTERM',()=>{});
const child=spawn(process.execPath,['-e',${JSON.stringify(descendant)}],{stdio:['ignore','ignore','ignore','ipc']});
child.once('message',()=>process.send(child.pid,()=>{${exitParent ? "child.disconnect();process.exit(0)" : "setInterval(()=>{},1000)"}}));`,
],
{ stdio: ["ignore", "pipe", "pipe"], ipc: true, reject: false },
);
child = command.nodeChildProcess;
childResult = command;
const [message] = await once(child, "message", {
signal: AbortSignal.timeout(3_000),
});
descendantPid = Number(message);
expect(Number.isSafeInteger(descendantPid)).toBe(true);
if (exitParent) {
await command;
}
if (identity === "reused-after-exit") {
expect(child.exitCode).toBe(0);
identityProbe.mockReturnValue(2);
}
expect(processIdentity.isPidAlive(descendantPid)).toBe(true);
if (completion === "explicit") {
stop();
expect(() => spawnCommand([process.execPath, "-e", ""])).toThrow(
"Command process scope is closed",
);
} else if (completion === "reject") {
throw failure;
}
});
if (completion === "reject") {
await expect(running).rejects.toBe(failure);
} else {
await running;
}
if (descendantPid === undefined) {
throw new Error("Scope did not receive its descendant PID");
}
expect(await waitForPidToExit(descendantPid)).toBe(identity !== "reused-after-exit");
await childResult;
expect(processIdentity.isPidAlive(unrelated.pid!)).toBe(true);
} finally {
identityProbe.mockRestore();
killPidIfAlive(child?.pid);
killPidIfAlive(descendantPid);
killPidIfAlive(unrelated.pid);
await childResult;
await unrelated;
}
});
});