mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
fix(gateway): stop the unmanaged gateway named by its lock (#111378)
`openclaw gateway stop` resolved the unmanaged fallback port from config and discovered pids through lsof only. On hosts without lsof, and whenever the gateway runs on a port other than the configured one, discovery came back empty and the command reported `Gateway service disabled.` with `ok:true` and exit 0 while the gateway kept serving. The gateway lock already holds the verified owner pid and port. Restart learned to read the lock port in #105241 to keep an unmanaged restart honest when the configured port drifts; stop's fallback never did. Read the lock identity once in the not-loaded fallback, use its port for discovery, and signal its owner when no listener is found. Verified listeners still win when lsof is available. Signalling still goes through `signalVerifiedGatewayPidSync`, which re-reads argv immediately before SIGTERM, and lock identities are only returned after a liveness and start-time or argv check, so dead, recycled, port-less and non-gateway lock owners are refused and the command stays `not-loaded`. Closes #72948
This commit is contained in:
@@ -249,6 +249,19 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runUnmanagedStop(opts: { json?: boolean; force?: boolean } = { json: true }) {
|
||||||
|
let outcome: unknown;
|
||||||
|
runServiceStop.mockImplementation(
|
||||||
|
async (params: {
|
||||||
|
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||||
|
}) => {
|
||||||
|
outcome = await params.onNotLoaded?.({ stdout: process.stdout });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await runDaemonStop(opts);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
({ runDaemonStart, runDaemonRestart, runDaemonStop } = await import("./lifecycle.js"));
|
({ runDaemonStart, runDaemonRestart, runDaemonStop } = await import("./lifecycle.js"));
|
||||||
});
|
});
|
||||||
@@ -707,25 +720,20 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("signals an unmanaged gateway process on stop", async () => {
|
it("signals an unmanaged gateway process on stop", async () => {
|
||||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4200, 4300]);
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4300, 4300, 4400]);
|
||||||
runServiceStop.mockImplementation(
|
|
||||||
async (params: {
|
|
||||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await runDaemonStop({ json: true });
|
await runUnmanagedStop();
|
||||||
|
|
||||||
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
|
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
|
||||||
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGTERM");
|
|
||||||
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4300, "SIGTERM");
|
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4300, "SIGTERM");
|
||||||
|
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4400, "SIGTERM");
|
||||||
|
// Verified listeners win over the lock owner (pid 4200) when lsof can see them.
|
||||||
|
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalledWith(4200, "SIGTERM");
|
||||||
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({
|
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith({
|
||||||
action: "stop",
|
action: "stop",
|
||||||
source: "cli",
|
source: "cli",
|
||||||
mode: "sigterm",
|
mode: "sigterm",
|
||||||
pid: 4200,
|
pid: 4300,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -760,15 +768,8 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
it("allows forced non-interactive unmanaged stop fallback", async () => {
|
it("allows forced non-interactive unmanaged stop fallback", async () => {
|
||||||
isTerminalInteractive.mockReturnValue(false);
|
isTerminalInteractive.mockReturnValue(false);
|
||||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||||
runServiceStop.mockImplementation(
|
|
||||||
async (params: {
|
|
||||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await runDaemonStop({ json: true, force: true });
|
await runUnmanagedStop({ json: true, force: true });
|
||||||
|
|
||||||
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGTERM");
|
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGTERM");
|
||||||
});
|
});
|
||||||
@@ -789,15 +790,8 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
it("stops a running disabled systemd unit through the service manager", async () => {
|
it("stops a running disabled systemd unit through the service manager", async () => {
|
||||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||||
service.readRuntime.mockResolvedValue({ status: "running" });
|
service.readRuntime.mockResolvedValue({ status: "running" });
|
||||||
runServiceStop.mockImplementation(
|
|
||||||
async (params: {
|
|
||||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await runDaemonStop({ json: true });
|
await runUnmanagedStop();
|
||||||
|
|
||||||
expect(service.stop).toHaveBeenCalledWith(
|
expect(service.stop).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ env: process.env, stdout: process.stdout }),
|
expect.objectContaining({ env: process.env, stdout: process.stdout }),
|
||||||
@@ -813,6 +807,37 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
expect(resolveGatewayPort).not.toHaveBeenCalled();
|
expect(resolveGatewayPort).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stops the locked gateway owner when listener discovery finds nothing", async () => {
|
||||||
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
|
||||||
|
formatGatewayPidList.mockImplementation((pids) => pids.join(", "));
|
||||||
|
|
||||||
|
const outcome = await runUnmanagedStop();
|
||||||
|
|
||||||
|
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "SIGTERM");
|
||||||
|
expect(appendGatewayLifecycleAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "stop", mode: "sigterm", pid: 4200 }),
|
||||||
|
);
|
||||||
|
expect(outcome).toEqual({
|
||||||
|
result: "stopped",
|
||||||
|
message: "Gateway stop signal sent to unmanaged process on port 18789: 4200.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the active lock port when the configured port has drifted", async () => {
|
||||||
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
|
||||||
|
readActiveGatewayLockIdentity.mockResolvedValue({
|
||||||
|
pid: 4300,
|
||||||
|
createdAt: "2026-07-16T12:00:00.000Z",
|
||||||
|
port: 39_471,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runUnmanagedStop();
|
||||||
|
|
||||||
|
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(39_471);
|
||||||
|
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4300, "SIGTERM");
|
||||||
|
expect(service.readCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("signals a single unmanaged gateway process on restart", async () => {
|
it("signals a single unmanaged gateway process on restart", async () => {
|
||||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||||
mockUnmanagedRestart({ runPostRestartCheck: true });
|
mockUnmanagedRestart({ runPostRestartCheck: true });
|
||||||
@@ -1071,15 +1096,9 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
});
|
});
|
||||||
stopSystemdService.mockResolvedValue(undefined);
|
stopSystemdService.mockResolvedValue(undefined);
|
||||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||||
runServiceStop.mockImplementation(
|
await expect(runUnmanagedStop()).resolves.toEqual(
|
||||||
async (params: {
|
expect.objectContaining({ result: "stopped" }),
|
||||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(runDaemonStop({ json: true })).resolves.toBeUndefined();
|
|
||||||
expect(stopSystemdService).toHaveBeenCalled();
|
expect(stopSystemdService).toHaveBeenCalled();
|
||||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1097,15 +1116,7 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||||
runServiceStop.mockImplementation(
|
await expect(runUnmanagedStop()).rejects.toThrow(
|
||||||
async (params: {
|
|
||||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(runDaemonStop({ json: true })).rejects.toThrow(
|
|
||||||
/sudo systemctl stop openclaw-gateway\.service/,
|
/sudo systemctl stop openclaw-gateway\.service/,
|
||||||
);
|
);
|
||||||
expect(stopSystemdService).toHaveBeenCalled();
|
expect(stopSystemdService).toHaveBeenCalled();
|
||||||
@@ -1114,16 +1125,12 @@ describe("runDaemonRestart health checks", () => {
|
|||||||
|
|
||||||
it("skips unmanaged signaling for pids that are not live gateway processes", async () => {
|
it("skips unmanaged signaling for pids that are not live gateway processes", async () => {
|
||||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
|
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
|
||||||
runServiceStop.mockImplementation(
|
readActiveGatewayLockIdentity.mockResolvedValue(undefined);
|
||||||
async (params: {
|
|
||||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
|
||||||
}) => {
|
|
||||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await runDaemonStop({ json: true });
|
const outcome = await runUnmanagedStop();
|
||||||
|
|
||||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||||
|
expect(appendGatewayLifecycleAudit).not.toHaveBeenCalled();
|
||||||
|
expect(outcome).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -206,12 +206,16 @@ async function handleSystemScopeSystemdGateway(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopGatewayWithoutServiceManager(port: number) {
|
async function stopGatewayWithoutServiceManager(port: number, lockOwnerPid: number | undefined) {
|
||||||
const managed = await handleSystemScopeSystemdGateway("stop");
|
const managed = await handleSystemScopeSystemdGateway("stop");
|
||||||
if (managed) {
|
if (managed) {
|
||||||
return managed;
|
return managed;
|
||||||
}
|
}
|
||||||
const pids = resolveVerifiedGatewayListenerPids(port);
|
const listenerPids = resolveVerifiedGatewayListenerPids(port);
|
||||||
|
// Listener discovery needs lsof, which minimal containers omit. The gateway
|
||||||
|
// lock already names the verified owner of this port, so signal it instead of
|
||||||
|
// reporting the gateway as not running while it keeps serving.
|
||||||
|
const pids = listenerPids.length > 0 ? listenerPids : lockOwnerPid ? [lockOwnerPid] : [];
|
||||||
if (pids.length === 0) {
|
if (pids.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -517,7 +521,6 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
|||||||
}
|
}
|
||||||
assertGatewayServiceMutationAllowed("stop the gateway");
|
assertGatewayServiceMutationAllowed("stop the gateway");
|
||||||
const service = resolveGatewayService();
|
const service = resolveGatewayService();
|
||||||
let gatewayPortPromise: Promise<number> | undefined;
|
|
||||||
return await runServiceStop({
|
return await runServiceStop({
|
||||||
serviceNoun: "Gateway",
|
serviceNoun: "Gateway",
|
||||||
service,
|
service,
|
||||||
@@ -537,10 +540,14 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
|||||||
return { result: "stopped" };
|
return { result: "stopped" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gatewayPortPromise ??= resolveGatewayLifecyclePort(service).catch(() =>
|
// An unmanaged run loop keeps its lock port across config edits, so use it
|
||||||
resolveGatewayPortFallback(),
|
// for discovery the way restart already does; otherwise a valid port
|
||||||
);
|
// override makes the running gateway look like it is already stopped.
|
||||||
return await stopGatewayWithoutServiceManager(await gatewayPortPromise);
|
const lockIdentity = await readActiveGatewayLockIdentity().catch(() => undefined);
|
||||||
|
const port =
|
||||||
|
lockIdentity?.port ??
|
||||||
|
(await resolveGatewayLifecyclePort(service).catch(() => resolveGatewayPortFallback()));
|
||||||
|
return await stopGatewayWithoutServiceManager(port, lockIdentity?.pid);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user