diff --git a/CHANGELOG.md b/CHANGELOG.md index 20554ebd941..67d7bf8faab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/commands/status-all/diagnosis.test.ts b/src/commands/status-all/diagnosis.test.ts new file mode 100644 index 00000000000..f8dc23cae1b --- /dev/null +++ b/src/commands/status-all/diagnosis.test.ts @@ -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[0]; + +function createProgressReporter(): ProgressReporter { + return { + setLabel: () => {}, + setPercent: () => {}, + tick: () => {}, + done: () => {}, + }; +} + +function createBaseParams( + listeners: NonNullable["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."); + }); +}); diff --git a/src/commands/status-all/diagnosis.ts b/src/commands/status-all/diagnosis.ts index 289fdb7a16e..c12c4d86e08 100644 --- a/src/commands/status-all/diagnosis.ts +++ b/src/commands/status-all/diagnosis.ts @@ -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; 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.")}`, + ); } } diff --git a/src/infra/ports-format.test.ts b/src/infra/ports-format.test.ts index 3ef0497174c..c7a280efb16 100644 --- a/src/infra/ports-format.test.ts +++ b/src/infra/ports-format.test.ts @@ -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" }, diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts index d8c45dfe27a..9cde73eec39 100644 --- a/src/infra/ports-format.ts +++ b/src/infra/ports-format.ts @@ -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(); + 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.", ); diff --git a/src/infra/ports.ts b/src/infra/ports.ts index 58f0cf7d7c8..1cf2e785102 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -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";