restartGatewayProcessWithFreshPid() checks SUPERVISOR_HINT_ENV_VARS to decide whether to let the supervisor handle the restart (mode=supervised) or to fork a detached child (mode=spawned). The existing list only had native launchd vars (LAUNCH_JOB_LABEL, LAUNCH_JOB_NAME) and systemd vars (INVOCATION_ID, SYSTEMD_EXEC_PID, JOURNAL_STREAM). macOS launchd does NOT automatically inject LAUNCH_JOB_LABEL into the child environment. OpenClaw's own plist generator (buildServiceEnvironment in service-env.ts) sets OPENCLAW_LAUNCHD_LABEL instead. So on stock macOS LaunchAgent installs, isLikelySupervisedProcess() returned false, causing the gateway to fork a detached child on SIGUSR1 restart. The original process then exits, launchd sees its child died, respawns a new instance which finds the orphan holding the port — infinite crash loop. Fix: add OPENCLAW_LAUNCHD_LABEL, OPENCLAW_SYSTEMD_UNIT, and OPENCLAW_SERVICE_MARKER to the supervisor hint list. These are set by OpenClaw's own service environment builders for both launchd and systemd and are the reliable supervised-mode signals. Fixes #27605
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { captureFullEnv } from "../test-utils/env.js";
|
|
|
|
const spawnMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("node:child_process", () => ({
|
|
spawn: (...args: unknown[]) => spawnMock(...args),
|
|
}));
|
|
|
|
import { restartGatewayProcessWithFreshPid } from "./process-respawn.js";
|
|
|
|
const originalArgv = [...process.argv];
|
|
const originalExecArgv = [...process.execArgv];
|
|
const envSnapshot = captureFullEnv();
|
|
|
|
afterEach(() => {
|
|
envSnapshot.restore();
|
|
process.argv = [...originalArgv];
|
|
process.execArgv = [...originalExecArgv];
|
|
spawnMock.mockClear();
|
|
});
|
|
|
|
function clearSupervisorHints() {
|
|
delete process.env.LAUNCH_JOB_LABEL;
|
|
delete process.env.LAUNCH_JOB_NAME;
|
|
delete process.env.OPENCLAW_LAUNCHD_LABEL;
|
|
delete process.env.INVOCATION_ID;
|
|
delete process.env.SYSTEMD_EXEC_PID;
|
|
delete process.env.JOURNAL_STREAM;
|
|
delete process.env.OPENCLAW_SYSTEMD_UNIT;
|
|
delete process.env.OPENCLAW_SERVICE_MARKER;
|
|
}
|
|
|
|
describe("restartGatewayProcessWithFreshPid", () => {
|
|
it("returns disabled when OPENCLAW_NO_RESPAWN is set", () => {
|
|
process.env.OPENCLAW_NO_RESPAWN = "1";
|
|
const result = restartGatewayProcessWithFreshPid();
|
|
expect(result.mode).toBe("disabled");
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns supervised when launchd/systemd hints are present", () => {
|
|
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
|
|
const result = restartGatewayProcessWithFreshPid();
|
|
expect(result.mode).toBe("supervised");
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("spawns detached child with current exec argv", () => {
|
|
delete process.env.OPENCLAW_NO_RESPAWN;
|
|
clearSupervisorHints();
|
|
process.execArgv = ["--import", "tsx"];
|
|
process.argv = ["/usr/local/bin/node", "/repo/dist/index.js", "gateway", "run"];
|
|
spawnMock.mockReturnValue({ pid: 4242, unref: vi.fn() });
|
|
|
|
const result = restartGatewayProcessWithFreshPid();
|
|
|
|
expect(result).toEqual({ mode: "spawned", pid: 4242 });
|
|
expect(spawnMock).toHaveBeenCalledWith(
|
|
process.execPath,
|
|
["--import", "tsx", "/repo/dist/index.js", "gateway", "run"],
|
|
expect.objectContaining({
|
|
detached: true,
|
|
stdio: "inherit",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("returns supervised when OPENCLAW_LAUNCHD_LABEL is set (stock launchd plist)", () => {
|
|
clearSupervisorHints();
|
|
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
|
|
const result = restartGatewayProcessWithFreshPid();
|
|
expect(result.mode).toBe("supervised");
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => {
|
|
clearSupervisorHints();
|
|
process.env.OPENCLAW_SYSTEMD_UNIT = "openclaw-gateway.service";
|
|
const result = restartGatewayProcessWithFreshPid();
|
|
expect(result.mode).toBe("supervised");
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns supervised when OPENCLAW_SERVICE_MARKER is set", () => {
|
|
clearSupervisorHints();
|
|
process.env.OPENCLAW_SERVICE_MARKER = "gateway";
|
|
const result = restartGatewayProcessWithFreshPid();
|
|
expect(result.mode).toBe("supervised");
|
|
expect(spawnMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns failed when spawn throws", () => {
|
|
delete process.env.OPENCLAW_NO_RESPAWN;
|
|
clearSupervisorHints();
|
|
|
|
spawnMock.mockImplementation(() => {
|
|
throw new Error("spawn failed");
|
|
});
|
|
const result = restartGatewayProcessWithFreshPid();
|
|
expect(result.mode).toBe("failed");
|
|
expect(result.detail).toContain("spawn failed");
|
|
});
|
|
});
|