Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 18 additions & 28 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,10 @@ const authoritativeRebuildTarget: typeof import("./onboard/authoritative-rebuild
require("./onboard/authoritative-rebuild-target");
const { assertDashboardPortNotReserved, buildRequiredPreflightPorts } =
require("./onboard/preflight-ports") as typeof import("./onboard/preflight-ports");
const { failFastOnForeignGatewayPortConflict } =
require("./onboard/gateway-port-conflict") as typeof import("./onboard/gateway-port-conflict");
const { printPortConflictReport } =
require("./onboard/port-conflict-report") as typeof import("./onboard/port-conflict-report");
const { tryCleanupOrphanedDashboardForward } =
require("./onboard/orphaned-dashboard-forward") as typeof import("./onboard/orphaned-dashboard-forward");
const { destroyGatewayForReuse } =
Expand Down Expand Up @@ -1496,6 +1500,13 @@ async function preflight(
});

ensureOpenshellForOnboard();
await failFastOnForeignGatewayPortConflict({
gatewayPort: GATEWAY_PORT,
checkPortAvailable,
getGatewayPortCheckOptions: dockerDriverGatewayEnv.getGatewayPortCheckOptions,
isDockerDriverGatewayPortListener,
exitProcess: (code) => process.exit(code),
});

// Classify gateway state before port checks. Legacy non-Docker-driver
// path destroys stale/unnamed gateways here so the port frees up for
Expand Down Expand Up @@ -1648,34 +1659,13 @@ async function preflight(
if (outcome.kind === "killed-still-blocked") portCheck = outcome.portCheck;
else if (outcome.kind !== "not-openshell") continue;
}
console.error("");
console.error(` !! Port ${port} is not available.`);
console.error(` ${label} needs this port.`);
console.error("");
if (portCheck.process && portCheck.process !== "unknown") {
console.error(
` Blocked by: ${portCheck.process}${portCheck.pid ? ` (PID ${portCheck.pid})` : ""}`,
);
console.error("");
console.error(" To fix, stop the conflicting process:");
console.error("");
if (portCheck.pid) {
console.error(` sudo kill ${portCheck.pid}`);
} else {
console.error(` sudo lsof -i :${port} -sTCP:LISTEN -P -n`);
}
for (const hint of getPortConflictServiceHints()) {
console.error(hint);
}
} else {
console.error(` Could not identify the process using port ${port}.`);
console.error(` Run: sudo lsof -i :${port} -sTCP:LISTEN`);
}
console.error("");
console.error(` Or rerun with a different port:`);
console.error(` ${envVar}=<port> nemoclaw onboard`);
console.error("");
console.error(` Detail: ${portCheck.reason}`);
printPortConflictReport({
port,
label,
envVar,
portCheck,
serviceHints: getPortConflictServiceHints(),
});
process.exit(1);
}
console.log(` ✓ Port ${port} available (${label})`);
Expand Down
62 changes: 62 additions & 0 deletions src/lib/onboard/gateway-port-conflict.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import {
couldBeNemoClawGatewayPortListener,
failFastOnForeignGatewayPortConflict,
} from "./gateway-port-conflict";
import type { PortProbeResult } from "./preflight";

function blockedPort(processName: string): PortProbeResult {
return {
ok: false,
process: processName,
pid: 1234,
reason: "listener is already using the port",
};
}

