mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 09:47:53 +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 () => {
|
||||
({ runDaemonStart, runDaemonRestart, runDaemonStop } = await import("./lifecycle.js"));
|
||||
});
|
||||
@@ -707,25 +720,20 @@ describe("runDaemonRestart health checks", () => {
|
||||
});
|
||||
|
||||
it("signals an unmanaged gateway process on stop", async () => {
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200, 4200, 4300]);
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4300, 4300, 4400]);
|
||||
|
||||
await runDaemonStop({ json: true });
|
||||
await runUnmanagedStop();
|
||||
|
||||
expect(findVerifiedGatewayListenerPidsOnPortSync).toHaveBeenCalledWith(18789);
|
||||
expect(signalVerifiedGatewayPidSync).toHaveBeenCalledWith(4200, "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({
|
||||
action: "stop",
|
||||
source: "cli",
|
||||
mode: "sigterm",
|
||||
pid: 4200,
|
||||
pid: 4300,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -760,15 +768,8 @@ describe("runDaemonRestart health checks", () => {
|
||||
it("allows forced non-interactive unmanaged stop fallback", async () => {
|
||||
isTerminalInteractive.mockReturnValue(false);
|
||||
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");
|
||||
});
|
||||
@@ -789,15 +790,8 @@ describe("runDaemonRestart health checks", () => {
|
||||
it("stops a running disabled systemd unit through the service manager", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("linux");
|
||||
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.objectContaining({ env: process.env, stdout: process.stdout }),
|
||||
@@ -813,6 +807,37 @@ describe("runDaemonRestart health checks", () => {
|
||||
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 () => {
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
mockUnmanagedRestart({ runPostRestartCheck: true });
|
||||
@@ -1071,15 +1096,9 @@ describe("runDaemonRestart health checks", () => {
|
||||
});
|
||||
stopSystemdService.mockResolvedValue(undefined);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
await expect(runUnmanagedStop()).resolves.toEqual(
|
||||
expect.objectContaining({ result: "stopped" }),
|
||||
);
|
||||
|
||||
await expect(runDaemonStop({ json: true })).resolves.toBeUndefined();
|
||||
expect(stopSystemdService).toHaveBeenCalled();
|
||||
expect(signalVerifiedGatewayPidSync).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1097,15 +1116,7 @@ describe("runDaemonRestart health checks", () => {
|
||||
),
|
||||
);
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([4200]);
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
|
||||
await expect(runDaemonStop({ json: true })).rejects.toThrow(
|
||||
await expect(runUnmanagedStop()).rejects.toThrow(
|
||||
/sudo systemctl stop openclaw-gateway\.service/,
|
||||
);
|
||||
expect(stopSystemdService).toHaveBeenCalled();
|
||||
@@ -1114,16 +1125,12 @@ describe("runDaemonRestart health checks", () => {
|
||||
|
||||
it("skips unmanaged signaling for pids that are not live gateway processes", async () => {
|
||||
findVerifiedGatewayListenerPidsOnPortSync.mockReturnValue([]);
|
||||
runServiceStop.mockImplementation(
|
||||
async (params: {
|
||||
onNotLoaded?: (ctx: { stdout: NodeJS.WritableStream }) => Promise<unknown>;
|
||||
}) => {
|
||||
await params.onNotLoaded?.({ stdout: process.stdout });
|
||||
},
|
||||
);
|
||||
readActiveGatewayLockIdentity.mockResolvedValue(undefined);
|
||||
|
||||
await runDaemonStop({ json: true });
|
||||
const outcome = await runUnmanagedStop();
|
||||
|
||||
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");
|
||||
if (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) {
|
||||
return null;
|
||||
}
|
||||
@@ -517,7 +521,6 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
||||
}
|
||||
assertGatewayServiceMutationAllowed("stop the gateway");
|
||||
const service = resolveGatewayService();
|
||||
let gatewayPortPromise: Promise<number> | undefined;
|
||||
return await runServiceStop({
|
||||
serviceNoun: "Gateway",
|
||||
service,
|
||||
@@ -537,10 +540,14 @@ export async function runDaemonStop(opts: DaemonLifecycleOptions = {}) {
|
||||
return { result: "stopped" };
|
||||
}
|
||||
}
|
||||
gatewayPortPromise ??= resolveGatewayLifecyclePort(service).catch(() =>
|
||||
resolveGatewayPortFallback(),
|
||||
);
|
||||
return await stopGatewayWithoutServiceManager(await gatewayPortPromise);
|
||||
// An unmanaged run loop keeps its lock port across config edits, so use it
|
||||
// for discovery the way restart already does; otherwise a valid port
|
||||
// override makes the running gateway look like it is already stopped.
|
||||
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