mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
fix: harden launchd port release polling
This commit is contained in:
+37
-16
@@ -65,6 +65,9 @@ const cleanStaleGatewayProcessesSync = vi.hoisted(() =>
|
||||
const inspectPortUsage = vi.hoisted(() =>
|
||||
vi.fn(async () => ({ port: 18789, status: "free", listeners: [], hints: [] })),
|
||||
);
|
||||
const probePortUsage = vi.hoisted(() =>
|
||||
vi.fn<typeof import("../infra/ports-probe.js").probePortUsage>(async () => "free"),
|
||||
);
|
||||
const formatPortDiagnostics = vi.hoisted(() => vi.fn(() => ["Port 18789 is already in use."]));
|
||||
const defaultProgramArguments = ["node", "-e", "process.exit(0)"];
|
||||
|
||||
@@ -262,6 +265,10 @@ vi.mock("../infra/ports.js", () => ({
|
||||
formatPortDiagnostics,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/ports-probe.js", () => ({
|
||||
probePortUsage,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs/promises")>("node:fs/promises");
|
||||
const wrapped = {
|
||||
@@ -354,6 +361,8 @@ beforeEach(() => {
|
||||
cleanStaleGatewayProcessesSync.mockReturnValue([]);
|
||||
inspectPortUsage.mockReset();
|
||||
inspectPortUsage.mockResolvedValue({ port: 18789, status: "free", listeners: [], hints: [] });
|
||||
probePortUsage.mockReset();
|
||||
probePortUsage.mockResolvedValue("free");
|
||||
formatPortDiagnostics.mockReset();
|
||||
formatPortDiagnostics.mockReturnValue(["Port 18789 is already in use."]);
|
||||
launchdRestartHandoffState.scheduleDetachedLaunchdRestartHandoff.mockReset();
|
||||
@@ -1117,25 +1126,35 @@ describe("launchd install", () => {
|
||||
...createDefaultLaunchdEnv(),
|
||||
OPENCLAW_GATEWAY_PORT: "19009",
|
||||
};
|
||||
inspectPortUsage
|
||||
.mockResolvedValueOnce({
|
||||
port: 19009,
|
||||
status: "busy",
|
||||
listeners: [],
|
||||
hints: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
port: 19009,
|
||||
status: "free",
|
||||
listeners: [],
|
||||
hints: [],
|
||||
});
|
||||
inspectPortUsage.mockResolvedValueOnce({
|
||||
port: 19009,
|
||||
status: "busy",
|
||||
listeners: [],
|
||||
hints: [],
|
||||
});
|
||||
|
||||
await runStopLaunchAgentWithFakeTimers({ env, stdout: new PassThrough() });
|
||||
|
||||
expect(inspectPortUsage).toHaveBeenCalledTimes(2);
|
||||
expect(inspectPortUsage).toHaveBeenNthCalledWith(1, 19009);
|
||||
expect(inspectPortUsage).toHaveBeenNthCalledWith(2, 19009);
|
||||
expect(inspectPortUsage).toHaveBeenCalledTimes(1);
|
||||
expect(probePortUsage).toHaveBeenCalledWith(19009);
|
||||
});
|
||||
|
||||
it("keeps waiting until a bind probe explicitly confirms port release", async () => {
|
||||
const env = {
|
||||
...createDefaultLaunchdEnv(),
|
||||
OPENCLAW_GATEWAY_PORT: "19010",
|
||||
};
|
||||
inspectPortUsage.mockResolvedValueOnce({
|
||||
port: 19010,
|
||||
status: "busy",
|
||||
listeners: [],
|
||||
hints: [],
|
||||
});
|
||||
probePortUsage.mockResolvedValueOnce("busy").mockResolvedValueOnce("unknown");
|
||||
|
||||
await runStopLaunchAgentWithFakeTimers({ env, stdout: new PassThrough() });
|
||||
|
||||
expect(probePortUsage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("resolves the stop postcondition port from the stored LaunchAgent environment", async () => {
|
||||
@@ -1170,6 +1189,7 @@ describe("launchd install", () => {
|
||||
listeners: [],
|
||||
hints: [],
|
||||
});
|
||||
probePortUsage.mockResolvedValue("busy");
|
||||
formatPortDiagnostics.mockReturnValue(["Port 19004 is held by pid 4242."]);
|
||||
|
||||
await expect(runStopLaunchAgentWithFakeTimers({ env, stdout })).rejects.toThrow(
|
||||
@@ -1315,6 +1335,7 @@ describe("launchd install", () => {
|
||||
listeners: [],
|
||||
hints: [],
|
||||
});
|
||||
probePortUsage.mockResolvedValue("busy");
|
||||
formatPortDiagnostics.mockReturnValue(["Port 19008 is held by pid 4242."]);
|
||||
|
||||
await expect(runStopLaunchAgentWithFakeTimers({ env, stdout, disable: true })).rejects.toThrow(
|
||||
|
||||
+18
-10
@@ -5,6 +5,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
|
||||
import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js";
|
||||
import { normalizeEnvVarKey } from "../infra/host-env-security.js";
|
||||
import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
|
||||
import { probePortUsage } from "../infra/ports-probe.js";
|
||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||
import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js";
|
||||
import { parseTcpPort } from "../infra/tcp-port.js";
|
||||
@@ -49,7 +50,7 @@ const LAUNCH_AGENT_ENV_DIR_NAME = "service-env";
|
||||
const LAUNCH_AGENT_STDERR_PATH = "/dev/null";
|
||||
const OPENCLAW_UPDATE_LAUNCHD_LABEL_PREFIX = "ai.openclaw.update.";
|
||||
const OPENCLAW_MANUAL_UPDATE_LAUNCHD_LABEL_PATTERN = /^ai\.openclaw\.manual-update\.\d+$/;
|
||||
const LAUNCH_AGENT_STOP_PORT_RELEASE_ATTEMPTS = 20;
|
||||
const LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS = 2_000;
|
||||
const LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS = 100;
|
||||
|
||||
export type StaleOpenClawUpdateLaunchdJob = {
|
||||
@@ -773,24 +774,31 @@ async function waitForLaunchAgentStopped(serviceTarget: string): Promise<LaunchA
|
||||
return lastUnknown ?? { state: "running" };
|
||||
}
|
||||
|
||||
async function waitForGatewayPortRelease(port: number): Promise<boolean> {
|
||||
const deadline = Date.now() + LAUNCH_AGENT_STOP_PORT_RELEASE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(Math.min(LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS, deadline - Date.now()));
|
||||
const status = await probePortUsage(port);
|
||||
if (status === "free") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assertGatewayPortReleasedAfterStop(env: GatewayServiceEnv): Promise<void> {
|
||||
const port = await resolveLaunchAgentGatewayPort(env);
|
||||
if (port === null) {
|
||||
return;
|
||||
}
|
||||
cleanStaleGatewayProcessesSync(port);
|
||||
let diagnostics = await inspectPortUsage(port).catch(() => null);
|
||||
for (
|
||||
let attempt = 1;
|
||||
diagnostics?.status === "busy" && attempt < LAUNCH_AGENT_STOP_PORT_RELEASE_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
await sleep(LAUNCH_AGENT_STOP_PORT_RELEASE_POLL_MS);
|
||||
diagnostics = await inspectPortUsage(port).catch(() => null);
|
||||
}
|
||||
const diagnostics = await inspectPortUsage(port).catch(() => null);
|
||||
if (diagnostics?.status !== "busy") {
|
||||
return;
|
||||
}
|
||||
if (await waitForGatewayPortRelease(port)) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`gateway port ${port} is still busy after LaunchAgent stop`,
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import os from "node:os";
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { isErrno } from "./errors.js";
|
||||
import { parseStrictPositiveInteger } from "./parse-finite-number.js";
|
||||
import { buildPortHints } from "./ports-format.js";
|
||||
import { resolveLsofCommand } from "./ports-lsof.js";
|
||||
import { tryListenOnPort } from "./ports-probe.js";
|
||||
import { probePortUsage } from "./ports-probe.js";
|
||||
import type {
|
||||
PortConnection,
|
||||
PortConnectionDirection,
|
||||
@@ -610,36 +609,6 @@ async function readWindowsEstablishedConnections(
|
||||
return { connections: result.entries, detail: result.detail, errors: result.errors };
|
||||
}
|
||||
|
||||
async function tryListenOnHost(port: number, host: string): Promise<PortUsageStatus | "skip"> {
|
||||
try {
|
||||
await tryListenOnPort({ port, host, exclusive: true });
|
||||
return "free";
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
return "busy";
|
||||
}
|
||||
if (isErrno(err) && (err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT")) {
|
||||
return "skip";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPortInUse(port: number): Promise<PortUsageStatus> {
|
||||
const hosts = ["127.0.0.1", "0.0.0.0", "::1", "::"];
|
||||
let sawUnknown = false;
|
||||
for (const host of hosts) {
|
||||
const result = await tryListenOnHost(port, host);
|
||||
if (result === "busy") {
|
||||
return "busy";
|
||||
}
|
||||
if (result === "unknown") {
|
||||
sawUnknown = true;
|
||||
}
|
||||
}
|
||||
return sawUnknown ? "unknown" : "free";
|
||||
}
|
||||
|
||||
export async function inspectPortUsage(port: number): Promise<PortUsage> {
|
||||
const errors: string[] = [];
|
||||
const result =
|
||||
@@ -648,7 +617,7 @@ export async function inspectPortUsage(port: number): Promise<PortUsage> {
|
||||
let listeners = result.listeners;
|
||||
let status: PortUsageStatus = listeners.length > 0 ? "busy" : "unknown";
|
||||
if (listeners.length === 0) {
|
||||
status = await checkPortInUse(port);
|
||||
status = await probePortUsage(port);
|
||||
}
|
||||
if (status !== "busy") {
|
||||
listeners = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Tests local port probing and availability detection.
|
||||
import net from "node:net";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tryListenOnPort } from "./ports-probe.js";
|
||||
import { probePortUsage, tryListenOnPort } from "./ports-probe.js";
|
||||
|
||||
async function withListeningServer(cb: (address: net.AddressInfo) => Promise<void>): Promise<void> {
|
||||
const server = net.createServer();
|
||||
@@ -65,3 +65,11 @@ describe("tryListenOnPort", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("probePortUsage", () => {
|
||||
it("reports an IPv4-only loopback listener as busy", async () => {
|
||||
await withListeningServer(async (address) => {
|
||||
await expect(probePortUsage(address.port)).resolves.toBe("busy");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// Probes local ports and reports listener availability.
|
||||
import net from "node:net";
|
||||
import { isErrno } from "./errors.js";
|
||||
import type { PortUsageStatus } from "./ports-types.js";
|
||||
|
||||
const PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"];
|
||||
|
||||
/** Opens and closes a temporary listener to verify that a port can be bound. */
|
||||
export async function tryListenOnPort(params: {
|
||||
@@ -28,3 +32,33 @@ export async function tryListenOnPort(params: {
|
||||
.listen(listenOptions);
|
||||
});
|
||||
}
|
||||
|
||||
async function probePortOnHost(port: number, host: string): Promise<PortUsageStatus | "skip"> {
|
||||
try {
|
||||
await tryListenOnPort({ port, host, exclusive: true });
|
||||
return "free";
|
||||
} catch (err) {
|
||||
if (isErrno(err) && err.code === "EADDRINUSE") {
|
||||
return "busy";
|
||||
}
|
||||
if (isErrno(err) && (err.code === "EADDRNOTAVAIL" || err.code === "EAFNOSUPPORT")) {
|
||||
return "skip";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks all supported local address families without resolving listener diagnostics. */
|
||||
export async function probePortUsage(port: number): Promise<PortUsageStatus> {
|
||||
let sawUnknown = false;
|
||||
for (const host of PORT_PROBE_HOSTS) {
|
||||
const result = await probePortOnHost(port, host);
|
||||
if (result === "busy") {
|
||||
return "busy";
|
||||
}
|
||||
if (result === "unknown") {
|
||||
sawUnknown = true;
|
||||
}
|
||||
}
|
||||
return sawUnknown ? "unknown" : "free";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user