fix(gateway): use launchd KeepAlive restarts

This commit is contained in:
Peter Steinberger
2026-04-05 07:43:28 +01:00
parent d655a8bc76
commit a65ab607c7
5 changed files with 57 additions and 63 deletions

View File

@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/macOS: let launchd `KeepAlive` own in-process gateway restarts again, adding a short supervised-exit delay so rapid restarts avoid launchd crash-loop unloads while `openclaw gateway restart` still reports real LaunchAgent errors synchronously.
- Synology Chat/security: route webhook token comparison through the shared constant-time secret helper for consistency with other bundled plugins.
- Models/MiniMax: honor `MINIMAX_API_HOST` for implicit bundled MiniMax provider catalogs so China-hosted API-key setups pick `api.minimaxi.com/anthropic` without manual provider config. (#34524) Thanks @caiqinghua.
- Usage/MiniMax: invert remaining-style `usage_percent` fields when MiniMax reports only remaining percentage data, so usage bars stop showing nearly-full remaining quota as nearly-exhausted usage. (#60254) Thanks @jwchmodx.

View File

@@ -72,6 +72,17 @@ vi.mock("../../logging/subsystem.js", () => ({
const LOOP_SIGNALS = ["SIGTERM", "SIGINT", "SIGUSR1"] as const;
type LoopSignal = (typeof LOOP_SIGNALS)[number];
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
function setPlatform(platform: string) {
if (!originalPlatformDescriptor) {
return;
}
Object.defineProperty(process, "platform", {
...originalPlatformDescriptor,
value: platform,
});
}
function removeNewSignalListeners(signal: LoopSignal, existing: Set<(...args: unknown[]) => void>) {
for (const listener of process.listeners(signal)) {
@@ -356,6 +367,33 @@ describe("runGatewayLoop", () => {
});
});
it("waits briefly before exiting on launchd supervised restart", async () => {
vi.clearAllMocks();
try {
setPlatform("darwin");
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
restartGatewayProcessWithFreshPid.mockReturnValueOnce({
mode: "supervised",
});
await withIsolatedSignals(async ({ captureSignal }) => {
const { runtime, exited } = await createSignaledLoopHarness();
const sigusr1 = captureSignal("SIGUSR1");
const startedAt = Date.now();
sigusr1();
await expect(exited).resolves.toBe(0);
expect(runtime.exit).toHaveBeenCalledWith(0);
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(1400);
});
} finally {
delete process.env.LAUNCH_JOB_LABEL;
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
}
});
it("forwards lockPort to initial and restart lock acquisitions", async () => {
vi.clearAllMocks();

View File

@@ -12,6 +12,7 @@ import {
markGatewaySigusr1RestartHandled,
scheduleGatewaySigusr1Restart,
} from "../../infra/restart.js";
import { detectRespawnSupervisor } from "../../infra/supervisor-markers.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import {
getActiveTaskCount,
@@ -23,6 +24,7 @@ import { createRestartIterationHook } from "../../process/restart-recovery.js";
import type { RuntimeEnv } from "../../runtime.js";
const gatewayLog = createSubsystemLogger("gateway");
const LAUNCHD_SUPERVISED_RESTART_EXIT_DELAY_MS = 1500;
type GatewayRunSignalAction = "stop" | "restart";
@@ -77,6 +79,16 @@ export async function runGatewayLoop(params: {
? `spawned pid ${respawn.pid ?? "unknown"}`
: "supervisor restart";
gatewayLog.info(`restart mode: full process restart (${modeLabel})`);
if (
respawn.mode === "supervised" &&
detectRespawnSupervisor(process.env, process.platform) === "launchd"
) {
// A short clean-exit pause keeps rapid SIGUSR1/config restarts from
// tripping launchd crash-loop throttling before KeepAlive relaunches.
await new Promise((resolve) => {
setTimeout(resolve, LAUNCHD_SUPERVISED_RESTART_EXIT_DELAY_MS);
});
}
exitProcess(0);
return;
}

View File

@@ -4,7 +4,6 @@ import { SUPERVISOR_HINT_ENV_VARS } from "./supervisor-markers.js";
const spawnMock = vi.hoisted(() => vi.fn());
const triggerOpenClawRestartMock = vi.hoisted(() => vi.fn());
const scheduleDetachedLaunchdRestartHandoffMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const { mockNodeBuiltinModule } = await import("../../test/helpers/node-builtin-mocks.js");
@@ -18,10 +17,6 @@ vi.mock("node:child_process", async () => {
vi.mock("./restart.js", () => ({
triggerOpenClawRestart: (...args: unknown[]) => triggerOpenClawRestartMock(...args),
}));
vi.mock("../daemon/launchd-restart-handoff.js", () => ({
scheduleDetachedLaunchdRestartHandoff: (...args: unknown[]) =>
scheduleDetachedLaunchdRestartHandoffMock(...args),
}));
import { restartGatewayProcessWithFreshPid } from "./process-respawn.js";
@@ -46,8 +41,6 @@ afterEach(() => {
process.execArgv = [...originalExecArgv];
spawnMock.mockClear();
triggerOpenClawRestartMock.mockClear();
scheduleDetachedLaunchdRestartHandoffMock.mockReset();
scheduleDetachedLaunchdRestartHandoffMock.mockReturnValue({ ok: true, pid: 8123 });
if (originalPlatformDescriptor) {
Object.defineProperty(process, "platform", originalPlatformDescriptor);
}
@@ -59,25 +52,14 @@ function clearSupervisorHints() {
}
}
function expectLaunchdSupervisedWithoutKickstart(params?: {
launchJobLabel?: string;
detailContains?: string;
}) {
function expectLaunchdSupervisedWithoutKickstart(params?: { launchJobLabel?: string }) {
setPlatform("darwin");
if (params?.launchJobLabel) {
process.env.LAUNCH_JOB_LABEL = params.launchJobLabel;
}
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
const result = restartGatewayProcessWithFreshPid();
expect(result.mode).toBe("supervised");
if (params?.detailContains) {
expect(result.detail).toContain(params.detailContains);
}
expect(scheduleDetachedLaunchdRestartHandoffMock).toHaveBeenCalledWith({
env: process.env,
mode: "start-after-exit",
waitForPid: process.pid,
});
expect(result).toEqual({ mode: "supervised" });
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
}
@@ -92,10 +74,7 @@ describe("restartGatewayProcessWithFreshPid", () => {
it("returns supervised when launchd hints are present on macOS (no kickstart)", () => {
clearSupervisorHints();
expectLaunchdSupervisedWithoutKickstart({
launchJobLabel: "ai.openclaw.gateway",
detailContains: "launchd restart handoff",
});
expectLaunchdSupervisedWithoutKickstart({ launchJobLabel: "ai.openclaw.gateway" });
});
it("returns supervised on macOS when launchd label is set (no kickstart)", () => {
@@ -118,25 +97,6 @@ describe("restartGatewayProcessWithFreshPid", () => {
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
});
it("falls back to plain supervised exit when launchd handoff scheduling fails", () => {
clearSupervisorHints();
setPlatform("darwin");
process.env.XPC_SERVICE_NAME = "ai.openclaw.gateway";
scheduleDetachedLaunchdRestartHandoffMock.mockReturnValue({
ok: false,
detail: "spawn failed",
});
const result = restartGatewayProcessWithFreshPid();
expect(result).toEqual({
mode: "supervised",
detail: "launchd exit fallback (spawn failed)",
});
expect(triggerOpenClawRestartMock).not.toHaveBeenCalled();
expect(spawnMock).not.toHaveBeenCalled();
});
it("does not schedule kickstart on non-darwin platforms", () => {
setPlatform("linux");
process.env.INVOCATION_ID = "abc123";

View File

@@ -1,5 +1,4 @@
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";
@@ -31,25 +30,9 @@ export function restartGatewayProcessWithFreshPid(): GatewayRespawnResult {
}
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"}`,
};
}
// On macOS launchd, exit cleanly and let KeepAlive relaunch the service.
// Avoid detached kickstart/start handoffs here so restart timing stays tied
// to launchd's native supervision rather than a second helper process.
if (supervisor === "schtasks") {
const restart = triggerOpenClawRestart();
if (!restart.ok) {