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:
Masato Hoshino
2026-07-19 10:20:31 -07:00
committed by GitHub
parent d4c7549d1c
commit 6fb3aae404
2 changed files with 73 additions and 59 deletions
+59 -52
View File
@@ -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();
});
});
+14 -7
View File
@@ -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);
},
});
}