fix(daemon): skip gateway port-release assertion when stopping a node-host LaunchAgent

The generic LaunchAgent stop path (assertGatewayPortReleasedAfterStop)
resolves a gateway-relative port from the stopped service's own
program arguments/env and then asserts that port is no longer bound
after stop. `resolveNodeService()` reuses this exact same stop path
for the node-host service, but a node-host's "--port" argument is the
Gateway port it connects to *outward as a client* -- it never binds
that port itself.

On a host where the node-host and Gateway are co-located (a common,
documented topology), this made `openclaw node stop`/`restart` fail
with a false-positive "gateway port NNNN is still busy after
LaunchAgent stop" error, because the assertion was inadvertently
checking whether the co-located Gateway's own (legitimately still
open) port had been released -- something the node-host stop has no
business asserting.

Skip the port-release assertion entirely when OPENCLAW_SERVICE_KIND
indicates the node-host service, using the existing service-kind
env marker already used elsewhere in the daemon lifecycle code for
this exact gateway-vs-node distinction.

Fixes openclaw/openclaw#124296 (secondary bug: node stop/restart
false-positive port-collision guard when co-located with Gateway).
This commit is contained in:
Anis Khan
2026-08-16 01:41:42 +02:00
parent 5014839937
commit 2a7d73501b
2 changed files with 57 additions and 0 deletions
+10
View File
@@ -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<void> {
// 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;
+47
View File
@@ -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();