Skip to content

Commit b405c6e

Browse files
authored
fix(mac): verify launchd stop releases gateway port
Fixes #73132. Summary: - Verify macOS LaunchAgent stop/restart port postconditions before reporting success. - Resolve the effective gateway port from launchd args, persisted service environment, then caller env. - Delay degraded fallback success output until the listener port is confirmed released. Verification: - node scripts/run-vitest.mjs src/daemon/launchd.test.ts src/cli/daemon-cli/lifecycle.test.ts src/cli/daemon-cli/lifecycle-core.test.ts src/cli/daemon-cli/restart-health.test.ts - pnpm exec oxfmt --check --threads=1 src/daemon/launchd.ts src/daemon/launchd.test.ts CHANGELOG.md - git diff --check - Testbox tbx_01krjxf8vrbjwxv3xfdx4770xr: pnpm check:changed
1 parent c70adb8 commit b405c6e

3 files changed

Lines changed: 209 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
2424
- CLI: route `plugins list --json` through the parsed command fast path and cover it in response budgets so plugin JSON inventory avoids full CLI registration work.
2525
- Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong.
2626
- Memory/daily-files: widen the daily-memory file matcher used by Dreaming, rem-backfill, rem-harness, the doctor sweep, and short-term promotion so `memory/YYYY-MM-DD-<slug>.md` files written by the bundled session-memory hook (and any future slugged variants) are discovered alongside the date-only `memory/YYYY-MM-DD.md` shape. Date extraction still uses the leading `YYYY-MM-DD` capture group, so per-day ingestion/promotion semantics are unchanged for existing date-only files; slugged files now flow through the same paths instead of being silently skipped. Fixes #69536. Thanks @jack-stormentswe.
27+
- macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev.
2728
- Security/sandbox: include Windows `USERPROFILE` in the sandbox blocked home roots so credential-bearing binds (such as `.codex`, `.openclaw`, or `.ssh` under the Windows user profile) are denied even when `HOME` points at a different shell home. (#63074) Thanks @luoyanglang.
2829
- Gateway/OpenAI-compatible HTTP: parse shared JSON endpoint paths without trusting malformed Host headers, avoiding 500s before `/v1/chat/completions`, `/v1/responses`, and `/v1/embeddings` request handling.
2930
- Telegram: keep Bot API polling alive during main event-loop stalls by moving ingress to an isolated worker with a durable local spool. Fixes #81132. (#81746) Thanks @joshavant.

src/daemon/launchd.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ const launchdRestartHandoffState = vi.hoisted(() => ({
5555
const cleanStaleGatewayProcessesSync = vi.hoisted(() =>
5656
vi.fn<(port?: number) => number[]>(() => []),
5757
);
58+
const inspectPortUsage = vi.hoisted(() =>
59+
vi.fn(async () => ({ port: 18789, status: "free", listeners: [], hints: [] })),
60+
);
61+
const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."]));
5862
const defaultProgramArguments = ["node", "-e", "process.exit(0)"];
5963

6064
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
@@ -218,6 +222,11 @@ vi.mock("../infra/restart-stale-pids.js", () => ({
218222
cleanStaleGatewayProcessesSync: (port?: number) => cleanStaleGatewayProcessesSync(port),
219223
}));
220224

225+
vi.mock("../infra/ports.js", () => ({
226+
inspectPortUsage,
227+
formatPortDiagnostics,
228+
}));
229+
221230
vi.mock("node:fs/promises", async () => {
222231
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
223232
const wrapped = {
@@ -307,6 +316,10 @@ beforeEach(() => {
307316
state.fileWrites.length = 0;
308317
cleanStaleGatewayProcessesSync.mockReset();
309318
cleanStaleGatewayProcessesSync.mockReturnValue([]);
319+
inspectPortUsage.mockReset();
320+
inspectPortUsage.mockResolvedValue({ port: 18789, status: "free", listeners: [], hints: [] });
321+
formatPortDiagnostics.mockReset();
322+
formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]);
310323
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReset();
311324
launchdRestartHandoffState.isCurrentProcessLaunchdServiceLabel.mockReturnValue(false);
312325
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset();
@@ -660,6 +673,67 @@ describe("launchd install", () => {
660673
expect(output).toContain("Stopped LaunchAgent");
661674
});
662675

676+
it("verifies the configured gateway port is released before reporting stop success", async () => {
677+
const env = {
678+
...createDefaultLaunchdEnv(),
679+
OPENCLAW_GATEWAY_PORT: "19003",
680+
};
681+
const stdout = new PassThrough();
682+
let output = "";
683+
stdout.on("data", (chunk: Buffer) => {
684+
output += chunk.toString();
685+
});
686+
687+
await stopLaunchAgent({ env, stdout });
688+
689+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19003);
690+
expect(inspectPortUsage).toHaveBeenCalledWith(19003);
691+
expect(output).toContain("Stopped LaunchAgent");
692+
});
693+
694+
it("resolves the stop postcondition port from the stored LaunchAgent environment", async () => {
695+
const env = createDefaultLaunchdEnv();
696+
await installLaunchAgent({
697+
env,
698+
stdout: new PassThrough(),
699+
programArguments: defaultProgramArguments,
700+
environment: { OPENCLAW_GATEWAY_PORT: "19006" },
701+
});
702+
state.launchctlCalls.length = 0;
703+
704+
await stopLaunchAgent({ env, stdout: new PassThrough() });
705+
706+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19006);
707+
expect(inspectPortUsage).toHaveBeenCalledWith(19006);
708+
});
709+
710+
it("fails stop when the verified gateway port remains busy after cleanup", async () => {
711+
const env = {
712+
...createDefaultLaunchdEnv(),
713+
OPENCLAW_GATEWAY_PORT: "19004",
714+
};
715+
const stdout = new PassThrough();
716+
let output = "";
717+
stdout.on("data", (chunk: Buffer) => {
718+
output += chunk.toString();
719+
});
720+
inspectPortUsage.mockResolvedValue({
721+
port: 19004,
722+
status: "busy",
723+
listeners: [],
724+
hints: [],
725+
});
726+
formatPortDiagnostics.mockReturnValue(["Port 19004 is held by pid 4242."]);
727+
728+
await expect(stopLaunchAgent({ env, stdout })).rejects.toThrow(
729+
"gateway port 19004 is still busy after LaunchAgent stop\nPort 19004 is held by pid 4242.",
730+
);
731+
732+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19004);
733+
expect(inspectPortUsage).toHaveBeenCalledWith(19004);
734+
expect(output).not.toContain("Stopped LaunchAgent");
735+
});
736+
663737
it("stops LaunchAgent with disable+stop when --disable is passed", async () => {
664738
const env = createDefaultLaunchdEnv();
665739
const stdout = new PassThrough();
@@ -680,6 +754,24 @@ describe("launchd install", () => {
680754
expect(output).toContain("Stopped LaunchAgent");
681755
});
682756

757+
it("verifies the configured gateway port is released before reporting disable stop success", async () => {
758+
const env = {
759+
...createDefaultLaunchdEnv(),
760+
OPENCLAW_GATEWAY_PORT: "19005",
761+
};
762+
const stdout = new PassThrough();
763+
let output = "";
764+
stdout.on("data", (chunk: Buffer) => {
765+
output += chunk.toString();
766+
});
767+
768+
await stopLaunchAgent({ env, stdout, disable: true });
769+
770+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19005);
771+
expect(inspectPortUsage).toHaveBeenCalledWith(19005);
772+
expect(output).toContain("Stopped LaunchAgent");
773+
});
774+
683775
it("treats already-unloaded services as successfully stopped without bootout fallback (--disable)", async () => {
684776
const env = createDefaultLaunchdEnv();
685777
const stdout = new PassThrough();
@@ -740,6 +832,34 @@ describe("launchd install", () => {
740832
expect(output).toContain("used bootout fallback");
741833
});
742834

835+
it("does not report degraded stop success when fallback cleanup leaves the port busy", async () => {
836+
const env = {
837+
...createDefaultLaunchdEnv(),
838+
OPENCLAW_GATEWAY_PORT: "19008",
839+
};
840+
const stdout = new PassThrough();
841+
let output = "";
842+
state.disableError = "Operation not permitted";
843+
stdout.on("data", (chunk: Buffer) => {
844+
output += chunk.toString();
845+
});
846+
inspectPortUsage.mockResolvedValue({
847+
port: 19008,
848+
status: "busy",
849+
listeners: [],
850+
hints: [],
851+
});
852+
formatPortDiagnostics.mockReturnValue(["Port 19008 is held by pid 4242."]);
853+
854+
await expect(stopLaunchAgent({ env, stdout, disable: true })).rejects.toThrow(
855+
"gateway port 19008 is still busy after LaunchAgent stop\nPort 19008 is held by pid 4242.",
856+
);
857+
858+
expect(launchctlCommandNames()).toContain("bootout");
859+
expect(output).toContain("used bootout fallback");
860+
expect(output).not.toContain("Stopped LaunchAgent");
861+
});
862+
743863
it("falls back to bootout when stop does not fully stop the service (--disable)", async () => {
744864
const env = createDefaultLaunchdEnv();
745865
const stdout = new PassThrough();
@@ -885,6 +1005,52 @@ describe("launchd install", () => {
8851005
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19001);
8861006
});
8871007

1008+
it("uses the stored LaunchAgent environment port for restart stale cleanup", async () => {
1009+
const env = createDefaultLaunchdEnv();
1010+
await installLaunchAgent({
1011+
env,
1012+
stdout: new PassThrough(),
1013+
programArguments: defaultProgramArguments,
1014+
environment: { OPENCLAW_GATEWAY_PORT: "19007" },
1015+
});
1016+
state.launchctlCalls.length = 0;
1017+
1018+
await restartLaunchAgent({
1019+
env,
1020+
stdout: new PassThrough(),
1021+
});
1022+
1023+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19007);
1024+
expect(inspectPortUsage).toHaveBeenCalledWith(19007);
1025+
});
1026+
1027+
it("fails restart before kickstart when the configured gateway port remains busy", async () => {
1028+
const env = {
1029+
...createDefaultLaunchdEnv(),
1030+
OPENCLAW_GATEWAY_PORT: "19002",
1031+
};
1032+
inspectPortUsage.mockResolvedValue({
1033+
port: 19002,
1034+
status: "busy",
1035+
listeners: [],
1036+
hints: [],
1037+
});
1038+
formatPortDiagnostics.mockReturnValue(["Port 19002 is held by pid 4242."]);
1039+
1040+
await expect(
1041+
restartLaunchAgent({
1042+
env,
1043+
stdout: new PassThrough(),
1044+
}),
1045+
).rejects.toThrow(
1046+
"gateway port 19002 is still busy before LaunchAgent restart\nPort 19002 is held by pid 4242.",
1047+
);
1048+
1049+
expect(cleanStaleGatewayProcessesSync).toHaveBeenCalledWith(19002);
1050+
expect(inspectPortUsage).toHaveBeenCalledWith(19002);
1051+
expect(launchctlCommandNames()).not.toContain("kickstart");
1052+
});
1053+
8881054
it("skips stale cleanup when no explicit launch agent port can be resolved", async () => {
8891055
const env = createDefaultLaunchdEnv();
8901056
state.files.clear();

src/daemon/launchd.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
22
import path from "node:path";
33
import { normalizeEnvVarKey } from "../infra/host-env-security.js";
44
import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
5+
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
56
import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js";
67
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
78
import { sanitizeForLog } from "../terminal/ansi.js";
@@ -253,6 +254,12 @@ async function resolveLaunchAgentGatewayPort(env: GatewayServiceEnv): Promise<nu
253254
if (fromArgs !== null) {
254255
return fromArgs;
255256
}
257+
const fromServiceEnv = parseStrictPositiveInteger(
258+
command?.environment?.OPENCLAW_GATEWAY_PORT ?? "",
259+
);
260+
if (fromServiceEnv !== undefined) {
261+
return fromServiceEnv;
262+
}
256263
const fromEnv = parseStrictPositiveInteger(env.OPENCLAW_GATEWAY_PORT ?? "");
257264
return fromEnv ?? null;
258265
}
@@ -552,7 +559,6 @@ async function bootoutLaunchAgentOrThrow(params: {
552559
);
553560
}
554561
params.stdout.write(`${formatLine("Warning", params.warning)}\n`);
555-
params.stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", params.serviceTarget)}\n`);
556562
}
557563

558564
type LaunchAgentProbeResult =
@@ -601,6 +607,24 @@ async function waitForLaunchAgentStopped(serviceTarget: string): Promise<LaunchA
601607
return lastUnknown ?? { state: "running" };
602608
}
603609

610+
async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise<void> {
611+
const port = await resolveLaunchAgentGatewayPort(env);
612+
if (port === null) {
613+
return;
614+
}
615+
cleanStaleGatewayProcessesSync(port);
616+
const diagnostics = await inspectPortUsage(port).catch(() => null);
617+
if (diagnostics?.status !== "busy") {
618+
return;
619+
}
620+
throw new Error(
621+
[
622+
`gateway port ${port} is still busy after LaunchAgent stop`,
623+
...formatPortDiagnostics(diagnostics),
624+
].join("\n"),
625+
);
626+
}
627+
604628
export async function stopLaunchAgent({
605629
stdout,
606630
env,
@@ -619,6 +643,7 @@ export async function stopLaunchAgent({
619643
if (bootout.code !== 0 && !isLaunchctlNotLoaded(bootout)) {
620644
throw new Error(`launchctl bootout failed: ${formatLaunchctlResultDetail(bootout)}`);
621645
}
646+
await assertGatewayPortReleasedAfterStop(serviceEnv);
622647
stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`);
623648
return;
624649
}
@@ -632,6 +657,8 @@ export async function stopLaunchAgent({
632657
stdout,
633658
warning: `launchctl disable failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(disableResult)}`,
634659
});
660+
await assertGatewayPortReleasedAfterStop(serviceEnv);
661+
stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`);
635662
return;
636663
}
637664

@@ -643,6 +670,8 @@ export async function stopLaunchAgent({
643670
stdout,
644671
warning: `launchctl stop failed; used bootout fallback and left service unloaded: ${formatLaunchctlResultDetail(stop)}`,
645672
});
673+
await assertGatewayPortReleasedAfterStop(serviceEnv);
674+
stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`);
646675
return;
647676
}
648677

@@ -653,9 +682,12 @@ export async function stopLaunchAgent({
653682
? `launchctl print could not confirm stop; used bootout fallback and left service unloaded: ${stopState.detail ?? "unknown error"}`
654683
: "launchctl stop did not fully stop the service; used bootout fallback and left service unloaded";
655684
await bootoutLaunchAgentOrThrow({ serviceTarget, stdout, warning });
685+
await assertGatewayPortReleasedAfterStop(serviceEnv);
686+
stdout.write(`${formatLine("Stopped LaunchAgent (degraded)", serviceTarget)}\n`);
656687
return;
657688
}
658689

690+
await assertGatewayPortReleasedAfterStop(serviceEnv);
659691
stdout.write(`${formatLine("Stopped LaunchAgent", serviceTarget)}\n`);
660692
}
661693

@@ -851,6 +883,15 @@ export async function restartLaunchAgent({
851883
const cleanupPort = await resolveLaunchAgentGatewayPort(serviceEnv);
852884
if (cleanupPort !== null) {
853885
cleanStaleGatewayProcessesSync(cleanupPort);
886+
const diagnostics = await inspectPortUsage(cleanupPort).catch(() => null);
887+
if (diagnostics?.status === "busy") {
888+
throw new Error(
889+
[
890+
`gateway port ${cleanupPort} is still busy before LaunchAgent restart`,
891+
...formatPortDiagnostics(diagnostics),
892+
].join("\n"),
893+
);
894+
}
854895
}
855896

856897
// `openclaw gateway restart` is an explicit operator request to bring the

0 commit comments

Comments
 (0)