describe("gateway port conflict", () => {
it("exits with a report for an identifiable foreign gateway port listener", async () => {
const checkPortAvailable = vi.fn().mockResolvedValue(blockedPort("python3"));
const exitProcess = vi.fn();
const lines: string[] = [];

await failFastOnForeignGatewayPortConflict({
gatewayPort: 8080,
checkPortAvailable,
getGatewayPortCheckOptions: () => ({ host: "127.0.0.1" }),
isDockerDriverGatewayPortListener: () => false,
exitProcess,
serviceHints: [" systemctl --user stop openshell-gateway.service"],
writeError: (line) => lines.push(line),
});

expect(checkPortAvailable).toHaveBeenCalledWith(8080, { host: "127.0.0.1" });
expect(exitProcess).toHaveBeenCalledWith(1);
expect(lines.join("\n")).toContain("Port 8080 is not available.");
expect(lines.join("\n")).toContain("Blocked by: python3 (PID 1234)");
expect(lines.join("\n")).toContain("NEMOCLAW_GATEWAY_PORT=<port> nemoclaw onboard");
});

it("keeps OpenShell-like listeners on the gateway reuse path", async () => {
const checkPortAvailable = vi.fn().mockResolvedValue(blockedPort("openshell-gateway"));
const exitProcess = vi.fn();

await failFastOnForeignGatewayPortConflict({
gatewayPort: 8080,
checkPortAvailable,
getGatewayPortCheckOptions: () => ({ host: "127.0.0.1" }),
isDockerDriverGatewayPortListener: () => false,
exitProcess,
});

expect(exitProcess).not.toHaveBeenCalled();
});

it("keeps Docker-driver gateway listeners on the gateway reuse path", () => {
expect(couldBeNemoClawGatewayPortListener(blockedPort("python3"), () => true)).toBe(true);
});
});
75 changes: 75 additions & 0 deletions src/lib/onboard/gateway-port-conflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { printPortConflictReport } from "./port-conflict-report";
import type { CheckPortOpts, PortProbeResult } from "./preflight";
import { getPortConflictServiceHints } from "./remediation";

type CheckPortAvailable = (port: number, opts?: CheckPortOpts) => Promise<PortProbeResult>;
type DockerGatewayPortListenerClassifier = (portCheck: PortProbeResult) => boolean;

export interface GatewayPortConflictDeps {
gatewayPort: number;
checkPortAvailable: CheckPortAvailable;
getGatewayPortCheckOptions: () => CheckPortOpts;
isDockerDriverGatewayPortListener: DockerGatewayPortListenerClassifier;
exitProcess?: (code: number) => void;
serviceHints?: string[];
writeError?: (line: string) => void;
}

const GATEWAY_PORT_LISTENER_CANDIDATE_PROCESSES = [
"openshell",
"openshell-gateway",
"docker-proxy",
"com.docker.backend",
"vpnkit",
"rootlesskit",
"slirp4netns",
];

export function couldBeNemoClawGatewayPortListener(
portCheck: PortProbeResult,
isDockerDriverGatewayPortListener: DockerGatewayPortListenerClassifier,
): boolean {
if (portCheck.ok) return false;
const processName = String(portCheck.process || "").toLowerCase();
if (!processName || processName === "unknown") return true;
if (isDockerDriverGatewayPortListener(portCheck)) return true;
return GATEWAY_PORT_LISTENER_CANDIDATE_PROCESSES.some(
(candidate) =>
processName === candidate ||
processName.startsWith(`${candidate}-`) ||
processName.startsWith(`${candidate}.`),
);
}

export async function failFastOnForeignGatewayPortConflict({
gatewayPort,
checkPortAvailable,
getGatewayPortCheckOptions,
isDockerDriverGatewayPortListener,
exitProcess = (code) => process.exit(code),
serviceHints = getPortConflictServiceHints(),
writeError,
}: GatewayPortConflictDeps): Promise<void> {
const portCheck = await checkPortAvailable(gatewayPort, getGatewayPortCheckOptions());
if (
portCheck.ok ||
couldBeNemoClawGatewayPortListener(portCheck, isDockerDriverGatewayPortListener)
) {
return;
}

printPortConflictReport(
{
port: gatewayPort,
label: "OpenShell gateway",
envVar: "NEMOCLAW_GATEWAY_PORT",
portCheck,
serviceHints,
},
writeError,
);
exitProcess(1);
}
34 changes: 34 additions & 0 deletions src/lib/onboard/gateway-reuse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it, vi } from "vitest";

import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts";
import { createGatewayReuseHelpers } from "./gateway-reuse";

