Gateway: stop and restart unmanaged listeners (#39355)
* Daemon: allow unmanaged gateway lifecycle fallback * Status: fix service summary formatting * Changelog: note unmanaged gateway lifecycle fallback * Tests: cover unmanaged gateway lifecycle fallback * Daemon: split unmanaged restart health checks * Daemon: harden unmanaged gateway signaling * Daemon: reject unmanaged restarts when disabled
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockReadFileSync = vi.hoisted(() => vi.fn());
|
||||
const mockSpawnSync = vi.hoisted(() => vi.fn());
|
||||
|
||||
type RestartHealthSnapshot = {
|
||||
healthy: boolean;
|
||||
@@ -25,18 +28,59 @@ const service = {
|
||||
};
|
||||
|
||||
const runServiceRestart = vi.fn();
|
||||
const runServiceStop = vi.fn();
|
||||
const waitForGatewayHealthyListener = vi.fn();
|
||||
const waitForGatewayHealthyRestart = vi.fn();
|
||||
const terminateStaleGatewayPids = vi.fn();
|
||||
const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"]);
|
||||
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
|
||||
const resolveGatewayPort = vi.fn(() => 18789);
|
||||
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
|
||||
const probeGateway = vi.fn<
|
||||
(opts: {
|
||||
url: string;
|
||||
auth?: { token?: string; password?: string };
|
||||
timeoutMs: number;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
configSnapshot: unknown;
|
||||
}>
|
||||
>();
|
||||
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
|
||||
const loadConfig = vi.fn(() => ({}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
default: {
|
||||
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawnSync: (...args: unknown[]) => mockSpawnSync(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => loadConfig(),
|
||||
readBestEffortConfig: async () => loadConfig(),
|
||||
resolveGatewayPort,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/restart.js", () => ({
|
||||
findGatewayPidsOnPortSync: (port: number) => findGatewayPidsOnPortSync(port),
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/probe.js", () => ({
|
||||
probeGateway: (opts: {
|
||||
url: string;
|
||||
auth?: { token?: string; password?: string };
|
||||
timeoutMs: number;
|
||||
}) => probeGateway(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/commands.js", () => ({
|
||||
isRestartEnabled: (config?: { commands?: unknown }) => isRestartEnabled(config),
|
||||
}));
|
||||
|
||||
vi.mock("../../daemon/service.js", () => ({
|
||||
resolveGatewayService: () => service,
|
||||
}));
|
||||
@@ -44,7 +88,9 @@ vi.mock("../../daemon/service.js", () => ({
|
||||
vi.mock("./restart-health.js", () => ({
|
||||
DEFAULT_RESTART_HEALTH_ATTEMPTS: 120,
|
||||
DEFAULT_RESTART_HEALTH_DELAY_MS: 500,
|
||||
waitForGatewayHealthyListener,
|
||||
waitForGatewayHealthyRestart,
|
||||
renderGatewayPortHealthDiagnostics,
|
||||
terminateStaleGatewayPids,
|
||||
renderRestartDiagnostics,
|
||||
}));
|
||||
@@ -52,26 +98,35 @@ vi.mock("./restart-health.js", () => ({
|
||||
vi.mock("./lifecycle-core.js", () => ({
|
||||
runServiceRestart,
|
||||
runServiceStart: vi.fn(),
|
||||
runServiceStop: vi.fn(),
|
||||
runServiceStop,
|
||||
runServiceUninstall: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("runDaemonRestart health checks", () => {
|
||||
let runDaemonRestart: (opts?: { json?: boolean }) => Promise<boolean>;
|
||||
let runDaemonStop: (opts?: { json?: boolean }) => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ runDaemonRestart } = await import("./lifecycle.js"));
|
||||
({ runDaemonRestart, runDaemonStop } = await import("./lifecycle.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.readCommand.mockClear();
|
||||
service.restart.mockClear();
|
||||
runServiceRestart.mockClear();
|
||||
waitForGatewayHealthyRestart.mockClear();
|
||||
terminateStaleGatewayPids.mockClear();
|
||||
renderRestartDiagnostics.mockClear();
|
||||
resolveGatewayPort.mockClear();
|
||||
loadConfig.mockClear();
|
||||
service.readCommand.mockReset();
|
||||
service.restart.mockReset();
|
||||
runServiceRestart.mockReset();
|
||||
runServiceStop.mockReset();
|
||||
waitForGatewayHealthyListener.mockReset();
|
||||
waitForGatewayHealthyRestart.mockReset();
|
||||
terminateStaleGatewayPids.mockReset();
|
||||
renderGatewayPortHealthDiagnostics.mockReset();
|
||||
renderRestartDiagnostics.mockReset();
|
||||
resolveGatewayPort.mockReset();
|
||||
findGatewayPidsOnPortSync.mockReset();
|
||||
probeGateway.mockReset();
|
||||
isRestartEnabled.mockReset();
|
||||
loadConfig.mockReset();
|
||||
mockReadFileSync.mockReset();
|
||||
mockSpawnSync.mockReset();
|
||||
|
||||
service.readCommand.mockResolvedValue({
|
||||
programArguments: ["openclaw", "gateway", "--port", "18789"],
|
||||
@@ -92,6 +147,37 @@ describe("runDaemonRestart health checks", () => {
|
||||
});
|
||||
return true;
|
||||
});
|
||||
runServiceStop.mockResolvedValue(undefined);
|
||||
waitForGatewayHealthyListener.mockResolvedValue({
|
||||
healthy: true,
|
||||
portUsage: { port: 18789, status: "busy", listeners: [], hints: [] },
|
||||
});
|
||||
probeGateway.mockResolvedValue({
|
||||
ok: true,
|
||||
configSnapshot: { commands: { restart: true } },
|
||||
});
|
||||
isRestartEnabled.mockReturnValue(true);
|
||||
mockReadFileSync.mockImplementation((path: string) => {
|
||||
const match = path.match(/\/proc\/(\d+)\/cmdline$/);
|
||||
if (!match) {
|
||||
throw new Error(`unexpected path ${path}`);
|
||||
}
|
||||
const pid = Number.parseInt(match[1] ?? "", 10);
|
||||
if ([4200, 4300].includes(pid)) {
|
||||
return ["openclaw", "gateway", "--port", "18789", ""].join("\0");
|
||||
}
|
||||
throw new Error(`unknown pid ${pid}`);
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({
|
||||
error: null,
|
||||
status: 0,
|
||||
stdout: "openclaw gateway --port 18789",
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("kills stale gateway pids and retries restart", async () => {
|
||||
@@ -134,4 +220,99 @@ describe("runDaemonRestart health checks", () => {
|
||||
expect(terminateStaleGatewayPids).not.toHaveBeenCalled();
|
||||
expect(renderRestartDiagnostics).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("signals an unmanaged gateway process on stop", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
findGatewayPidsOnPortSync.mockReturnValue([4200, 4200, 4300]);
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
|
||||
await runDaemonStop({ json: true });
|
||||
|
||||
expect(findGatewayPidsOnPortSync).toHaveBeenCalledWith(18789);
|
||||
expect(killSpy).toHaveBeenCalledWith(4200, "SIGTERM");
|
||||
expect(killSpy).toHaveBeenCalledWith(4300, "SIGTERM");
|
||||
});
|
||||
|
||||
it("signals a single unmanaged gateway process on restart", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
findGatewayPidsOnPortSync.mockReturnValue([4200]);
|
||||
runServiceRestart.mockImplementation(
|
||||
async (params: RestartParams & { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
await params.postRestartCheck?.({
|
||||
json: Boolean(params.opts?.json),
|
||||
stdout: process.stdout,
|
||||
warnings: [],
|
||||
fail: (message: string) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
});
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
await runDaemonRestart({ json: true });
|
||||
|
||||
expect(findGatewayPidsOnPortSync).toHaveBeenCalledWith(18789);
|
||||
expect(killSpy).toHaveBeenCalledWith(4200, "SIGUSR1");
|
||||
expect(probeGateway).toHaveBeenCalledTimes(1);
|
||||
expect(waitForGatewayHealthyListener).toHaveBeenCalledTimes(1);
|
||||
expect(waitForGatewayHealthyRestart).not.toHaveBeenCalled();
|
||||
expect(terminateStaleGatewayPids).not.toHaveBeenCalled();
|
||||
expect(service.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails unmanaged restart when multiple gateway listeners are present", async () => {
|
||||
findGatewayPidsOnPortSync.mockReturnValue([4200, 4300]);
|
||||
runServiceRestart.mockImplementation(
|
||||
async (params: RestartParams & { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(runDaemonRestart({ json: true })).rejects.toThrow(
|
||||
"multiple gateway processes are listening on port 18789",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails unmanaged restart when the running gateway has commands.restart disabled", async () => {
|
||||
findGatewayPidsOnPortSync.mockReturnValue([4200]);
|
||||
probeGateway.mockResolvedValue({
|
||||
ok: true,
|
||||
configSnapshot: { commands: { restart: false } },
|
||||
});
|
||||
isRestartEnabled.mockReturnValue(false);
|
||||
runServiceRestart.mockImplementation(
|
||||
async (params: RestartParams & { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
await expect(runDaemonRestart({ json: true })).rejects.toThrow(
|
||||
"Gateway restart is disabled in the running gateway config",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips unmanaged signaling for pids that are not live gateway processes", async () => {
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
findGatewayPidsOnPortSync.mockReturnValue([4200]);
|
||||
mockReadFileSync.mockReturnValue(["python", "-m", "http.server", ""].join("\0"));
|
||||
mockSpawnSync.mockReturnValue({
|
||||
error: null,
|
||||
status: 0,
|
||||
stdout: "python -m http.server",
|
||||
stderr: "",
|
||||
});
|
||||
runServiceStop.mockImplementation(async (params: { onNotLoaded?: () => Promise<unknown> }) => {
|
||||
await params.onNotLoaded?.();
|
||||
});
|
||||
|
||||
await runDaemonStop({ json: true });
|
||||
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user