openclaw / src /cli /daemon-cli /lifecycle.external-supervision.test.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
f778c12 verified
Raw
History Blame Contribute Delete
15.1 kB
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { captureEnv } from "../../test-utils/env.js";
import { formatGatewayRestartFailure } from "./restart-health-diagnostics.js";
const service = {
readCommand: vi.fn(),
readRuntime: vi.fn(),
restart: vi.fn(),
stop: vi.fn(),
};
const runServiceStart = vi.fn();
const runServiceRestart = vi.fn();
const runServiceStop = vi.fn();
const runServiceUninstall = vi.fn();
const waitForGatewayHealthyListener = vi.fn();
const resolveGatewayPort = vi.hoisted(() => vi.fn((_cfg?: unknown, _env?: unknown) => 18_789));
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
const readActiveGatewayLockPort = vi.hoisted(() => vi.fn());
const readActiveGatewayLockIdentity = vi.hoisted(() => vi.fn());
const findVerifiedGatewayListenerPidsOnPortSync = vi.fn();
const signalVerifiedGatewayPidSync = vi.fn();
const writeGatewayRestartIntentSync = vi.fn();
const clearGatewayRestartIntentSync = vi.fn();
const findInstalledSystemdGatewayScope = vi.fn();
const probeGateway = vi.fn();
const callGatewayCli = vi.fn();
const isTerminalInteractive = vi.fn(() => true);
const appendGatewayLifecycleAudit = vi.fn();
const createGatewayLifecycleMutationAudit = vi.fn(() => vi.fn());
const gatewayLockIdentity = {
pid: 4200,
ownerId: "gateway-owner-old",
createdAt: "2026-07-16T12:00:00.000Z",
port: 18_789,
};
vi.mock("../../config/config.js", () => ({
readBestEffortConfig: async () => loadConfig(),
resolveGatewayPort: (cfg?: unknown, env?: unknown) => resolveGatewayPort(cfg, env),
}));
vi.mock("../../infra/gateway-processes.js", () => ({
findVerifiedGatewayListenerPidsOnPortSync: (port: number) =>
findVerifiedGatewayListenerPidsOnPortSync(port),
signalVerifiedGatewayPidSync: (pid: number, signal: "SIGTERM" | "SIGUSR1") =>
signalVerifiedGatewayPidSync(pid, signal),
formatGatewayPidList: (pids: number[]) => pids.join(", "),
}));
vi.mock("../../infra/gateway-lock.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/gateway-lock.js")>();
return {
...actual,
readActiveGatewayLockPort: () => readActiveGatewayLockPort(),
readActiveGatewayLockIdentity: () => readActiveGatewayLockIdentity(),
};
});
vi.mock("../../infra/restart-intent.js", () => ({
writeGatewayRestartIntentSync: (params: unknown) => writeGatewayRestartIntentSync(params),
clearGatewayRestartIntentSync: () => clearGatewayRestartIntentSync(),
}));
vi.mock("../../gateway/probe.js", () => ({
probeGateway: (opts: unknown) => probeGateway(opts),
}));
vi.mock("../../gateway/call.js", () => ({
callGatewayCli: (opts: unknown) => callGatewayCli(opts),
}));
vi.mock("../../daemon/service.js", () => ({
resolveGatewayService: () => service,
}));
vi.mock("../../daemon/systemd.js", () => ({
findInstalledSystemdGatewayScope: () => findInstalledSystemdGatewayScope(),
refreshLegacySystemdServiceMetadata: vi.fn(async () => false),
restartSystemdService: vi.fn(),
stopSystemdService: vi.fn(),
}));
vi.mock("./restart-health.js", () => ({
DEFAULT_RESTART_HEALTH_ATTEMPTS: 120,
DEFAULT_RESTART_HEALTH_DELAY_MS: 500,
formatGatewayRestartFailure,
waitForGatewayHealthyListener,
waitForGatewayHealthyRestart: vi.fn(),
renderGatewayPortHealthDiagnostics: vi.fn(() => []),
renderRestartDiagnostics: vi.fn(() => []),
terminateStaleGatewayPids: vi.fn(),
}));
vi.mock("./lifecycle-core.js", () => ({
runServiceStart,
runServiceRestart,
runServiceStop,
runServiceUninstall,
}));
vi.mock("../terminal-interactivity.js", () => ({
isTerminalInteractive: () => isTerminalInteractive(),
NON_INTERACTIVE_GATEWAY_STOP_MESSAGE: "non-interactive gateway stop requires --force",
}));
vi.mock("./lifecycle-audit.js", () => ({
appendGatewayLifecycleAudit: (params: unknown) => appendGatewayLifecycleAudit(params),
createGatewayLifecycleMutationAudit: () => createGatewayLifecycleMutationAudit(),
}));
async function expectRestartError(promise: Promise<unknown>): Promise<Error> {
try {
await promise;
} catch (error) {
return error as Error;
}
throw new Error("expected restart to fail");
}
describe("external gateway supervision lifecycle", () => {
let runDaemonStart: (opts?: { json?: boolean }) => Promise<void>;
let runDaemonRestart: typeof import("./lifecycle.js").runDaemonRestart;
let runDaemonStop: (opts?: { json?: boolean }) => Promise<void>;
let runDaemonUninstall: (opts?: { json?: boolean }) => Promise<void>;
let envSnapshot: ReturnType<typeof captureEnv>;
beforeAll(async () => {
({ runDaemonStart, runDaemonRestart, runDaemonStop, runDaemonUninstall } =
await import("./lifecycle.js"));
});
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_SUPERVISOR_MODE"]);
process.env.OPENCLAW_SUPERVISOR_MODE = "external";
for (const mock of [
service.readCommand,
service.readRuntime,
service.restart,
service.stop,
runServiceStart,
runServiceRestart,
runServiceStop,
runServiceUninstall,
waitForGatewayHealthyListener,
resolveGatewayPort,
loadConfig,
readActiveGatewayLockPort,
readActiveGatewayLockIdentity,
findVerifiedGatewayListenerPidsOnPortSync,
signalVerifiedGatewayPidSync,
writeGatewayRestartIntentSync,
clearGatewayRestartIntentSync,
findInstalledSystemdGatewayScope,
probeGateway,
callGatewayCli,
isTerminalInteractive,
appendGatewayLifecycleAudit,
createGatewayLifecycleMutationAudit,
]) {
mock.mockReset();
}
resolveGatewayPort.mockReturnValue(18_789);
loadConfig.mockReturnValue({});
readActiveGatewayLockPort.mockResolvedValue(18_789);
readActiveGatewayLockIdentity.mockResolvedValue(gatewayLockIdentity);
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
writeGatewayRestartIntentSync.mockReturnValue(true);
findInstalledSystemdGatewayScope.mockResolvedValue(null);
waitForGatewayHealthyListener.mockResolvedValue({
healthy: true,
portUsage: { port: 18_789, status: "busy", listeners: [], hints: [] },
});
callGatewayCli.mockResolvedValue({ ok: true, status: "emitted", pid: 4200 });
isTerminalInteractive.mockReturnValue(true);
});
afterEach(() => {
envSnapshot.restore();
vi.restoreAllMocks();
});
async function expectExternalRestartFailure(message: string) {
const { defaultRuntime } = await import("../../runtime.js");
const writeJson = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {});
await expectRestartError(runDaemonRestart({ json: true }));
expect(writeJson).toHaveBeenCalledWith(
expect.objectContaining({
action: "restart",
ok: false,
error: expect.stringContaining(message),
}),
);
}
it("restarts through the exact running Gateway without candidate state access", async () => {
const lockIdentity = { ...gatewayLockIdentity, port: 19_455 };
readActiveGatewayLockPort.mockResolvedValue(19_455);
readActiveGatewayLockIdentity.mockResolvedValue(lockIdentity);
await expect(runDaemonRestart({ json: true, force: true })).resolves.toBe(true);
expect(runServiceRestart).not.toHaveBeenCalled();
expect(service.readCommand).not.toHaveBeenCalled();
expect(findInstalledSystemdGatewayScope).not.toHaveBeenCalled();
expect(probeGateway).not.toHaveBeenCalled();
expect(loadConfig).not.toHaveBeenCalled();
expect(callGatewayCli).toHaveBeenCalledWith({
method: "gateway.restart.request",
params: {
reason: "gateway.restart",
target: {
pid: 4200,
ownerId: "gateway-owner-old",
port: 19_455,
},
restartIntent: { force: true },
},
localPortOverride: 19_455,
ignoreEnvUrlOverride: true,
timeoutMs: 10_000,
});
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(clearGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({
action: "restart",
source: "supervisor",
mode: "rpc",
pid: 4200,
});
expect(waitForGatewayHealthyListener).toHaveBeenCalledWith({
port: 19_455,
attempts: 120,
delayMs: 500,
previousLockIdentity: lockIdentity,
waitIndefinitelyForPreviousOwner: false,
});
});
it("preserves wait intent through the running Gateway", async () => {
await runDaemonRestart({ json: true, wait: "30s" });
expect(callGatewayCli).toHaveBeenCalledWith({
method: "gateway.restart.request",
params: {
reason: "gateway.restart",
target: {
pid: 4200,
ownerId: "gateway-owner-old",
port: 18_789,
},
restartIntent: { waitMs: 30_000 },
},
localPortOverride: 18_789,
ignoreEnvUrlOverride: true,
timeoutMs: 10_000,
});
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(waitForGatewayHealthyListener).toHaveBeenCalledWith({
port: 18_789,
attempts: 180,
delayMs: 500,
previousLockIdentity: gatewayLockIdentity,
waitIndefinitelyForPreviousOwner: false,
});
});
it("uses the same targeted in-gateway transport on Windows", async () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
await runDaemonRestart({ json: true, wait: "30s" });
expect(callGatewayCli).toHaveBeenCalledWith({
method: "gateway.restart.request",
params: {
reason: "gateway.restart",
target: {
pid: 4200,
ownerId: "gateway-owner-old",
port: 18_789,
},
restartIntent: { waitMs: 30_000 },
},
localPortOverride: 18_789,
ignoreEnvUrlOverride: true,
timeoutMs: 10_000,
});
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(clearGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(waitForGatewayHealthyListener).toHaveBeenCalledWith({
port: 18_789,
attempts: 420,
delayMs: 500,
previousLockIdentity: gatewayLockIdentity,
waitIndefinitelyForPreviousOwner: false,
});
});
it("keeps safe restarts on the exact local lock owner", async () => {
callGatewayCli.mockResolvedValue({
ok: true,
status: "scheduled",
preflight: { safe: false, summary: "restart deferred", blockers: [] },
restart: { pid: 4200 },
});
await runDaemonRestart({ json: true, safe: true, skipDeferral: true });
expect(callGatewayCli).toHaveBeenCalledWith({
method: "gateway.restart.request",
params: {
reason: "gateway.restart.safe",
safe: true,
skipDeferral: true,
target: {
pid: 4200,
ownerId: "gateway-owner-old",
port: 18_789,
},
},
ignoreEnvUrlOverride: true,
localPortOverride: 18_789,
requiredCapabilities: ["gateway-restart-target-safe-v1"],
timeoutMs: 10_000,
});
expect(loadConfig).not.toHaveBeenCalled();
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
});
it("refuses a restart when the lock owner does not match the listener", async () => {
readActiveGatewayLockIdentity.mockResolvedValue({
...gatewayLockIdentity,
pid: 4300,
ownerId: "gateway-owner-other",
});
await expectExternalRestartFailure("gateway lock identity does not match");
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
expect(waitForGatewayHealthyListener).not.toHaveBeenCalled();
});
it("does not touch state when lock ownership changes before delivery", async () => {
readActiveGatewayLockIdentity
.mockResolvedValueOnce(gatewayLockIdentity)
.mockResolvedValueOnce(gatewayLockIdentity)
.mockResolvedValue({
...gatewayLockIdentity,
pid: 4300,
ownerId: "gateway-owner-new",
createdAt: "2026-07-16T12:00:01.000Z",
});
await expectExternalRestartFailure("gateway lock owner changed");
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(clearGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(callGatewayCli).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
});
it("does not fall back when targeted restart delivery fails", async () => {
callGatewayCli.mockRejectedValue(new Error("connection closed"));
await expectExternalRestartFailure("connection closed");
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(clearGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
expect(waitForGatewayHealthyListener).not.toHaveBeenCalled();
});
it("rejects a legacy generic restart acknowledgement", async () => {
callGatewayCli.mockResolvedValue({
ok: true,
status: "deferred",
restart: { pid: 4200 },
});
await expectExternalRestartFailure("invalid restart acknowledgement");
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
expect(waitForGatewayHealthyListener).not.toHaveBeenCalled();
});
it("fails closed before delivery for a pre-targeted-restart Gateway lock", async () => {
readActiveGatewayLockIdentity.mockResolvedValue({
pid: 4200,
createdAt: "2026-07-16T12:00:00.000Z",
port: 18_789,
});
await expectExternalRestartFailure("predates targeted restart ownership");
expect(callGatewayCli).not.toHaveBeenCalled();
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
});
it.each([
["start", () => runDaemonStart({ json: true })],
["stop", () => runDaemonStop({ json: true })],
["uninstall", () => runDaemonUninstall({ json: true })],
["preserved restart", () => runDaemonRestart({ json: true, preserveDefinition: true })],
])("blocks native %s lifecycle access", async (_action, run) => {
await expect(run()).rejects.toThrow("gateway lifecycle is managed by an external supervisor");
expect(runServiceStart).not.toHaveBeenCalled();
expect(runServiceRestart).not.toHaveBeenCalled();
expect(runServiceStop).not.toHaveBeenCalled();
expect(runServiceUninstall).not.toHaveBeenCalled();
expect(service.readCommand).not.toHaveBeenCalled();
expect(readActiveGatewayLockIdentity).not.toHaveBeenCalled();
expect(callGatewayCli).not.toHaveBeenCalled();
expect(writeGatewayRestartIntentSync).not.toHaveBeenCalled();
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
});
});