diff --git a/src/daemon/launchd-stop.ts b/src/daemon/launchd-stop.ts index aad3be7c7de1..a4103dbed930 100644 --- a/src/daemon/launchd-stop.ts +++ b/src/daemon/launchd-stop.ts @@ -4,6 +4,7 @@ import { inspectPortUsage } from "../infra/ports-inspect.js"; import { probePortUsage } from "../infra/ports-probe.js"; import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; import { sleep } from "../utils.js"; +import { NODE_SERVICE_KIND } from "./constants.js"; import { isCurrentProcessLaunchdServiceLabel } from "./launchd-current-service.js"; import { execLaunchctl, @@ -55,6 +56,15 @@ async function waitForGatewayPortRelease( } async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise { + // Node-host services only ever connect outward to a gateway as a client; + // they never bind the gateway's own port. Reusing the generic LaunchAgent + // stop path for `openclaw node stop`/`restart` on a host that is also + // running the Gateway would otherwise make this assertion see the + // co-located Gateway's own (legitimately still-open) port and report a + // false-positive "still busy" failure. See openclaw/openclaw#124296. + if (env.OPENCLAW_SERVICE_KIND?.trim() === NODE_SERVICE_KIND) { + return; + } const { port, probeHosts } = await resolveLaunchAgentGatewayContext(env); if (port === null) { return; diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts index 866af23df944..1ded9cccfd67 100644 --- a/src/daemon/launchd.test.ts +++ b/src/daemon/launchd.test.ts @@ -2732,6 +2732,53 @@ describe("launchd install", () => { expect(output).not.toContain("Stopped LaunchAgent"); }); + it("does not treat a co-located Gateway's own port as busy when stopping a node-host LaunchAgent", async () => { + // Regression test for https://github.com/openclaw/openclaw/issues/124296: + // `openclaw node stop`/`restart` must not fail with a false-positive + // port-collision guard when the node-host and Gateway are co-located on + // the same host. The node-host only ever connects outward to the + // gateway port as a client and never binds it, so the generic + // LaunchAgent stop path must skip the port-release assertion entirely + // when stopping a service tagged with the node service kind. + const env = { + ...createDefaultLaunchdEnv(), + OPENCLAW_SERVICE_KIND: "node", + OPENCLAW_GATEWAY_PORT: "18789", + }; + const stdout = new PassThrough(); + let output = ""; + stdout.on("data", (chunk: Buffer) => { + output += chunk.toString(); + }); + // Simulate the Gateway's own port still being (legitimately) busy on the + // same host; this must not fail the node-host stop. + inspectPortUsage.mockResolvedValue({ + port: 18789, + status: "busy", + listeners: [], + hints: [], + }); + probePortUsage.mockResolvedValue("busy"); + + await withProcessEnv( + { + LAUNCH_JOB_LABEL: undefined, + LAUNCH_JOB_NAME: undefined, + XPC_SERVICE_NAME: undefined, + OPENCLAW_SERVICE_MARKER: undefined, + OPENCLAW_SERVICE_KIND: undefined, + OPENCLAW_LAUNCHD_LABEL: undefined, + }, + async () => { + await stopLaunchAgent({ env, stdout }); + }, + ); + + expect(inspectPortUsage).not.toHaveBeenCalled(); + expect(cleanStaleGatewayProcessesSync).not.toHaveBeenCalled(); + expect(output).toContain("Stopped LaunchAgent"); + }); + it("stops LaunchAgent with disable+stop when --disable is passed", async () => { const env = createDefaultLaunchdEnv(); const stdout = new PassThrough();