Files
openclaw/src/infra/process-respawn.ts
Robin Waslander 3c0fd3dffe fix(daemon): replace bootout with kickstart -k for launchd restarts on macOS
On macOS, launchctl bootout permanently unloads the LaunchAgent plist.
Even with KeepAlive: true, launchd cannot respawn a service whose plist
has been removed from its registry. This left users with a dead gateway
requiring manual 'openclaw gateway install' to recover.

Affected trigger paths:
- openclaw gateway restart from an agent session (#43311)
- SIGTERM on config reload (#43406)
- Gateway self-restart via SIGTERM (#43035)
- Hot reload on channel config change (#43049)

Switch restartLaunchAgent() to launchctl kickstart -k, which force-kills
and restarts the service without unloading the plist. When the restart
originates from inside the launchd-managed process tree, delegate to a
new detached handoff helper (launchd-restart-handoff.ts) to avoid the
caller being killed mid-command. Self-restart paths in process-respawn.ts
now schedule the detached start-after-exit handoff before exiting instead
of relying on exit/KeepAlive timing.

Fixes #43311, #43406, #43035, #43049
2026-03-12 01:16:49 +01:00

87 lines
2.9 KiB
TypeScript

import { spawn } from "node:child_process";
import { scheduleDetachedLaunchdRestartHandoff } from "../daemon/launchd-restart-handoff.js";
import { triggerOpenClawRestart } from "./restart.js";
import { detectRespawnSupervisor } from "./supervisor-markers.js";
type RespawnMode = "spawned" | "supervised" | "disabled" | "failed";
export type GatewayRespawnResult = {
mode: RespawnMode;
pid?: number;
detail?: string;
};
function isTruthy(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalized = value.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
/**
* Attempt to restart this process with a fresh PID.
* - supervised environments (launchd/systemd/schtasks): caller should exit and let supervisor restart
* - OPENCLAW_NO_RESPAWN=1: caller should keep in-process restart behavior (tests/dev)
* - otherwise: spawn detached child with current argv/execArgv, then caller exits
*/
export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult {
if (isTruthy(process.env.OPENCLAW_NO_RESPAWN)) {
return { mode: "disabled" };
}
const supervisor = detectRespawnSupervisor(process.env);
if (supervisor) {
// Hand off launchd restarts to a detached helper before exiting so config
// reloads and SIGUSR1-driven restarts do not depend on exit/respawn timing.
if (supervisor === "launchd") {
const handoff = scheduleDetachedLaunchdRestartHandoff({
env: process.env,
mode: "start-after-exit",
waitForPid: process.pid,
});
if (!handoff.ok) {
return {
mode: "supervised",
detail: `launchd exit fallback (${handoff.detail ?? "restart handoff failed"})`,
};
}
return {
mode: "supervised",
detail: `launchd restart handoff pid ${handoff.pid ?? "unknown"}`,
};
}
if (supervisor === "schtasks") {
const restart = triggerOpenClawRestart();
if (!restart.ok) {
return {
mode: "failed",
detail: restart.detail ?? `${restart.method} restart failed`,
};
}
}
return { mode: "supervised" };
}
if (process.platform === "win32") {
// Detached respawn is unsafe on Windows without an identified Scheduled Task:
// the child becomes orphaned if the original process exits.
return {
mode: "disabled",
detail: "win32: detached respawn unsupported without Scheduled Task markers",
};
}
try {
const args = [...process.execArgv, ...process.argv.slice(1)];
const child = spawn(process.execPath, args, {
env: process.env,
detached: true,
stdio: "inherit",
});
child.unref();
return { mode: "spawned", pid: child.pid ?? undefined };
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return { mode: "failed", detail };
}
}