mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 04:40:43 +00:00
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
This commit is contained in:
@@ -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.
|
||||
|
||||
152
openclaw.mjs
152
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
140
src/entry.ts
140
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForFile(filePath: string, timeoutMs: number): Promise<string> {
|
||||
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<boolean> {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user