diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index c3dc775a9df..fa633ecc8ce 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -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; } diff --git a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts index 7bea6f99634..ffa19f8712a 100644 --- a/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts +++ b/test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts @@ -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;