diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index cc684fd8ff5..20a7a0c0911 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -13,6 +13,7 @@ const DEFAULT_PACKAGE_INVENTORY_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_PACKAGE_PACK_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_PACKAGE_TARBALL_CHECK_TIMEOUT_MS = 5 * 60 * 1000; const DEFAULT_TIMEOUT_KILL_AFTER_MS = 5_000; +const DEFAULT_CAPTURED_STDOUT_MAX_BYTES = 1024 * 1024; const ACTIVE_CHILD_KILLERS = new Set(); const SIGNAL_EXIT_CODES = { SIGHUP: 129, @@ -91,9 +92,16 @@ function run(command, args, cwd, options = {}) { detached: useProcessGroup, }); let timedOut = false; + let outputLimitExceeded = false; let stdout = ""; + let stdoutBytes = 0; let settled = false; let timeout; + let forceKillTimeout; + const maxCapturedStdoutBytes = Math.max( + 1, + options.maxCapturedStdoutBytes ?? DEFAULT_CAPTURED_STDOUT_MAX_BYTES, + ); const finish = (error, value = "") => { if (settled) { return; @@ -123,22 +131,37 @@ function run(command, args, cwd, options = {}) { } child.kill(signal); }; + const terminateChild = () => { + killChild("SIGTERM"); + forceKillTimeout = setTimeout( + () => killChild("SIGKILL"), + options.killAfterMs ?? DEFAULT_TIMEOUT_KILL_AFTER_MS, + ); + forceKillTimeout.unref?.(); + }; ACTIVE_CHILD_KILLERS.add(killChild); timeout = options.timeoutMs === undefined ? undefined : setTimeout(() => { timedOut = true; - killChild("SIGTERM"); - setTimeout( - () => killChild("SIGKILL"), - options.killAfterMs ?? DEFAULT_TIMEOUT_KILL_AFTER_MS, - ).unref?.(); + terminateChild(); }, options.timeoutMs); timeout?.unref?.(); if (options.captureStdout) { child.stdout.on("data", (chunk) => { - stdout += String(chunk); + if (outputLimitExceeded) { + return; + } + const chunkText = String(chunk); + const chunkBytes = Buffer.byteLength(chunkText); + if (stdoutBytes + chunkBytes > maxCapturedStdoutBytes) { + outputLimitExceeded = true; + terminateChild(); + return; + } + stdout += chunkText; + stdoutBytes += chunkBytes; }); } else { child.stdout.pipe(process.stderr, { end: false }); @@ -150,6 +173,14 @@ function run(command, args, cwd, options = {}) { finish(new Error(`${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms`)); return; } + if (outputLimitExceeded) { + finish( + new Error( + `${command} ${args.join(" ")} exceeded captured stdout limit (${maxCapturedStdoutBytes} bytes)`, + ), + ); + return; + } if (status === 0) { finish(undefined, stdout); return; diff --git a/test/scripts/package-openclaw-for-docker.test.ts b/test/scripts/package-openclaw-for-docker.test.ts index 2b5a3ae75f3..a0b23455915 100644 --- a/test/scripts/package-openclaw-for-docker.test.ts +++ b/test/scripts/package-openclaw-for-docker.test.ts @@ -155,6 +155,62 @@ describe("package-openclaw-for-docker", () => { } }); + it("keeps fallback SIGKILL armed for descendants after the direct child exits", async () => { + if (process.platform === "win32") { + return; + } + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-package-descendant-")); + const childPidPath = path.join(tempDir, "child.pid"); + let childPid = 0; + try { + const childScript = ["process.on('SIGTERM', () => {});", "setInterval(() => {}, 1000);"].join( + "", + ); + const parentScript = [ + "const { spawn } = require('node:child_process');", + "const fs = require('node:fs');", + `const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], { stdio: 'ignore' });`, + "fs.writeFileSync(process.env.OPENCLAW_TEST_CHILD_PID, String(child.pid));", + "setInterval(() => {}, 1000);", + ].join(""); + + await expect( + runCommandForTest(process.execPath, ["-e", parentScript], process.cwd(), { + env: { ...process.env, OPENCLAW_TEST_CHILD_PID: childPidPath }, + killAfterMs: 50, + timeoutMs: 2000, + }), + ).rejects.toThrow(/timed out after 2000ms/u); + + await waitForFile(childPidPath, 2000); + childPid = Number(fs.readFileSync(childPidPath, "utf8")); + await waitForDead(childPid, 2000); + } finally { + if (childPid && isProcessAlive(childPid)) { + process.kill(childPid, "SIGKILL"); + } + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); + + it("fails captured commands that exceed the stdout limit", async () => { + const script = [ + "process.stdout.write('x'.repeat(2048));", + "process.on('SIGTERM', () => {});", + "setInterval(() => {}, 1000);", + ].join(""); + + await expect( + runCommandForTest(process.execPath, ["-e", script], process.cwd(), { + captureStdout: true, + killAfterMs: 50, + maxCapturedStdoutBytes: 1024, + timeoutMs: 5000, + }), + ).rejects.toThrow(/exceeded captured stdout limit \(1024 bytes\)/u); + }); + it("forwards external termination to active child process groups", async () => { if (process.platform === "win32") { return;