mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
73bdb4b924
* feat(agents): record run-end worktree cleanup outcome Persist removed, retained, and failed run-end cleanup outcomes on managed worktree records. Operators and QA can inspect the durable fact through worktrees.list and openclaw worktrees list --json. Release note: Managed worktree run-end cleanup now records why a checkout was removed or retained in worktree list JSON. * test(qa): prove dirty worktree retention outcome * chore(protocol): regenerate swift gateway models * fix(agents): harden worktree cleanup recovery Register run_end_cleanup_json as a lazy compatible column so same-version v6 index repair and read-only doctor migration can recover databases created before the column existed. Type removal contention at the registry boundary; unexpected claim failures now best-effort record a bounded failed outcome and rethrow the original error. * fix(ci): clear repo-wide lint debt blocking merge gates The red-main landing rule requires this PR to repair repository-wide merge-gate debt instead of bypassing it. Apply the current lint contracts mechanically and split turn-transition coverage into a concept-named sibling with per-file-safe test state. Exact line delta: +676/-574 (net +102) across 44 test/support files. * fix(ci): preserve cached health refresh proof Require the public refresh call to exist before accepting that sensitive fields were omitted, so the boundary proof cannot pass on a missing call. * fix(ci): correct test typing left by the lint sweep Literal-widened totalTokensVersion fixtures, a WebSocket RawData overload mismatch, and the protocol schema document cast broke check-test-types after the repo-wide lint repair. Aligns the fixtures with SessionEntry, narrows Buffer handling per RawData, and keeps the JSON-shaped undefined omission under structuredClone. * test(agents): reuse upstream resource-loader test support The session-loop split and #120463's helper extraction landed the same createResourceLoader/createCompactionHandlers twice; the rebase kept both, orphaning main's agent-session-loop-resource-loader.test-support.ts and failing the dead-code gate. Import the upstream helpers and delete the duplicates. * fix(agents): reject finalized rows at the worktree removal claim Address the accepted ClawSweeper late-claim finding by rereading and rejecting missing or finalized worktree rows inside the synchronous removal-claim transaction. Preserve the authoritative cleanup invariant: finalized contenders record nothing, while retained-busy is written only while the row remains live. * refactor(agents): reuse registry update for busy outcomes Keep the live-row conditional write in the canonical registry update path so the finalized-claim repair stays below the registry max-lines ratchet without weakening the authoritative-outcome invariant. * test(agents): drop session test duplicates after rebase Keep current main as the canonical owner of next-turn lifecycle coverage and correctness test support after replaying the older lint-debt split. * fix(agents): guard post-abort cleanup outcomes against finalization After abortWorktreeRemoval releases a stale remover's claim, its retained or failed write raced a finalizing remover and could overwrite the authoritative removed-lossless fact. Route every retained/failed write through the live-row condition; only the finalizing remover's own removed-lossless write stays unconditional. * fix(agents): persist the removal outcome atomically with finalization A delayed removed-lossless write after remove() finalized could race a restore plus newer cleanup and overwrite the newer operator-visible fact. The run-end outcome now rides remove()'s finalization update; every other cleanup write stays live-row conditional, so no post-finalize write path remains. * test(qa): restore strict cached-health contract assertions The lint sweep's Boolean() coercions let truthy non-booleans satisfy the wire-typed cached-meta contract. Assert the literal boolean for unknown-typed fields and use nullish-coalesced strict equivalents for boolean chains. * fix(agents): clear the stale cleanup outcome when restoring a worktree A restored checkout begins a new lifecycle; leaving the removed-lossless fact on the live row showed operators a stale result until the next cleanup. Restore clears the recorded outcome and the regression asserts the cleared state before the next cleanup records fresh truth. * fix(agents): scope stale cleanup outcomes to their observed lifecycle A stale remover's retained/failed write raced a concurrent remove-plus- restore: the revived row is live again, so the live-row condition alone could stamp a prior-lifecycle outcome. Condition those writes on the activity stamp the remover observed; restore bumps lastActiveAt, making any prior-lifecycle write a no-op. * fix(agents): advance the restore activity stamp within one millisecond Stale cleanup writes fence on the activity stamp they observed; a restore completing in the same millisecond could revive the row with an identical stamp and let the fence match. Restore now always advances past the stored value, and the ABA regression pins the clock to prove the same-millisecond case.
649 lines
20 KiB
TypeScript
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.mjs";
|
|
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.mjs")).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;
|
|
}
|
|
}
|