diff --git a/scripts/e2e/lib/bun-global-install/assertions.mjs b/scripts/e2e/lib/bun-global-install/assertions.mjs index ef0fce30939f..4181e4e56394 100644 --- a/scripts/e2e/lib/bun-global-install/assertions.mjs +++ b/scripts/e2e/lib/bun-global-install/assertions.mjs @@ -35,6 +35,31 @@ const signalChild = (child, signal) => { } }; +const processGroupAlive = (child) => { + if (process.platform === "win32" || !child.pid) { + return false; + } + try { + process.kill(-child.pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +}; + +const waitForProcessGroupExit = async (child, timeout) => { + const deadlineAt = Date.now() + timeout; + while (Date.now() < deadlineAt) { + if (!processGroupAlive(child)) { + return true; + } + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + return !processGroupAlive(child); +}; + const runWithTimeout = async (timeout, command, commandArgs) => { const killGrace = parsePositiveNumber( process.env.OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS ?? @@ -48,6 +73,7 @@ const runWithTimeout = async (timeout, command, commandArgs) => { }); let timedOut = false; let killTimer; + let killDeadlineAt = 0; child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); @@ -57,6 +83,7 @@ const runWithTimeout = async (timeout, command, commandArgs) => { const timeoutTimer = setTimeout(() => { timedOut = true; signalChild(child, "SIGTERM"); + killDeadlineAt = Date.now() + killGrace; killTimer = setTimeout(() => signalChild(child, "SIGKILL"), killGrace); killTimer.unref(); }, timeout); @@ -71,11 +98,20 @@ const runWithTimeout = async (timeout, command, commandArgs) => { }); clearTimeout(timeoutTimer); - clearTimeout(killTimer); if (timedOut) { + const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now()); + if (remainingGraceMs > 0) { + await waitForProcessGroupExit(child, remainingGraceMs); + } + if (processGroupAlive(child)) { + signalChild(child, "SIGKILL"); + await waitForProcessGroupExit(child, 100); + } + clearTimeout(killTimer); console.error(`command timed out after ${timeout}ms: ${command}`); process.exit(1); } + clearTimeout(killTimer); if (result.error) { console.error(`command failed: ${command}: ${result.error.message}`); process.exit(1); diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 2a5203695a8d..7ecc77995335 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -1,8 +1,10 @@ // Test Install Sh Docker tests cover test install sh docker script behavior. import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; import { runInNewContext } from "node:vm"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTempDirTracker } from "../helpers/temp-dir.js"; const SCRIPT_PATH = "scripts/test-install-sh-docker.sh"; const INSTALL_E2E_DOCKER_PATH = "scripts/test-install-sh-e2e-docker.sh"; @@ -17,6 +19,11 @@ const BUN_GLOBAL_ASSERTIONS_PATH = "scripts/e2e/lib/bun-global-install/assertion const INSTALL_SMOKE_WORKFLOW_PATH = ".github/workflows/install-smoke.yml"; const RELEASE_CHECKS_WORKFLOW_PATH = ".github/workflows/openclaw-release-checks.yml"; const LIVE_E2E_WORKFLOW_PATH = ".github/workflows/openclaw-live-and-e2e-checks-reusable.yml"; +const tempDirs = createTempDirTracker(); + +afterEach(() => { + tempDirs.cleanup(); +}); class ScriptExit extends Error { constructor(readonly status: number) { @@ -757,6 +764,54 @@ describe("bun global install smoke", () => { } }); + it.runIf(process.platform !== "win32" && existsSync("/usr/bin/time"))( + "preserves Bun global timeout kill grace after the leader exits", + () => { + const tempDir = tempDirs.make("openclaw-bun-global-timeout-grace-"); + const readyPath = path.join(tempDir, "ready"); + const drainedPath = path.join(tempDir, "drained"); + const childScript = [ + "const fs = require('node:fs');", + "process.on('SIGTERM', () => {", + " setTimeout(() => {", + " fs.writeFileSync(process.argv[2], 'drained');", + " process.exit(0);", + " }, 50);", + "});", + "fs.writeFileSync(process.argv[1], 'ready');", + "setInterval(() => {}, 1000);", + ].join("\n"); + + const result = spawnSync( + process.execPath, + [ + BUN_GLOBAL_ASSERTIONS_PATH, + "run-with-timeout", + "500", + "/usr/bin/time", + process.execPath, + "-e", + childScript, + readyPath, + drainedPath, + ], + { + encoding: "utf8", + env: { + ...process.env, + OPENCLAW_BUN_GLOBAL_SMOKE_TIMEOUT_KILL_GRACE_MS: "1000", + }, + timeout: 5_000, + }, + ); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("command timed out after 500ms: /usr/bin/time"); + expect(readFileSync(readyPath, "utf8")).toBe("ready"); + expect(readFileSync(drainedPath, "utf8")).toBe("drained"); + }, + ); + it("gates workflow Bun install smoke to scheduled and release-check runs", () => { const workflow = readFileSync(INSTALL_SMOKE_WORKFLOW_PATH, "utf8"); const releaseChecks = readFileSync(RELEASE_CHECKS_WORKFLOW_PATH, "utf8");