fix(scripts): clamp package docker timers

This commit is contained in:
Vincent Koc 2026-06-22 02:47:41 +02:00
parent 75c6a8fff5
commit 851b65c060
No known key found for this signature in database
2 changed files with 78 additions and 5 deletions

View file

@ -17,6 +17,7 @@ const DEFAULT_TIMEOUT_KILL_AFTER_MS = 5_000;
const PROCESS_GROUP_EXIT_POLL_MS = 25;
const POST_FORCE_KILL_WAIT_MS = 1_000;
const DEFAULT_CAPTURED_STDOUT_MAX_BYTES = 1024 * 1024;
const MAX_TIMER_TIMEOUT_MS = 2_147_000_000;
const ACTIVE_CHILD_KILLERS = new Set();
const SIGNAL_EXIT_CODES = {
SIGHUP: 129,
@ -65,6 +66,23 @@ function resolveTimeoutMs(envName, defaultValue) {
return parsed;
}
function numericTimerValueMs(valueMs) {
const value = Number(valueMs);
return Number.isFinite(value) ? Math.floor(value) : undefined;
}
function resolveTimerTimeoutMs(valueMs, fallbackMs = MAX_TIMER_TIMEOUT_MS) {
const value = numericTimerValueMs(valueMs) ?? numericTimerValueMs(fallbackMs);
return Math.min(Math.max(value ?? MAX_TIMER_TIMEOUT_MS, 1), MAX_TIMER_TIMEOUT_MS);
}
function resolveOptionalTimerTimeoutMs(valueMs) {
if (valueMs === undefined) {
return undefined;
}
return resolveTimerTimeoutMs(valueMs, 1);
}
function readOptionValue(argv, index, optionName) {
const value = argv[index + 1];
if (value === undefined || value === "" || value.startsWith("-")) {
@ -149,6 +167,11 @@ export function parseArgs(argv) {
function run(command, args, cwd, options = {}) {
return new Promise((resolve, reject) => {
const resolvedTimeoutMs = resolveOptionalTimerTimeoutMs(options.timeoutMs);
const resolvedKillAfterMs = resolveTimerTimeoutMs(
options.killAfterMs,
DEFAULT_TIMEOUT_KILL_AFTER_MS,
);
const useProcessGroup = process.platform !== "win32";
const child = spawn(command, args, {
cwd,
@ -230,21 +253,21 @@ function run(command, args, cwd, options = {}) {
return;
}
killChild("SIGKILL");
}, options.killAfterMs ?? DEFAULT_TIMEOUT_KILL_AFTER_MS);
}, resolvedKillAfterMs);
forceKillTimeout.unref?.();
};
ACTIVE_CHILD_KILLERS.add(killChild);
const timeout =
options.timeoutMs === undefined
resolvedTimeoutMs === undefined
? undefined
: setTimeout(() => {
timedOut = true;
terminateChild();
}, options.timeoutMs);
}, resolvedTimeoutMs);
timeout?.unref?.();
const finishAfterTeardown = async (error, value = "") => {
if (processGroupAlive()) {
await waitForProcessGroupExit(options.killAfterMs ?? DEFAULT_TIMEOUT_KILL_AFTER_MS);
await waitForProcessGroupExit(resolvedKillAfterMs);
}
if (processGroupAlive()) {
killChild("SIGKILL");
@ -275,7 +298,7 @@ function run(command, args, cwd, options = {}) {
child.on("close", (status, signal) => {
if (timedOut) {
void finishAfterTeardown(
new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`),
new Error(`${command} ${args.join(" ")} timed out after ${resolvedTimeoutMs}ms`),
);
return;
}

View file

@ -4,6 +4,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { describe, expect, it, vi } from "vitest";
import {
buildPackageArtifacts,
@ -316,6 +317,20 @@ describe("package-openclaw-for-docker", () => {
expect(calls).toEqual(["prepare:/repo", "pack", "restore:/repo"]);
});
it("clamps oversized command timers before scheduling", async () => {
await expect(
runCommandForTest(
process.execPath,
["-e", "setTimeout(() => process.exit(0), 25);"],
process.cwd(),
{
killAfterMs: MAX_TIMER_TIMEOUT_MS + 1,
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1,
},
),
).resolves.toBe("");
});
it("kills timed-out child process groups", async () => {
if (process.platform === "win32") {
return;
@ -354,6 +369,41 @@ describe("package-openclaw-for-docker", () => {
}
});
it("clamps oversized kill grace before scheduling", async () => {
if (process.platform === "win32") {
return;
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-grace-"));
const donePath = path.join(tempDir, "done");
const childPidPath = path.join(tempDir, "child.pid");
let childPid;
try {
const script = [
"const fs = require('node:fs');",
`fs.writeFileSync(${JSON.stringify(childPidPath)}, String(process.pid));`,
"process.on('SIGTERM', () => {",
` setTimeout(() => { fs.writeFileSync(${JSON.stringify(donePath)}, 'done'); process.exit(0); }, 75);`,
"});",
"setInterval(() => {}, 1000);",
].join("\n");
const runPromise = runCommandForTest(process.execPath, ["-e", script], process.cwd(), {
killAfterMs: MAX_TIMER_TIMEOUT_MS + 1,
timeoutMs: 500,
});
childPid = await readPid(childPidPath, 2000);
await expect(runPromise).rejects.toThrow(/timed out after 500ms/u);
expect(fs.readFileSync(donePath, "utf8")).toBe("done");
} finally {
if (childPid && isProcessAlive(childPid)) {
process.kill(childPid, "SIGKILL");
}
fs.rmSync(tempDir, { force: true, recursive: true });
}
});
it("keeps fallback SIGKILL armed for descendants after the direct child exits", async () => {
if (process.platform === "win32") {
return;