From 3e701449ff12b300097a9bdf7c5f1b730e1dcbab Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 27 May 2026 01:23:55 +0200 Subject: [PATCH] fix(e2e): keep mac smoke commands bounded without timeout --- CHANGELOG.md | 1 + scripts/lib/openclaw-e2e-instance.sh | 78 +++++++++++++++++++++- test/scripts/openclaw-e2e-instance.test.ts | 51 +++++++++++--- 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f46bf9e537b..0fed97eae6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai - Crabbox: bootstrap raw AWS macOS JavaScript commands launched through `/usr/bin/env` so native mac runners without preinstalled Node, Corepack, or pnpm can still run wrapped Node and pnpm proof. - macOS: let app packaging fall back to `corepack pnpm` when a fresh native runner has Node/Corepack but no pnpm shim on `PATH`. +- E2E: keep package/onboarding/plugin smoke commands bounded on macOS shells that have Node but no GNU `timeout` or `gtimeout` binary. ## 2026.5.26 diff --git a/scripts/lib/openclaw-e2e-instance.sh b/scripts/lib/openclaw-e2e-instance.sh index bff1fce9694..4eb644743ad 100644 --- a/scripts/lib/openclaw-e2e-instance.sh +++ b/scripts/lib/openclaw-e2e-instance.sh @@ -52,7 +52,83 @@ openclaw_e2e_maybe_timeout() { timeout_bin="gtimeout" fi if [ -z "$timeout_bin" ]; then - echo "timeout or gtimeout is required for OpenClaw E2E command timeout $timeout_value" >&2 + if command -v node >/dev/null 2>&1; then + echo "timeout command not found; using Node watchdog for OpenClaw E2E command timeout $timeout_value" >&2 + node - "$timeout_value" "$@" <<'NODE' +const [, , timeoutValue, command, ...args] = process.argv; +const parseTimeoutMs = (value) => { + const match = /^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)?$/u.exec(String(value ?? "").trim()); + if (!match) { + throw new Error(`unsupported timeout value: ${value}`); + } + const amount = Number(match[1]); + const unit = match[2] ?? "s"; + const multiplier = unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000; + return Math.max(1, Math.ceil(amount * multiplier)); +}; +if (!command) { + console.error("missing command for Node watchdog"); + process.exit(1); +} +const { spawn } = await import("node:child_process"); +let timeoutMs; +try { + timeoutMs = parseTimeoutMs(timeoutValue); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} +const child = spawn(command, args, { + detached: process.platform !== "win32", + stdio: "inherit", +}); +let timedOut = false; +const killTarget = process.platform === "win32" ? child.pid : -child.pid; +const killChild = (signal) => { + if (!child.pid) { + return; + } + try { + process.kill(killTarget, signal); + } catch { + try { + child.kill(signal); + } catch {} + } +}; +const timer = setTimeout(() => { + timedOut = true; + console.error(`OpenClaw E2E command timed out after ${timeoutValue}`); + killChild("SIGTERM"); + setTimeout(() => killChild("SIGKILL"), 30_000).unref(); +}, timeoutMs); +const forwardSignal = (signal) => { + killChild(signal); +}; +process.once("SIGINT", forwardSignal); +process.once("SIGTERM", forwardSignal); +child.on("exit", (code, signal) => { + clearTimeout(timer); + if (timedOut) { + process.exit(124); + } + if (code !== null) { + process.exit(code); + } + if (signal) { + process.kill(process.pid, signal); + } + process.exit(1); +}); +child.on("error", (error) => { + clearTimeout(timer); + console.error(error.message); + process.exit(127); +}); +NODE + return + fi + echo "timeout command not found and Node is unavailable; cannot bound OpenClaw E2E command after $timeout_value" >&2 return 127 fi if "$timeout_bin" --kill-after=1s 1s true >/dev/null 2>&1; then diff --git a/test/scripts/openclaw-e2e-instance.test.ts b/test/scripts/openclaw-e2e-instance.test.ts index 69934701bb7..e827aa97cf8 100644 --- a/test/scripts/openclaw-e2e-instance.test.ts +++ b/test/scripts/openclaw-e2e-instance.test.ts @@ -241,18 +241,18 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => { } }); - it("fails package installs when no timeout binary is available", () => { + it("uses the Node watchdog when timeout is unavailable", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-instance-no-timeout-")); try { const npmArgsPath = path.join(tempDir, "npm-args.txt"); const logPath = path.join(tempDir, "install.log"); const packagePath = path.join(tempDir, "openclaw.tgz"); + const nodeBinDir = path.dirname(process.execPath); fs.writeFileSync(packagePath, ""); fs.writeFileSync( path.join(tempDir, "npm"), ["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"), ); - fs.symlinkSync("/bin/cat", path.join(tempDir, "cat")); fs.chmodSync(path.join(tempDir, "npm"), 0o755); const result = spawnSync( @@ -269,7 +269,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => { encoding: "utf8", env: { ...process.env, - PATH: tempDir, + PATH: `${tempDir}:${nodeBinDir}`, OPENCLAW_CURRENT_PACKAGE_TGZ: packagePath, OPENCLAW_E2E_NPM_INSTALL_TIMEOUT: "42s", OPENCLAW_TEST_NPM_ARGS: npmArgsPath, @@ -277,15 +277,46 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => { }, ); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain( - "timeout or gtimeout is required for OpenClaw E2E command timeout 42s", + expect(result.status).toBe(0); + expect(fs.readFileSync(logPath, "utf8")).toContain("using Node watchdog"); + expect(fs.readFileSync(npmArgsPath, "utf8").trim()).toBe( + `install -g ${packagePath} --no-fund --no-audit`, ); - expect(result.stderr).toContain("npm install failed for fixture package"); - expect(fs.readFileSync(logPath, "utf8")).toContain( - "timeout or gtimeout is required for OpenClaw E2E command timeout 42s", + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + } + }); + + it("bounds commands with the Node watchdog when timeout is unavailable", () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-e2e-instance-node-watchdog-")); + try { + const nodeBinDir = path.dirname(process.execPath); + const startedAt = Date.now(); + const result = spawnSync( + "/bin/bash", + [ + "-c", + [ + "set -euo pipefail", + `source ${shellQuote(helperPath)}`, + `openclaw_e2e_maybe_timeout 200ms ${shellQuote(process.execPath)} -e ${shellQuote("setInterval(() => {}, 1000)")}`, + ].join("; "), + ], + { + encoding: "utf8", + env: { + ...process.env, + PATH: `${tempDir}:${nodeBinDir}`, + }, + timeout: 5_000, + }, ); - expect(fs.existsSync(npmArgsPath)).toBe(false); + const elapsedMs = Date.now() - startedAt; + + expect(result.status).toBe(124); + expect(elapsedMs).toBeLessThan(4_000); + expect(result.stderr).toContain("using Node watchdog"); + expect(result.stderr).toContain("OpenClaw E2E command timed out after 200ms"); } finally { fs.rmSync(tempDir, { force: true, recursive: true }); }