From 120d08c73057e09ddc20c85b1f2845d0082518c9 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 20 Jun 2026 11:00:14 +0200 Subject: [PATCH] fix(scripts): preserve boundary abort grace --- ...e-extension-package-boundary-artifacts.mjs | 77 ++++++++++++++----- ...tension-package-boundary-artifacts.test.ts | 44 +++++++++++ 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 61f6d19284cb..fccf7b0ef6cc 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -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}`)); + })(); }); }); } diff --git a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts index e148bd801845..b68fc6857a48 100644 --- a/test/scripts/prepare-extension-package-boundary-artifacts.test.ts +++ b/test/scripts/prepare-extension-package-boundary-artifacts.test.ts @@ -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(); @@ -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 = []; const child = new EventEmitter() as EventEmitter & {