From 592998ae0eff57af81cf833eca8123e74f0ae9fd Mon Sep 17 00:00:00 2001 From: Kevin Lin Date: Mon, 4 May 2026 15:28:49 -0700 Subject: [PATCH] fix: clean up orphaned child processes (#77481) * fix: forward launcher respawn signals * docs: explain respawn signal exit timer * fix: centralize launcher respawn supervision * fix: include respawn helper in duplicate scan * fix: keep launcher respawn bridge local --- CHANGELOG.md | 1 + openclaw.mjs | 152 +++++++++++++++++++++++------ src/entry.compile-cache.test.ts | 110 ++++++++++++++++++++- src/entry.compile-cache.ts | 101 +++++++++++++++++-- src/entry.ts | 140 +++++++++++++------------- test/openclaw-launcher.e2e.test.ts | 134 ++++++++++++++++++++++++- 6 files changed, 527 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f69caec1f0..9ce5351e1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Docs: https://docs.openclaw.ai - Release validation: skip Slack live QA unless Slack credentials are explicitly configured, so release gates can keep proving non-Slack surfaces while Slack is still local and credential-gated. Thanks @vincentkoc. - Plugins/update: treat OpenClaw CalVer correction versions like `2026.5.3-1` as satisfying base plugin API ranges, so correction builds can install plugins that require the base runtime API. Fixes #77293. (#77450) Thanks @p3nchan. - Discord/Gateway startup: retry Discord READY waits with backoff, defer startup `sessions.list` and native approval readiness failures until sidecars recover, and preserve component-only Discord payloads when final reply scrubbing removes all text. (#77478) Thanks @NikolaFC. +- CLI/launcher: forward termination signals to compile-cache respawn children, so killing a wrapper process no longer leaves the security audit worker orphaned. Fixes #77458. Thanks @jaikharbanda. - fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987. - Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987. - Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333. diff --git a/openclaw.mjs b/openclaw.mjs index aae262e5b57..7a35f199b2a 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { access } from "node:fs/promises"; import module from "node:module"; @@ -84,6 +84,102 @@ const resolvePackagedCompileCacheDirectory = () => { ); }; +const respawnSignals = + process.platform === "win32" + ? ["SIGTERM", "SIGINT", "SIGBREAK"] + : ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"]; +const respawnSignalExitGraceMs = 1_000; +const respawnSignalForceKillGraceMs = 1_000; + +const runRespawnedChild = (command, args, env) => { + const child = spawn(command, args, { + stdio: "inherit", + env, + }); + const listeners = new Map(); + // This intentionally overlaps with src/entry.compile-cache.ts; keep the + // respawn supervision behavior in sync until the launcher can share TS code. + // Give the child a moment to honor forwarded signals, then exit the wrapper so + // a child that ignores SIGTERM cannot keep the launcher alive indefinitely. + let signalExitTimer = null; + let signalForceKillTimer = null; + const detach = () => { + for (const [signal, listener] of listeners) { + process.off(signal, listener); + } + listeners.clear(); + if (signalExitTimer) { + clearTimeout(signalExitTimer); + signalExitTimer = null; + } + if (signalForceKillTimer) { + clearTimeout(signalForceKillTimer); + signalForceKillTimer = null; + } + }; + const forceKillChild = () => { + try { + child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + } catch { + // Best-effort shutdown fallback. + } + }; + const requestChildTermination = () => { + try { + child.kill("SIGTERM"); + } catch { + // Best-effort shutdown fallback. + } + signalForceKillTimer = setTimeout(() => { + forceKillChild(); + process.exit(1); + }, respawnSignalForceKillGraceMs); + signalForceKillTimer.unref?.(); + }; + const scheduleParentExit = () => { + if (signalExitTimer) { + return; + } + signalExitTimer = setTimeout(() => { + requestChildTermination(); + }, respawnSignalExitGraceMs); + signalExitTimer.unref?.(); + }; + for (const signal of respawnSignals) { + const listener = () => { + try { + child.kill(signal); + } catch { + // Best-effort signal forwarding. + } + scheduleParentExit(); + }; + try { + process.on(signal, listener); + listeners.set(signal, listener); + } catch { + // Unsupported signal on this platform. + } + } + child.once("exit", (code, signal) => { + detach(); + if (signal) { + process.exit(1); + } + process.exit(code ?? 1); + }); + child.once("error", (error) => { + detach(); + process.stderr.write( + `[openclaw] Failed to respawn launcher: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n`, + ); + process.exit(1); + }); + return true; +}; + const respawnWithoutCompileCacheIfNeeded = () => { if (!isSourceCheckoutLauncher()) { return false; @@ -100,22 +196,13 @@ const respawnWithoutCompileCacheIfNeeded = () => { OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1", }; delete env.NODE_COMPILE_CACHE; - const result = spawnSync( + return runRespawnedChild( process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], - { - stdio: "inherit", - env, - }, + env, ); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); }; -respawnWithoutCompileCacheIfNeeded(); - const respawnWithPackagedCompileCacheIfNeeded = () => { if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) { return false; @@ -136,24 +223,23 @@ const respawnWithPackagedCompileCacheIfNeeded = () => { NODE_COMPILE_CACHE: desiredDirectory, OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED: "1", }; - const result = spawnSync( + return runRespawnedChild( process.execPath, [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], - { - stdio: "inherit", - env, - }, + env, ); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); }; -respawnWithPackagedCompileCacheIfNeeded(); +const waitingForCompileCacheRespawn = + respawnWithoutCompileCacheIfNeeded() || respawnWithPackagedCompileCacheIfNeeded(); // https://nodejs.org/api/module.html#module-compile-cache -if (module.enableCompileCache && !isNodeCompileCacheDisabled() && !isSourceCheckoutLauncher()) { +if ( + !waitingForCompileCacheRespawn && + module.enableCompileCache && + !isNodeCompileCacheDisabled() && + !isSourceCheckoutLauncher() +) { try { module.enableCompileCache(resolvePackagedCompileCacheDirectory()); } catch { @@ -297,17 +383,19 @@ const tryOutputBrowserHelp = () => { return true; }; -if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { - // OK -} else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { - // OK -} else { - await installProcessWarningFilter(); - if (await tryImport("./dist/entry.js")) { +if (!waitingForCompileCacheRespawn) { + if (!isHelpFastPathDisabled() && (await tryOutputBareRootHelp())) { // OK - } else if (await tryImport("./dist/entry.mjs")) { + } else if (!isHelpFastPathDisabled() && tryOutputBrowserHelp()) { // OK } else { - throw new Error(await buildMissingEntryErrorMessage()); + await installProcessWarningFilter(); + if (await tryImport("./dist/entry.js")) { + // OK + } else if (await tryImport("./dist/entry.mjs")) { + // OK + } else { + throw new Error(await buildMissingEntryErrorMessage()); + } } } diff --git a/src/entry.compile-cache.test.ts b/src/entry.compile-cache.test.ts index c43f1d9f3e7..576aeafc4ab 100644 --- a/src/entry.compile-cache.test.ts +++ b/src/entry.compile-cache.test.ts @@ -1,12 +1,15 @@ +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../test/helpers/temp-dir.js"; import { buildOpenClawCompileCacheRespawnPlan, isSourceCheckoutInstallRoot, resolveOpenClawCompileCacheDirectory, resolveEntryInstallRoot, + runOpenClawCompileCacheRespawnPlan, shouldEnableOpenClawCompileCache, } from "./entry.compile-cache.js"; @@ -122,4 +125,109 @@ describe("entry compile cache", () => { }), ).toBeUndefined(); }); + + it("runs compile-cache respawn plans with the child-process bridge", () => { + const child = new EventEmitter() as ChildProcess; + const spawn = vi.fn(() => child); + const attachChildProcessBridge = vi.fn(); + const exit = vi.fn(); + const writeError = vi.fn(); + + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js", "status"], + env: { NODE_DISABLE_COMPILE_CACHE: "1" }, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge, + exit: exit as unknown as (code?: number) => never, + writeError, + }, + ); + + expect(spawn).toHaveBeenCalledWith( + "/usr/bin/node", + ["/repo/openclaw/dist/entry.js", "status"], + { + stdio: "inherit", + env: { NODE_DISABLE_COMPILE_CACHE: "1" }, + }, + ); + expect(attachChildProcessBridge).toHaveBeenCalledWith(child, { + onSignal: expect.any(Function), + }); + + child.emit("exit", 0, null); + + expect(exit).toHaveBeenCalledWith(0); + expect(writeError).not.toHaveBeenCalled(); + }); + + it("marks signal-terminated compile-cache respawn children as failed without forcing another exit", () => { + const child = new EventEmitter() as ChildProcess; + const spawn = vi.fn(() => child); + const exit = vi.fn(); + + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js"], + env: {}, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge: vi.fn(), + exit: exit as unknown as (code?: number) => never, + writeError: vi.fn(), + }, + ); + + child.emit("exit", null, "SIGTERM"); + + expect(exit).toHaveBeenCalledWith(1); + }); + + it("terminates before force-killing a signaled compile-cache respawn child", () => { + vi.useFakeTimers(); + const child = new EventEmitter() as ChildProcess; + const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => true); + child.kill = kill as ChildProcess["kill"]; + const spawn = vi.fn(() => child); + const exit = vi.fn(); + let onSignal: ((signal: NodeJS.Signals) => void) | undefined; + + try { + runOpenClawCompileCacheRespawnPlan( + { + command: "/usr/bin/node", + args: ["/repo/openclaw/dist/entry.js"], + env: {}, + }, + { + spawn: spawn as unknown as typeof import("node:child_process").spawn, + attachChildProcessBridge: vi.fn((_child, options) => { + onSignal = options?.onSignal; + return { detach: vi.fn() }; + }), + exit: exit as unknown as (code?: number) => never, + writeError: vi.fn(), + }, + ); + + onSignal?.("SIGTERM"); + vi.advanceTimersByTime(1_000); + + expect(kill).toHaveBeenCalledWith("SIGTERM"); + expect(exit).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1_000); + + expect(kill).toHaveBeenCalledWith(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + expect(exit).toHaveBeenCalledWith(1); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/entry.compile-cache.ts b/src/entry.compile-cache.ts index f3b2c8905a1..72595d1bff4 100644 --- a/src/entry.compile-cache.ts +++ b/src/entry.compile-cache.ts @@ -1,8 +1,12 @@ -import { spawnSync } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { enableCompileCache, getCompileCacheDir } from "node:module"; import os from "node:os"; import path from "node:path"; +import { attachChildProcessBridge } from "./process/child-process-bridge.js"; + +const COMPILE_CACHE_RESPAWN_SIGNAL_EXIT_GRACE_MS = 1_000; +const COMPILE_CACHE_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS = 1_000; export function resolveEntryInstallRoot(entryFile: string): string { const entryDir = path.dirname(entryFile); @@ -84,12 +88,19 @@ export function resolveOpenClawCompileCacheDirectory(params: { ); } -type OpenClawCompileCacheRespawnPlan = { +export type OpenClawCompileCacheRespawnPlan = { command: string; args: string[]; env: NodeJS.ProcessEnv; }; +type OpenClawCompileCacheRespawnRuntime = { + spawn: typeof spawn; + attachChildProcessBridge: typeof attachChildProcessBridge; + exit: (code?: number) => never; + writeError: (message: string) => void; +}; + export function buildOpenClawCompileCacheRespawnPlan(params: { currentFile: string; env?: NodeJS.ProcessEnv; @@ -138,15 +149,89 @@ export function respawnWithoutOpenClawCompileCacheIfNeeded(params: { if (!plan) { return false; } - const result = spawnSync(plan.command, plan.args, { + runOpenClawCompileCacheRespawnPlan(plan); + return true; +} + +export function runOpenClawCompileCacheRespawnPlan( + plan: OpenClawCompileCacheRespawnPlan, + runtime: OpenClawCompileCacheRespawnRuntime = { + spawn, + attachChildProcessBridge, + exit: process.exit.bind(process) as (code?: number) => never, + writeError: (message: string) => process.stderr.write(message), + }, +): ChildProcess { + const child = runtime.spawn(plan.command, plan.args, { stdio: "inherit", env: plan.env, }); - if (result.error) { - throw result.error; - } - process.exit(result.status ?? 1); - return true; + // Give the child a moment to honor forwarded signals, then exit the parent so + // a child that ignores SIGTERM cannot keep the compile-cache wrapper alive indefinitely. + let signalExitTimer: NodeJS.Timeout | undefined; + let signalForceKillTimer: NodeJS.Timeout | undefined; + const clearSignalExitTimer = (): void => { + if (signalExitTimer) { + clearTimeout(signalExitTimer); + signalExitTimer = undefined; + } + if (signalForceKillTimer) { + clearTimeout(signalForceKillTimer); + signalForceKillTimer = undefined; + } + }; + const forceKillChild = (): void => { + try { + child.kill(process.platform === "win32" ? "SIGTERM" : "SIGKILL"); + } catch { + // Best-effort shutdown fallback. + } + }; + const requestChildTermination = (): void => { + try { + child.kill("SIGTERM"); + } catch { + // Best-effort shutdown fallback. + } + signalForceKillTimer = setTimeout(() => { + forceKillChild(); + runtime.exit(1); + }, COMPILE_CACHE_RESPAWN_SIGNAL_FORCE_KILL_GRACE_MS); + signalForceKillTimer.unref?.(); + }; + const scheduleParentExit = (): void => { + if (signalExitTimer) { + return; + } + signalExitTimer = setTimeout(() => { + requestChildTermination(); + }, COMPILE_CACHE_RESPAWN_SIGNAL_EXIT_GRACE_MS); + signalExitTimer.unref?.(); + }; + + runtime.attachChildProcessBridge(child, { + onSignal: scheduleParentExit, + }); + + child.once("exit", (code, signal) => { + clearSignalExitTimer(); + if (signal) { + runtime.exit(1); + } + runtime.exit(code ?? 1); + }); + + child.once("error", (error) => { + clearSignalExitTimer(); + runtime.writeError( + `[openclaw] Failed to respawn CLI without compile cache: ${ + error instanceof Error ? (error.stack ?? error.message) : String(error) + }\n`, + ); + runtime.exit(1); + }); + + return child; } export function enableOpenClawCompileCache(params: { diff --git a/src/entry.ts b/src/entry.ts index 4e15f468a27..22632b5612d 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -84,92 +84,94 @@ if ( } else { const entryFile = fileURLToPath(import.meta.url); const installRoot = resolveEntryInstallRoot(entryFile); - respawnWithoutOpenClawCompileCacheIfNeeded({ + const waitingForCompileCacheRespawn = respawnWithoutOpenClawCompileCacheIfNeeded({ currentFile: entryFile, installRoot, }); - process.title = "openclaw"; - ensureOpenClawExecMarkerOnProcess(); - installProcessWarningFilter(); - normalizeEnv(); - enableOpenClawCompileCache({ - installRoot, - }); - gatewayEntryStartupTrace.mark("bootstrap"); + if (!waitingForCompileCacheRespawn) { + process.title = "openclaw"; + ensureOpenClawExecMarkerOnProcess(); + installProcessWarningFilter(); + normalizeEnv(); + enableOpenClawCompileCache({ + installRoot, + }); + gatewayEntryStartupTrace.mark("bootstrap"); - if (shouldForceReadOnlyAuthStore(process.argv)) { - process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; - } - - if (process.argv.includes("--no-color")) { - process.env.NO_COLOR = "1"; - process.env.FORCE_COLOR = "0"; - } - - function ensureCliRespawnReady(): boolean { - const plan = buildCliRespawnPlan(); - if (!plan) { - return false; + if (shouldForceReadOnlyAuthStore(process.argv)) { + process.env.OPENCLAW_AUTH_STORE_READONLY = "1"; } - const child = spawn(plan.command, plan.argv, { - stdio: "inherit", - env: plan.env, - }); + if (process.argv.includes("--no-color")) { + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; + } - attachChildProcessBridge(child); - - child.once("exit", (code, signal) => { - if (signal) { - process.exitCode = 1; - return; + function ensureCliRespawnReady(): boolean { + const plan = buildCliRespawnPlan(); + if (!plan) { + return false; } - process.exit(code ?? 1); - }); - child.once("error", (error) => { - console.error( - "[openclaw] Failed to respawn CLI:", - error instanceof Error ? (error.stack ?? error.message) : error, - ); - process.exit(1); - }); + const child = spawn(plan.command, plan.argv, { + stdio: "inherit", + env: plan.env, + }); - // Parent must not continue running the CLI. - return true; - } + attachChildProcessBridge(child); - process.argv = normalizeWindowsArgv(process.argv); + child.once("exit", (code, signal) => { + if (signal) { + process.exitCode = 1; + return; + } + process.exit(code ?? 1); + }); - if (!ensureCliRespawnReady()) { - const parsedContainer = parseCliContainerArgs(process.argv); - if (!parsedContainer.ok) { - console.error(`[openclaw] ${parsedContainer.error}`); - process.exit(2); + child.once("error", (error) => { + console.error( + "[openclaw] Failed to respawn CLI:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exit(1); + }); + + // Parent must not continue running the CLI. + return true; } - const parsed = parseCliProfileArgs(parsedContainer.argv); - if (!parsed.ok) { - // Keep it simple; Commander will handle rich help/errors after we strip flags. - console.error(`[openclaw] ${parsed.error}`); - process.exit(2); - } + process.argv = normalizeWindowsArgv(process.argv); - const containerTargetName = resolveCliContainerTarget(process.argv); - if (containerTargetName && parsed.profile) { - console.error("[openclaw] --container cannot be combined with --profile/--dev"); - process.exit(2); - } + if (!ensureCliRespawnReady()) { + const parsedContainer = parseCliContainerArgs(process.argv); + if (!parsedContainer.ok) { + console.error(`[openclaw] ${parsedContainer.error}`); + process.exit(2); + } - if (parsed.profile) { - applyCliProfileEnv({ profile: parsed.profile }); - // Keep Commander and ad-hoc argv checks consistent. - process.argv = parsed.argv; - } - gatewayEntryStartupTrace.mark("argv"); + const parsed = parseCliProfileArgs(parsedContainer.argv); + if (!parsed.ok) { + // Keep it simple; Commander will handle rich help/errors after we strip flags. + console.error(`[openclaw] ${parsed.error}`); + process.exit(2); + } - if (!tryHandleRootVersionFastPath(process.argv)) { - await runMainOrRootHelp(process.argv); + const containerTargetName = resolveCliContainerTarget(process.argv); + if (containerTargetName && parsed.profile) { + console.error("[openclaw] --container cannot be combined with --profile/--dev"); + process.exit(2); + } + + if (parsed.profile) { + applyCliProfileEnv({ profile: parsed.profile }); + // Keep Commander and ad-hoc argv checks consistent. + process.argv = parsed.argv; + } + gatewayEntryStartupTrace.mark("argv"); + + if (!tryHandleRootVersionFastPath(process.argv)) { + await runMainOrRootHelp(process.argv); + } } } } diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 59ab00993f9..9a5f69cae6b 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -1,4 +1,4 @@ -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -36,6 +36,41 @@ async function addCompileCacheProbe(fixtureRoot: string): Promise { ); } +async function waitForFile(filePath: string, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + return await fs.readFile(filePath, "utf8"); + } catch { + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + throw new Error(`timed out waiting for ${filePath}`); +} + +async function waitUntil(check: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (check()) { + return true; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return check(); +} + +function isProcessAlive(pid: number | undefined): boolean { + if (!pid) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + function launcherEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { const env = { ...process.env, ...extra }; delete env.NODE_COMPILE_CACHE; @@ -138,6 +173,103 @@ describe("openclaw launcher", () => { expect(result.stdout).toBe("cache:disabled;respawn:1"); }); + it.runIf(process.platform !== "win32")( + "forwards SIGTERM to source-checkout compile-cache respawn children", + async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + 'process.title = "openclaw-launcher-sigterm-test-child";', + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", + }); + let respawnChildPid: number | undefined; + + try { + const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number }; + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await waitUntil(() => !isProcessAlive(respawnChildPid), 5000); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + if (isProcessAlive(respawnChildPid)) { + process.kill(respawnChildPid!, "SIGKILL"); + } + if (isProcessAlive(launcher.pid)) { + process.kill(launcher.pid!, "SIGKILL"); + } + } + }, + ); + + it.runIf(process.platform !== "win32")( + "exits after SIGTERM when the respawn child ignores the forwarded signal", + async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + 'process.title = "openclaw-launcher-sigterm-ignore-test-child";', + 'process.on("SIGTERM", () => {});', + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", + }); + let respawnChildPid: number | undefined; + + try { + const childInfo = JSON.parse(await waitForFile(childInfoPath, 5000)) as { pid: number }; + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await waitUntil( + () => !isProcessAlive(launcher.pid) && !isProcessAlive(respawnChildPid), + 5000, + ); + expect(isProcessAlive(launcher.pid)).toBe(false); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + if (isProcessAlive(respawnChildPid)) { + process.kill(respawnChildPid!, "SIGKILL"); + } + if (isProcessAlive(launcher.pid)) { + process.kill(launcher.pid!, "SIGKILL"); + } + } + }, + ); + it.runIf(process.platform !== "win32")( "respawns symlinked source-checkout launchers without inherited NODE_COMPILE_CACHE", async () => {