diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index bdc01905d3..670f51e655 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -412,7 +412,7 @@ const tiers: typeof import("./policy/tiers") = require("./policy/tiers"); const { ensureUsageNoticeConsent } = require("./onboard/usage-notice"); const { findAvailableDashboardPort, - findDashboardForwardOwner, + preflightDashboardPortRangeAvailability, } = require("./onboard/dashboard-port") as typeof import("./onboard/dashboard-port"); const { destroyGatewayForReuse } = require("./onboard/gateway-cleanup") as typeof import("./onboard/gateway-cleanup"); const { verifyGatewayContainerRunning } = @@ -2359,7 +2359,7 @@ async function preflight( } } - return gpu; + if (_preflightDashboardPort === null) preflightDashboardPortRangeAvailability(); return gpu; // #3953 — fail-fast before next step } // ── Step 2: Gateway ────────────────────────────────────────────── @@ -6725,7 +6725,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { NON_INTERACTIVE = opts.nonInteractive || process.env.NEMOCLAW_NON_INTERACTIVE === "1"; RECREATE_SANDBOX = opts.recreateSandbox || process.env.NEMOCLAW_RECREATE_SANDBOX === "1"; AUTO_YES = opts.autoYes === true || process.env.NEMOCLAW_YES === "1"; - _preflightDashboardPort = opts.controlUiPort || null; + _preflightDashboardPort = opts.controlUiPort ?? (process.env.NEMOCLAW_DASHBOARD_PORT != null ? DASHBOARD_PORT : null); onboardRuntimeBoundary.reset(); delete process.env.OPENSHELL_GATEWAY; const resume = opts.resume === true; @@ -7083,6 +7083,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { updateSession: onboardSession.updateSession, }, }); + if (resume && _preflightDashboardPort === null) preflightDashboardPortRangeAvailability(); // #3953 — resume must mirror preflight()'s fail-fast session = preflightResult.session; const { sandboxGpuConfig, @@ -7530,7 +7531,6 @@ module.exports = { startGateway, findAvailableDashboardPort, - findDashboardForwardOwner, startGatewayForRecovery, openshellArgv, runCaptureOpenshell, diff --git a/src/lib/onboard/dashboard-port.test.ts b/src/lib/onboard/dashboard-port.test.ts index c70fe68d1a..10e99b7bcc 100644 --- a/src/lib/onboard/dashboard-port.test.ts +++ b/src/lib/onboard/dashboard-port.test.ts @@ -8,6 +8,7 @@ import { describe, it } from "vitest"; import { findAvailableDashboardPort, findDashboardForwardOwner, + preflightDashboardPortRangeAvailability, } from "../../../dist/lib/onboard/dashboard-port"; describe("findDashboardForwardOwner", () => { @@ -93,3 +94,48 @@ describe("findAvailableDashboardPort port-conflict detection (#3260)", () => { assert.deepEqual(seen, [18789, 18790]); }); }); + +describe("preflightDashboardPortRangeAvailability (#3953)", () => { + const allBound = (_p: number) => true; + const noneBound = (_p: number) => false; + const someBound = (...bound: number[]) => { + const set = new Set(bound); + return (p: number) => set.has(p); + }; + + it("exits 1 with the canonical message when every port in the range is bound", () => { + let exitCode: number | undefined; + const exitFn = ((code?: number) => { + exitCode = code; + throw new Error(`__exit_${code ?? 0}__`); + }) as (code?: number) => never; + const stderrChunks: string[] = []; + const origError = console.error; + console.error = (msg: string) => { stderrChunks.push(msg); }; + try { + assert.throws(() => preflightDashboardPortRangeAvailability(allBound, exitFn), /__exit_1__/); + } finally { + console.error = origError; + } + assert.equal(exitCode, 1); + const combined = stderrChunks.join("\n"); + assert.match(combined, /All dashboard ports in range 18789-18799 are occupied:/); + assert.match(combined, / 18789 → non-OpenShell host listener/); + assert.match(combined, / 18799 → non-OpenShell host listener/); + assert.match(combined, /--control-ui-port /); + }); + + it("returns without exiting when at least one port in the range is free", () => { + // Even if 10 of 11 ports are bound, the one free port short-circuits success. + const bound = someBound(18789, 18790, 18791, 18792, 18793, 18794, 18795, 18796, 18797, 18798); + preflightDashboardPortRangeAvailability(bound, (() => { + throw new Error("exitFn must not be called when a port is free"); + }) as (code?: number) => never); + }); + + it("returns without exiting when no port is bound", () => { + preflightDashboardPortRangeAvailability(noneBound, (() => { + throw new Error("exitFn must not be called when no port is bound"); + }) as (code?: number) => never); + }); +}); diff --git a/src/lib/onboard/dashboard-port.ts b/src/lib/onboard/dashboard-port.ts index 1f6803ae1b..5919eaa191 100644 --- a/src/lib/onboard/dashboard-port.ts +++ b/src/lib/onboard/dashboard-port.ts @@ -189,3 +189,44 @@ export function findAvailableDashboardPort( `Free a sandbox or use --control-ui-port with a port outside this range.`, ); } + +/** + * Preflight scan of the dashboard port range. If every port in + * [DASHBOARD_PORT_RANGE_START, DASHBOARD_PORT_RANGE_END] is bound on + * the host, print the same "All dashboard ports in range … are + * occupied" error that `findAvailableDashboardPort` would eventually + * raise during sandbox creation and exit non-zero. Calling this from + * `preflight()` surfaces the failure before any side effects (gateway + * start, inference setup), matching the contract reporters expect + * (#3953). + * + * Intentionally narrower than `findAvailableDashboardPort`: it does not + * consult OpenShell forward state, never reserves a port, and treats + * every bound port as a non-OpenShell listener. That is sound here — + * if every port is bound, the host either has no free port for a new + * sandbox OR every port already serves an existing sandbox dashboard; + * both cases require operator intervention via `--control-ui-port`. + * + * The `exitFn` and `isPortBoundCheck` parameters are dependency + * injection seams for unit tests; production callers use the defaults. + */ +export function preflightDashboardPortRangeAvailability( + isPortBoundCheck: (port: number) => boolean = isPortBoundOnHost, + exitFn: (code?: number) => never = process.exit as (code?: number) => never, +): void { + const ports = Array.from( + { length: DASHBOARD_PORT_RANGE_END - DASHBOARD_PORT_RANGE_START + 1 }, + (_, i) => DASHBOARD_PORT_RANGE_START + i, + ); + const bound: number[] = []; + for (const p of ports) { + if (!isPortBoundCheck(p)) return; + bound.push(p); + } + const lines = bound.map((p) => ` ${p} → non-OpenShell host listener`).join("\n"); + console.error( + ` All dashboard ports in range ${DASHBOARD_PORT_RANGE_START}-${DASHBOARD_PORT_RANGE_END} are occupied:\n${lines}\n` + + ` Free a sandbox or use --control-ui-port with a port outside this range.`, + ); + exitFn(1); +}