fix(scripts): preserve boundary abort grace

This commit is contained in:
Vincent Koc
2026-06-20 11:00:14 +02:00
parent 6ee7714306
commit 120d08c730
2 changed files with 101 additions and 20 deletions
@@ -444,9 +444,43 @@ export function runNodeStep(label, args, timeoutMs, params = {}) {
let settled = false;
let canceled = false;
let killTimer;
let killDeadlineAt = 0;
const stdoutWriter = createPrefixedOutputWriter(label, process.stdout);
const stderrWriter = createPrefixedOutputWriter(label, process.stderr);
const killNodeStep = (signal) => signalNodeStep(child, signal);
const processGroupAlive = () => {
if (process.platform === "win32" || !child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return Boolean(error && error.code === "EPERM");
}
};
const waitForProcessGroupExit = async (waitMs) => {
const deadlineAt = Date.now() + waitMs;
while (Date.now() < deadlineAt) {
if (!processGroupAlive()) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, 25);
});
}
return !processGroupAlive();
};
const waitForCanceledStepTeardown = async () => {
const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
if (remainingGraceMs > 0) {
await waitForProcessGroupExit(remainingGraceMs);
}
if (processGroupAlive()) {
killNodeStep("SIGKILL");
await waitForProcessGroupExit(100);
}
};
ACTIVE_NODE_STEP_KILLERS.add(killNodeStep);
const abortStep = () => {
if (settled || canceled) {
@@ -454,6 +488,7 @@ export function runNodeStep(label, args, timeoutMs, params = {}) {
}
canceled = true;
killNodeStep("SIGTERM");
killDeadlineAt = Date.now() + NODE_STEP_ABORT_KILL_GRACE_MS;
killTimer = setTimeout(() => {
killTimer = undefined;
killNodeStep("SIGKILL");
@@ -508,27 +543,29 @@ export function runNodeStep(label, args, timeoutMs, params = {}) {
if (settled) {
return;
}
settled = true;
stdoutWriter.flush();
stderrWriter.flush();
if (exitingAfterParentSignal) {
killNodeStep("SIGKILL");
void (async () => {
settled = true;
stdoutWriter.flush();
stderrWriter.flush();
if (exitingAfterParentSignal) {
killNodeStep("SIGKILL");
cleanup();
return;
}
if (canceled) {
await waitForCanceledStepTeardown();
cleanup();
rejectPromise(new Error(`${label} canceled after sibling failure`));
return;
}
cleanup();
return;
}
if (canceled) {
killNodeStep("SIGKILL");
cleanup();
rejectPromise(new Error(`${label} canceled after sibling failure`));
return;
}
cleanup();
if (code === 0) {
resolvePromise();
return;
}
abortSiblingSteps(abortController);
rejectPromise(new Error(`${label} failed with exit code ${code ?? 1}`));
if (code === 0) {
resolvePromise();
return;
}
abortSiblingSteps(abortController);
rejectPromise(new Error(`${label} failed with exit code ${code ?? 1}`));
})();
});
});
}
@@ -17,6 +17,7 @@ import {
runNodeSteps,
runNodeStepsInParallel,
} from "../../scripts/prepare-extension-package-boundary-artifacts.mjs";
import { makeTempDir } from "../helpers/temp-dir.js";
const tempRoots = new Set<string>();
@@ -170,6 +171,49 @@ describe("prepare-extension-package-boundary-artifacts", () => {
},
);
it.runIf(process.platform !== "win32")(
"lets aborted sibling descendants drain during kill grace",
async () => {
const rootDir = makeTempDir(tempRoots, "openclaw-boundary-abort-drain-");
const readyPath = path.join(rootDir, "descendant.ready");
const drainedPath = path.join(rootDir, "descendant.drained");
const descendantScript = [
"const fs = require('node:fs');",
"process.on('SIGTERM', () => {",
" setTimeout(() => {",
` fs.writeFileSync(${JSON.stringify(drainedPath)}, 'drained');`,
" process.exit(0);",
" }, 50);",
"});",
`fs.writeFileSync(${JSON.stringify(readyPath)}, 'ready');`,
"setInterval(() => {}, 1000);",
].join("\n");
const parentScript = [
"const { spawn } = require('node:child_process');",
`spawn(process.execPath, ["--eval", ${JSON.stringify(descendantScript)}], { stdio: "ignore" });`,
"process.on('SIGTERM', () => process.exit(0));",
"setInterval(() => {}, 1000);",
].join("\n");
const command = runNodeStepsInParallel([
{
label: "delayed-fail",
args: ["--eval", "setTimeout(() => process.exit(2), 150)"],
timeoutMs: 5_000,
},
{
label: "abort-group-drain",
args: ["--eval", parentScript],
timeoutMs: 60_000,
},
]);
await waitForFile(readyPath, 1_000);
await expect(command).rejects.toThrow("delayed-fail failed with exit code 2");
expect(fs.readFileSync(drainedPath, "utf8")).toBe("drained");
},
);
it("hard-kills timed out prep steps", async () => {
const signals: Array<NodeJS.Signals | number | undefined> = [];
const child = new EventEmitter() as EventEmitter & {