Files
openclaw/test/scripts/managed-child-process.test.ts
T
Peter Steinberger c70aee247e refactor(scripts): migrate JavaScript tools to TypeScript (#121005)
* refactor(scripts): migrate JavaScript tools to TypeScript

* fix(ci): keep changed-scope preflight zero-install

* fix(ci): preserve zero-install script owners

* fix(ci): complete script migration follow-through

* fix(release): keep stable closeout zero-install

* fix(scripts): preserve standalone execution boundaries

* fix(scripts): repair standalone loader boundaries

* fix(scripts): normalize gateway observation ids

* fix(scripts): keep Docker packager standalone

* test(scripts): preserve rebase cleanup helpers

* test(sessions): use tracked temp directory
2026-08-09 07:21:35 -07:00

649 lines
20 KiB
TypeScript

// Managed Child Process tests cover managed child process script behavior.
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { describe, expect, it, vi } from "vitest";
import {
createManagedCommandSpawnSpec,
runManagedCommand,
signalExitCode,
terminateManagedChild,
} from "../../scripts/lib/managed-child-process.mts";
import { createScriptTestHarness } from "./test-helpers.js";
const { createTempDir } = createScriptTestHarness();
const posixIt = process.platform === "win32" ? it.skip : it;
const taskkillPath = path.win32.join("C:\\Windows", "System32", "taskkill.exe");
function restoreEnvValue(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
return;
}
process.env[key] = value;
}
function withDefaultWindowsSystemRoot(run: () => void): void {
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
try {
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
run();
} finally {
restoreEnvValue("SystemRoot", originalSystemRoot);
restoreEnvValue("WINDIR", originalWindir);
}
}
function expectProcessPid(pid: number | undefined): number {
if (pid == null) {
throw new Error("Expected spawned process to expose a pid");
}
return pid;
}
describe("managed-child-process", () => {
it("maps forwarded signals to shell-compatible exit codes", () => {
expect(signalExitCode("SIGHUP")).toBe(129);
expect(signalExitCode("SIGINT")).toBe(130);
expect(signalExitCode("SIGTERM")).toBe(143);
expect(signalExitCode("SIGKILL")).toBe(137);
});
it("wraps Windows shell argv through cmd.exe without Node shell mode", () => {
expect(
createManagedCommandSpawnSpec({
args: ["lint:scripts", "--", "scripts"],
bin: "pnpm.cmd",
comSpec: "C:\\Windows\\System32\\cmd.exe",
env: {},
platform: "win32",
shell: true,
}),
).toEqual({
args: ["/d", "/s", "/c", "pnpm.cmd lint:scripts -- scripts"],
command: "C:\\Windows\\System32\\cmd.exe",
options: {
cwd: undefined,
detached: false,
env: {},
shell: false,
stdio: "inherit",
windowsVerbatimArguments: true,
},
});
});
it("uses Windows shell normalization when the platform override is win32", () => {
expect(
createManagedCommandSpawnSpec({
args: ["lint:scripts", "--", "scripts"],
bin: "pnpm.cmd",
comSpec: "C:\\Windows\\System32\\cmd.exe",
env: {},
platform: "win32",
}),
).toEqual({
args: ["/d", "/s", "/c", "pnpm.cmd lint:scripts -- scripts"],
command: "C:\\Windows\\System32\\cmd.exe",
options: {
cwd: undefined,
detached: false,
env: {},
shell: false,
stdio: "inherit",
windowsVerbatimArguments: true,
},
});
});
it("preserves explicit non-shell Windows subprocesses", () => {
expect(
createManagedCommandSpawnSpec({
args: ["--version"],
bin: "node.exe",
platform: "win32",
shell: false,
}),
).toEqual({
args: ["--version"],
command: "node.exe",
options: {
cwd: undefined,
detached: false,
env: undefined,
shell: false,
stdio: "inherit",
windowsVerbatimArguments: undefined,
},
});
});
it("rejects unsafe Windows shell argv instead of passing them to Node shell mode", () => {
expect(() =>
createManagedCommandSpawnSpec({
args: ["build && pnpm test"],
bin: "pnpm.cmd",
platform: "win32",
shell: true,
}),
).toThrow("unsafe Windows cmd.exe argument detected");
});
it("signals Windows managed process trees with taskkill", () => {
withDefaultWindowsSystemRoot(() => {
const child = {
kill: vi.fn(),
pid: 12345,
};
const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 }));
terminateManagedChild(child, "SIGTERM", {
platform: "win32",
runTaskkill,
});
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
killSignal: "SIGKILL",
stdio: "ignore",
timeout: 10_000,
});
terminateManagedChild(child, "SIGKILL", {
platform: "win32",
runTaskkill,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
killSignal: "SIGKILL",
stdio: "ignore",
timeout: 10_000,
});
expect(child.kill).not.toHaveBeenCalled();
});
});
it("force-kills Windows managed process trees when graceful taskkill fails", () => {
withDefaultWindowsSystemRoot(() => {
const child = {
kill: vi.fn(),
pid: 12345,
};
const runTaskkill = vi
.fn()
.mockReturnValueOnce({ error: undefined, status: 1 })
.mockReturnValueOnce({ error: undefined, status: 0 });
terminateManagedChild(child, "SIGTERM", {
platform: "win32",
runTaskkill,
});
expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], {
killSignal: "SIGKILL",
stdio: "ignore",
timeout: 10_000,
});
expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], {
killSignal: "SIGKILL",
stdio: "ignore",
timeout: 10_000,
});
expect(child.kill).not.toHaveBeenCalled();
});
});
it("shares process signal listeners across parallel managed commands", async () => {
const signals = ["SIGHUP", "SIGINT", "SIGTERM"] as const;
const baseline = new Map(signals.map((signal) => [signal, process.listenerCount(signal)]));
const children: Array<Parameters<typeof terminateManagedChild>[0]> = [];
let readyCount = 0;
const commands = Array.from({ length: 12 }, () =>
runManagedCommand({
args: ["-e", "setTimeout(() => {}, 10_000)"],
bin: process.execPath,
shell: false,
stdio: "ignore",
onReady: (child) => {
children.push(child);
readyCount += 1;
},
}),
);
try {
await waitFor(() => readyCount === commands.length);
for (const signal of signals) {
expect(process.listenerCount(signal)).toBe((baseline.get(signal) ?? 0) + 1);
}
} finally {
for (const child of children) {
terminateManagedChild(child, "SIGTERM");
}
await Promise.all(commands);
}
for (const signal of signals) {
expect(process.listenerCount(signal)).toBe(baseline.get(signal) ?? 0);
}
});
it("times out and kills managed command descendants", async () => {
const dir = createTempDir("openclaw-managed-timeout-");
const childPath = path.join(dir, "child.mjs");
const childPidPath = path.join(dir, "child.pid");
const descendantPidPath = path.join(dir, "descendant.pid");
fs.writeFileSync(
childPath,
`
import { spawn } from "node:child_process";
import fs from "node:fs";
const descendant = spawn(process.execPath, [
"-e",
"process.on('SIGTERM', () => {}); setTimeout(() => process.exit(0), 5_000); setInterval(() => {}, 1000);",
], { stdio: "ignore" });
fs.writeFileSync(process.argv[2], String(process.pid));
fs.writeFileSync(process.argv[3], String(descendant.pid));
process.on("SIGTERM", () => {});
setInterval(() => {}, 1_000);
`,
"utf8",
);
let childPid = 0;
let descendantPid = 0;
try {
await expect(
runManagedCommand({
bin: process.execPath,
args: [childPath, childPidPath, descendantPidPath],
shell: false,
stdio: "ignore",
timeoutMs: 500,
}),
).rejects.toMatchObject({ code: "ETIMEDOUT" });
childPid = Number(fs.readFileSync(childPidPath, "utf8"));
descendantPid = Number(fs.readFileSync(descendantPidPath, "utf8"));
expect(isProcessAlive(childPid)).toBe(false);
expect(isProcessAlive(descendantPid)).toBe(false);
} finally {
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
if (descendantPid && isProcessAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
}
});
it("uses a wall timeout even while the child emits progress", async () => {
const startedAt = Date.now();
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "setInterval(() => process.stderr.write('retrying\\n'), 20)"],
shell: false,
stdio: "ignore",
timeoutMs: 200,
}),
).rejects.toMatchObject({ code: "ETIMEDOUT" });
expect(Date.now() - startedAt).toBeLessThan(2_000);
});
it("refuses strict Windows commands before spawning an unverifiable tree", async () => {
const onReady = vi.fn();
const runTaskkill = vi.fn();
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "process.exit(0)"],
onReady,
platform: "win32",
requireProcessTreeExit: true,
runTaskkill,
shell: false,
stdio: "ignore",
timeoutMs: 1_000,
}),
).rejects.toMatchObject({
code: "EPROCESS_TREE_VERIFICATION_UNSUPPORTED",
});
expect(onReady).not.toHaveBeenCalled();
expect(runTaskkill).not.toHaveBeenCalled();
});
it("fails closed when Windows taskkill cannot verify timeout cleanup", async () => {
const originalSystemRoot = process.env.SystemRoot;
const originalWindir = process.env.WINDIR;
let childPid = 0;
const runTaskkill = vi.fn(() => ({
error: Object.assign(new Error("taskkill timed out"), { code: "ETIMEDOUT" }),
status: null,
}));
try {
process.env.SystemRoot = "C:\\Windows";
delete process.env.WINDIR;
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "setInterval(() => {}, 1_000)"],
onReady: (child) => {
childPid = expectProcessPid(child.pid);
},
platform: "win32",
runTaskkill,
shell: false,
stdio: "ignore",
timeoutMs: 200,
}),
).rejects.toMatchObject({
code: "EPROCESSGROUP_CLEANUP_FAILED",
manualRecoveryRequired: true,
processTreeState: "indeterminate",
});
expect(runTaskkill).toHaveBeenCalledWith(
taskkillPath,
["/PID", String(childPid), "/T", "/F"],
{
killSignal: "SIGKILL",
stdio: "ignore",
timeout: 10_000,
},
);
await waitFor(() => !isProcessAlive(childPid));
} finally {
restoreEnvValue("SystemRoot", originalSystemRoot);
restoreEnvValue("WINDIR", originalWindir);
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
}
});
posixIt("does not wait indefinitely when a timed-out child omits close", async () => {
const startedAt = Date.now();
let childPid = 0;
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "setInterval(() => {}, 1_000)"],
onReady: (child) => {
childPid = expectProcessPid(child.pid);
child.removeAllListeners("close");
},
shell: false,
stdio: "ignore",
timeoutMs: 200,
}),
).rejects.toMatchObject({ code: "ETIMEDOUT" });
expect(Date.now() - startedAt).toBeLessThan(2_000);
expect(isProcessAlive(childPid)).toBe(false);
});
posixIt("waits through transient indeterminate process-group state", async () => {
const originalKill = process.kill.bind(process);
let childPid = 0;
let injectedIndeterminate = false;
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
if (pid === -childPid && signal === 0 && !injectedIndeterminate) {
injectedIndeterminate = true;
throw Object.assign(new Error("transient process-group state"), { code: "EPERM" });
}
return originalKill(pid, signal);
}) as typeof process.kill;
try {
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "setInterval(() => {}, 1_000)"],
onReady: (child) => {
childPid = expectProcessPid(child.pid);
},
shell: false,
stdio: "ignore",
timeoutMs: 200,
}),
).rejects.toMatchObject({ code: "ETIMEDOUT" });
} finally {
process.kill = originalKill;
}
expect(injectedIndeterminate).toBe(true);
expect(isProcessAlive(childPid)).toBe(false);
});
posixIt("accepts a process group that vanishes before its cleanup signal", async () => {
const originalKill = process.kill.bind(process);
let childPid = 0;
let injectedLiveGroup = false;
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
if (pid === -childPid && signal === 0 && !injectedLiveGroup) {
injectedLiveGroup = true;
return true;
}
return originalKill(pid, signal);
}) as typeof process.kill;
try {
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "process.exit(0)"],
onReady: (child) => {
childPid = expectProcessPid(child.pid);
},
requireProcessTreeExit: true,
shell: false,
stdio: "ignore",
timeoutMs: 1_000,
}),
).resolves.toBe(0);
} finally {
process.kill = originalKill;
}
expect(injectedLiveGroup).toBe(true);
expect(isProcessAlive(childPid)).toBe(false);
});
it("allows bounded retry output and normal long-running work to complete", async () => {
await expect(
runManagedCommand({
bin: process.execPath,
args: [
"-e",
"process.stderr.write('network retry 1\\n'); setTimeout(() => process.exit(0), 100)",
],
shell: false,
stdio: "ignore",
timeoutMs: 1_000,
}),
).resolves.toBe(0);
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "setTimeout(() => process.exit(0), 200)"],
requireProcessTreeExit: true,
shell: false,
stdio: "ignore",
timeoutMs: 1_000,
}),
).resolves.toBe(0);
});
it("cleans up the child when onReady throws", async () => {
let childPid = 0;
await expect(
runManagedCommand({
bin: process.execPath,
args: ["-e", "setInterval(() => {}, 1_000)"],
onReady: (child) => {
childPid = expectProcessPid(child.pid);
throw new Error("setup failed");
},
shell: false,
stdio: "ignore",
}),
).rejects.toThrow("setup failed");
expect(isProcessAlive(childPid)).toBe(false);
});
posixIt("rejects and drains descendants left after a successful leader exit", async () => {
const dir = createTempDir("openclaw-managed-lingering-");
const descendantPidPath = path.join(dir, "descendant.pid");
let descendantPid = 0;
try {
await expect(
runManagedCommand({
bin: process.execPath,
args: [
"-e",
`
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
child.unref();
fs.writeFileSync(process.argv[1], String(child.pid));
`,
descendantPidPath,
],
requireProcessTreeExit: true,
shell: false,
stdio: "ignore",
timeoutMs: 1_000,
}),
).rejects.toMatchObject({ code: "EPROCESSGROUP_CLEANUP_FAILED" });
descendantPid = Number(fs.readFileSync(descendantPidPath, "utf8"));
expect(isProcessAlive(descendantPid)).toBe(false);
} finally {
if (descendantPid && isProcessAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
}
});
posixIt(
"kills managed child process group descendants when the runner is terminated",
async () => {
const dir = createTempDir("openclaw-managed-child-");
const childPath = path.join(dir, "child.mjs");
const runnerPath = path.join(dir, "runner.mjs");
const childPidPath = path.join(dir, "child.pid");
const descendantPidPath = path.join(dir, "descendant.pid");
const runnerReadyPath = path.join(dir, "runner.ready");
const helperUrl = pathToFileURL(path.resolve("scripts/lib/managed-child-process.mts")).href;
fs.writeFileSync(
childPath,
`
import { spawn } from "node:child_process";
import fs from "node:fs";
const descendant = spawn(process.execPath, [
"-e",
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);",
], { stdio: "ignore" });
fs.writeFileSync(process.argv[2], String(process.pid));
fs.writeFileSync(process.argv[3], String(descendant.pid));
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.on(signal, () => process.exit(0));
}
setInterval(() => {}, 1_000);
`,
"utf8",
);
fs.writeFileSync(
runnerPath,
`
import fs from "node:fs";
import { runManagedCommand } from ${JSON.stringify(helperUrl)};
process.exitCode = await runManagedCommand({
bin: process.execPath,
args: [${JSON.stringify(childPath)}, ${JSON.stringify(childPidPath)}, ${JSON.stringify(descendantPidPath)}],
stdio: "ignore",
onReady: () => fs.writeFileSync(${JSON.stringify(runnerReadyPath)}, "1"),
});
`,
"utf8",
);
const runner = spawn(process.execPath, [runnerPath], {
stdio: "ignore",
});
const runnerPid = expectProcessPid(runner.pid);
let childPid = 0;
let descendantPid = 0;
try {
await waitFor(() => fs.existsSync(runnerReadyPath));
await waitFor(() => fs.existsSync(childPidPath));
await waitFor(() => fs.existsSync(descendantPidPath));
childPid = Number(fs.readFileSync(childPidPath, "utf8"));
descendantPid = Number(fs.readFileSync(descendantPidPath, "utf8"));
expect(Number.isInteger(childPid)).toBe(true);
expect(Number.isInteger(descendantPid)).toBe(true);
expect(isProcessAlive(childPid)).toBe(true);
expect(isProcessAlive(descendantPid)).toBe(true);
process.kill(runnerPid, "SIGTERM");
const result = await waitForClose(runner);
expect(result).toEqual({ code: 143, signal: null });
await waitFor(() => !isProcessAlive(childPid), 1_500);
await waitFor(() => !isProcessAlive(descendantPid), 1_500);
} finally {
if (isProcessAlive(runnerPid)) {
process.kill(runnerPid, "SIGKILL");
}
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
if (descendantPid && isProcessAlive(descendantPid)) {
process.kill(descendantPid, "SIGKILL");
}
}
},
);
});
async function waitFor(condition: () => boolean, timeoutMs = 3_000) {
const startedAt = Date.now();
while (!condition()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error("timed out waiting for condition");
}
await delay(5);
}
}
async function waitForClose(child: ReturnType<typeof spawn>) {
return await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once("close", (code, signal) => resolve({ code, signal }));
});
}
function isProcessAlive(pid: number) {
try {
process.kill(pid, 0);
} catch {
return false;
}
if (process.platform !== "linux") {
return true;
}
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
// kill(pid, 0) also succeeds for a terminated process awaiting reaping.
return stat.charAt(stat.lastIndexOf(")") + 2) !== "Z";
} catch {
return false;
}
}