openclaw/src/infra/process-respawn.test.ts
taw0002 792ce7b5b4 fix: detect OpenClaw-managed launchd/systemd services in process respawn
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
2026-02-26 15:21:23 +00:00

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");
});
});