Status: suppress false dual-stack loopback port warning (#53398)

This commit is contained in:
Vignesh Natarajan
2026-03-28 23:25:02 -07:00
parent c7330eb716
commit e816d0968a
6 changed files with 198 additions and 6 deletions

View File

@@ -166,6 +166,7 @@ Docs: https://docs.openclaw.ai
- Plugins/Matrix: load bundled `@matrix-org/matrix-sdk-crypto-nodejs` through `createRequire(...)` so E2EE media send and receive keep the package-local native binding lookup working in packaged ESM builds. (#54566) thanks @joelnishanth.
- Plugins/Matrix: encrypt E2EE image thumbnails with `thumbnail_file` while keeping unencrypted-room previews on `thumbnail_url`, so encrypted Matrix image events keep thumbnail metadata without leaking plaintext previews. (#54711) thanks @frischeDaten.
- Telegram/forum topics: keep native `/new` and `/reset` routed to the active topic by preserving the topic target on forum-thread command context. (#35963)
- Status/port diagnostics: treat single-process dual-stack loopback gateway listeners as healthy in `openclaw status --all`, suppressing false “port already in use” conflict warnings. (#53398) Thanks @DanWebb1949.
## 2026.3.24

View File

@@ -0,0 +1,90 @@
import { describe, expect, it, vi } from "vitest";
import type { ProgressReporter } from "../../cli/progress.js";
vi.mock("../../daemon/launchd.js", () => ({
resolveGatewayLogPaths: () => {
throw new Error("skip log tail");
},
}));
vi.mock("./gateway.js", () => ({
readFileTailLines: vi.fn(async () => []),
summarizeLogTail: vi.fn(() => []),
}));
import { appendStatusAllDiagnosis } from "./diagnosis.js";
type DiagnosisParams = Parameters<typeof appendStatusAllDiagnosis>[0];
function createProgressReporter(): ProgressReporter {
return {
setLabel: () => {},
setPercent: () => {},
tick: () => {},
done: () => {},
};
}
function createBaseParams(
listeners: NonNullable<DiagnosisParams["portUsage"]>["listeners"],
): DiagnosisParams {
return {
lines: [] as string[],
progress: createProgressReporter(),
muted: (text: string) => text,
ok: (text: string) => text,
warn: (text: string) => text,
fail: (text: string) => text,
connectionDetailsForReport: "ws://127.0.0.1:18789",
snap: null,
remoteUrlMissing: false,
secretDiagnostics: [],
sentinel: null,
lastErr: null,
port: 18789,
portUsage: { port: 18789, status: "busy", listeners, hints: [] },
tailscaleMode: "off",
tailscale: {
backendState: null,
dnsName: null,
ips: [],
error: null,
},
tailscaleHttpsUrl: null,
skillStatus: null,
pluginCompatibility: [],
channelsStatus: null,
channelIssues: [],
gatewayReachable: false,
health: null,
};
}
describe("status-all diagnosis port checks", () => {
it("treats same-process dual-stack loopback listeners as healthy", async () => {
const params = createBaseParams([
{ pid: 5001, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
{ pid: 5001, commandLine: "openclaw-gateway", address: "[::1]:18789" },
]);
await appendStatusAllDiagnosis(params);
const output = params.lines.join("\n");
expect(output).toContain("✓ Port 18789");
expect(output).toContain("Detected dual-stack loopback listeners");
expect(output).not.toContain("Port 18789 is already in use.");
});
it("keeps warning for multi-process listener conflicts", async () => {
const params = createBaseParams([
{ pid: 5001, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
{ pid: 5002, commandLine: "openclaw-gateway", address: "[::1]:18789" },
]);
await appendStatusAllDiagnosis(params);
const output = params.lines.join("\n");
expect(output).toContain("! Port 18789");
expect(output).toContain("Port 18789 is already in use.");
});
});

View File

@@ -1,7 +1,11 @@
import type { ProgressReporter } from "../../cli/progress.js";
import { formatConfigIssueLine } from "../../config/issue-format.js";
import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
import { formatPortDiagnostics } from "../../infra/ports.js";
import {
formatPortDiagnostics,
isDualStackLoopbackGatewayListeners,
type PortUsage,
} from "../../infra/ports.js";
import {
type RestartSentinelPayload,
summarizeRestartSentinel,
@@ -22,7 +26,7 @@ type ConfigSnapshotLike = {
issues?: ConfigIssueLike[] | null;
};
type PortUsageLike = { listeners: unknown[] };
type PortUsageLike = Pick<PortUsage, "listeners" | "port" | "status" | "hints">;
type TailscaleStatusLike = {
backendState: string | null;
@@ -139,12 +143,20 @@ export async function appendStatusAllDiagnosis(params: {
}
if (params.portUsage) {
const portOk = params.portUsage.listeners.length === 0;
const benignDualStackLoopback = isDualStackLoopbackGatewayListeners(
params.portUsage.listeners,
params.port,
);
const portOk = params.portUsage.listeners.length === 0 || benignDualStackLoopback;
emitCheck(`Port ${params.port}`, portOk ? "ok" : "warn");
if (!portOk) {
for (const line of formatPortDiagnostics(params.portUsage as never)) {
for (const line of formatPortDiagnostics(params.portUsage)) {
lines.push(` ${muted(line)}`);
}
} else if (benignDualStackLoopback) {
lines.push(
` ${muted("Detected dual-stack loopback listeners (127.0.0.1 + ::1) for one gateway process.")}`,
);
}
}

View File

@@ -4,6 +4,7 @@ import {
classifyPortListener,
formatPortDiagnostics,
formatPortListener,
isDualStackLoopbackGatewayListeners,
} from "./ports-format.js";
describe("ports-format", () => {
@@ -35,6 +36,17 @@ describe("ports-format", () => {
expect(buildPortHints([], 18789)).toEqual([]);
});
it("treats single-process loopback dual-stack gateway listeners as benign", () => {
const listeners = [
{ pid: 4242, commandLine: "openclaw-gateway", address: "127.0.0.1:18789" },
{ pid: 4242, commandLine: "openclaw-gateway", address: "[::1]:18789" },
];
expect(isDualStackLoopbackGatewayListeners(listeners, 18789)).toBe(true);
expect(buildPortHints(listeners, 18789)).toEqual([
expect.stringContaining("Gateway already running locally."),
]);
});
it.each([
[
{ pid: 123, user: "alice", commandLine: "ssh -N", address: "::1" },

View File

@@ -19,6 +19,78 @@ export function classifyPortListener(listener: PortListener, port: number): Port
return "unknown";
}
function parseListenerAddress(address: string): { host: string; port: number } | null {
const trimmed = address.trim();
if (!trimmed) {
return null;
}
const normalized = trimmed.replace(/^tcp6?\s+/i, "").replace(/\s*\(listen\)\s*$/i, "");
const bracketMatch = normalized.match(/^\[([^\]]+)\]:(\d+)$/);
if (bracketMatch) {
const port = Number.parseInt(bracketMatch[2], 10);
return Number.isFinite(port) ? { host: bracketMatch[1].toLowerCase(), port } : null;
}
const lastColon = normalized.lastIndexOf(":");
if (lastColon <= 0 || lastColon >= normalized.length - 1) {
return null;
}
const host = normalized.slice(0, lastColon).trim().toLowerCase();
const portToken = normalized.slice(lastColon + 1).trim();
if (!/^\d+$/.test(portToken)) {
return null;
}
const port = Number.parseInt(portToken, 10);
return Number.isFinite(port) ? { host, port } : null;
}
function classifyLoopbackAddressFamily(host: string): "ipv4" | "ipv6" | null {
if (host === "127.0.0.1" || host === "localhost") {
return "ipv4";
}
if (host === "::1") {
return "ipv6";
}
if (host.startsWith("::ffff:")) {
const mapped = host.slice("::ffff:".length);
return mapped === "127.0.0.1" ? "ipv6" : null;
}
return null;
}
export function isDualStackLoopbackGatewayListeners(
listeners: PortListener[],
port: number,
): boolean {
if (listeners.length < 2) {
return false;
}
const pids = new Set<number>();
const families = new Set<"ipv4" | "ipv6">();
for (const listener of listeners) {
if (classifyPortListener(listener, port) !== "gateway") {
return false;
}
const pid = listener.pid;
if (typeof pid !== "number" || !Number.isFinite(pid)) {
return false;
}
pids.add(pid);
if (typeof listener.address !== "string") {
return false;
}
const parsedAddress = parseListenerAddress(listener.address);
if (!parsedAddress || parsedAddress.port !== port) {
return false;
}
const family = classifyLoopbackAddressFamily(parsedAddress.host);
if (!family) {
return false;
}
families.add(family);
}
return pids.size === 1 && families.has("ipv4") && families.has("ipv6");
}
export function buildPortHints(listeners: PortListener[], port: number): string[] {
if (listeners.length === 0) {
return [];
@@ -38,7 +110,7 @@ export function buildPortHints(listeners: PortListener[], port: number): string[
if (kinds.has("unknown")) {
hints.push("Another process is listening on this port.");
}
if (listeners.length > 1) {
if (listeners.length > 1 && !isDualStackLoopbackGatewayListeners(listeners, port)) {
hints.push(
"Multiple listeners detected; ensure only one gateway/tunnel per port unless intentionally running isolated profiles.",
);

View File

@@ -86,5 +86,10 @@ export async function handlePortError(
export { PortInUseError };
export type { PortListener, PortListenerKind, PortUsage, PortUsageStatus };
export { buildPortHints, classifyPortListener, formatPortDiagnostics } from "./ports-format.js";
export {
buildPortHints,
classifyPortListener,
formatPortDiagnostics,
isDualStackLoopbackGatewayListeners,
} from "./ports-format.js";
export { inspectPortUsage } from "./ports-inspect.js";