Skip to content
8 changes: 4 additions & 4 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } =
Expand Down Expand Up @@ -2359,7 +2359,7 @@ async function preflight(
}
}

return gpu;
if (_preflightDashboardPort === null) preflightDashboardPortRangeAvailability(); return gpu; // #3953 — fail-fast before next step
}

// ── Step 2: Gateway ──────────────────────────────────────────────
Expand Down Expand Up @@ -6725,7 +6725,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
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;
Expand Down Expand Up @@ -7083,6 +7083,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
updateSession: onboardSession.updateSession,
},
});
if (resume && _preflightDashboardPort === null) preflightDashboardPortRangeAvailability(); // #3953 — resume must mirror preflight()'s fail-fast
session = preflightResult.session;
const {
sandboxGpuConfig,
Expand Down Expand Up @@ -7530,7 +7531,6 @@ module.exports = {

startGateway,
findAvailableDashboardPort,
findDashboardForwardOwner,
startGatewayForRecovery,
openshellArgv,
runCaptureOpenshell,
Expand Down
46 changes: 46 additions & 0 deletions src/lib/onboard/dashboard-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { describe, it } from "vitest";
import {
findAvailableDashboardPort,
findDashboardForwardOwner,
preflightDashboardPortRangeAvailability,
} from "../../../dist/lib/onboard/dashboard-port";

describe("findDashboardForwardOwner", () => {
Expand Down Expand Up @@ -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 <N>/);
});

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);
});
});
41 changes: 41 additions & 0 deletions src/lib/onboard/dashboard-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,3 +189,44 @@ export function findAvailableDashboardPort(
`Free a sandbox or use --control-ui-port <N> 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 <N> with a port outside this range.`,
);
exitFn(1);
}
Loading