mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
fix(gateway): use launchd KeepAlive restarts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user