describe("gateway reuse snapshot", () => {
it("bounds OpenShell gateway inspection probes (#6752)", () => {
const runCaptureOpenshell = vi.fn(() => "");
const helpers = createGatewayReuseHelpers({
gatewayName: "nemoclaw",
runCaptureOpenshell,
runOpenshell: vi.fn(() => ({ status: 0 })),
cliDisplayName: () => "NemoClaw",
});

helpers.getGatewayReuseSnapshot();

expect(runCaptureOpenshell).toHaveBeenCalledWith(["status"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
expect(runCaptureOpenshell).toHaveBeenCalledWith(["gateway", "info", "-g", "nemoclaw"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
expect(runCaptureOpenshell).toHaveBeenCalledWith(["gateway", "info"], {
ignoreError: true,
timeout: OPENSHELL_PROBE_TIMEOUT_MS,
});
});
});
8 changes: 5 additions & 3 deletions src/lib/onboard/gateway-reuse.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { OPENSHELL_PROBE_TIMEOUT_MS } from "../adapters/openshell/timeouts";
import { getGatewayReuseState, shouldSelectNamedGatewayForReuse } from "../state/gateway";

export type GatewayReuseSnapshot = {
Expand Down Expand Up @@ -28,11 +29,12 @@ export function createGatewayReuseHelpers(deps: GatewayReuseDeps): GatewayReuseH

function getGatewayReuseSnapshot(): GatewayReuseSnapshot {
const gatewayName = currentGatewayName();
const gatewayStatus = deps.runCaptureOpenshell(["status"], { ignoreError: true });
const probeOptions = { ignoreError: true, timeout: OPENSHELL_PROBE_TIMEOUT_MS };
const gatewayStatus = deps.runCaptureOpenshell(["status"], probeOptions);
const gwInfo = deps.runCaptureOpenshell(["gateway", "info", "-g", gatewayName], {
ignoreError: true,
...probeOptions,
});
const activeGatewayInfo = deps.runCaptureOpenshell(["gateway", "info"], { ignoreError: true });
const activeGatewayInfo = deps.runCaptureOpenshell(["gateway", "info"], probeOptions);
return {
gatewayStatus,
gwInfo,
Expand Down
83 changes: 83 additions & 0 deletions src/lib/onboard/port-conflict-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { afterEach, describe, expect, it, vi } from "vitest";

import { formatPortConflictReport } from "./port-conflict-report";

const ORIGINAL_STDERR = {
isTTY: process.stderr.isTTY,
getColorDepth: process.stderr.getColorDepth,
};

function stubStderr(isTTY: boolean, colorDepth: number): void {
Object.defineProperty(process.stderr, "isTTY", { value: isTTY, configurable: true });
Object.defineProperty(process.stderr, "getColorDepth", {
value: () => colorDepth,
configurable: true,
});
}

function restoreStderr(): void {
Object.defineProperty(process.stderr, "isTTY", {
value: ORIGINAL_STDERR.isTTY,
configurable: true,
});
Object.defineProperty(process.stderr, "getColorDepth", {
value: ORIGINAL_STDERR.getColorDepth,
configurable: true,
});
}

const RED = (text: string) => `\x1b[31m${text}\x1b[39m`;

describe("port conflict report", () => {
afterEach(() => {
restoreStderr();
vi.unstubAllEnvs();
});

it("uses the shared error presentation for the conflict heading (#6752)", () => {
vi.stubEnv("NO_COLOR", "");
stubStderr(true, 24);

const report = formatPortConflictReport({
port: 8080,
label: "OpenShell gateway",
envVar: "NEMOCLAW_GATEWAY_PORT",
portCheck: {
ok: false,
process: "python3",
pid: 1234,
reason: "lsof reports python3 (PID 1234) listening on port 8080",
},
serviceHints: [" systemctl --user stop openclaw-gateway.service"],
}).join("\n");

expect(report).toContain(` ${RED("✗ Port 8080 is not available.")}`);
expect(report).toContain("Blocked by: python3 (PID 1234)");
expect(report).toContain("sudo kill 1234");
expect(report).toContain("NEMOCLAW_GATEWAY_PORT=<port> nemoclaw onboard");
});

it("does not emit raw ANSI escapes when stderr is not color-capable (#6752)", () => {
vi.stubEnv("NO_COLOR", "");
stubStderr(false, 1);

const report = formatPortConflictReport({
port: 8080,
label: "OpenShell gateway",
envVar: "NEMOCLAW_GATEWAY_PORT",
portCheck: {
ok: false,
process: "unknown",
pid: null,
reason: "port 8080 is in use (EADDRINUSE)",
},
}).join("\n");

expect(report).toContain(" ✗ Port 8080 is not available.");
expect(report).not.toMatch(/\x1b\[[0-9;]*m/);
expect(report).toContain("Could not identify the process using port 8080.");
});
});
Loading
Loading