diff --git a/scripts/lib/cross-os-release-checks/installed.ts b/scripts/lib/cross-os-release-checks/installed.ts index 2a698e3b95ea..0aad70888c55 100644 --- a/scripts/lib/cross-os-release-checks/installed.ts +++ b/scripts/lib/cross-os-release-checks/installed.ts @@ -420,8 +420,9 @@ export async function runOnboardWithInstalledCli(params: { providerConfig: ProviderConfig; installDaemon: boolean; logPath: string; + allocateGatewayPort?: boolean; }) { - await withAllocatedGatewayPort(params.lane, async () => { + const runOnboard = async () => { const args = buildReleaseOnboardArgs({ authChoice: params.providerConfig.authChoice, gatewayPort: params.lane.gatewayPort, @@ -436,7 +437,15 @@ export async function runOnboardWithInstalledCli(params: { logPath: params.logPath, timeoutMs: 10 * 60 * 1000, }); - }); + }; + if (params.allocateGatewayPort === false) { + if (params.lane.gatewayPort <= 0) { + throw new Error("Installed onboarding requires a reserved gateway port."); + } + await runOnboard(); + return; + } + await withAllocatedGatewayPort(params.lane, runOnboard); } export function buildReleaseOnboardArgs(params: { diff --git a/scripts/lib/cross-os-release-checks/lanes.ts b/scripts/lib/cross-os-release-checks/lanes.ts index a9b600fc7550..95d76cb9a24f 100644 --- a/scripts/lib/cross-os-release-checks/lanes.ts +++ b/scripts/lib/cross-os-release-checks/lanes.ts @@ -59,7 +59,12 @@ import { waitForInstalledGatewayToStop, } from "./installed.ts"; import { maybeRunDiscordRoundtrip } from "./network-smokes.ts"; -import { runCleanup, startStaticFileServer, stopGateway } from "./process.ts"; +import { + reserveGatewayPortForLane, + runCleanup, + startStaticFileServer, + stopGateway, +} from "./process.ts"; import { logLanePhase, runTimedLanePhase } from "./reporting.ts"; import { exerciseManagedGatewayLifecycle, @@ -432,6 +437,15 @@ export async function runInstallerFreshSuite( }); } + // Hold the configured port through onboarding and model setup so another runner process + // cannot claim it before the manual gateway starts. Release immediately before spawn. + const gatewayPortReservation = usesManagedGateway + ? null + : await reserveGatewayPortForLane(lane); + if (gatewayPortReservation) { + cleanup.push(() => gatewayPortReservation.release()); + } + logLanePhase(lane, "onboard"); await runOnboardWithInstalledCli({ lane, @@ -440,6 +454,7 @@ export async function runInstallerFreshSuite( providerConfig: params.providerConfig, installDaemon: usesManagedGateway, logPath: join(params.logsDir, "installer-fresh-onboard.log"), + allocateGatewayPort: gatewayPortReservation === null, }); if (shouldExerciseManagedGatewayLifecycleAfterInstall()) { @@ -483,6 +498,7 @@ export async function runInstallerFreshSuite( logPath: join(params.logsDir, "installer-fresh-gateway-stop-managed-status.log"), }); } + await gatewayPortReservation?.release(); logLanePhase(lane, "gateway-start"); const gateway = await startManualGatewayFromInstalledCli({ lane, diff --git a/scripts/lib/cross-os-release-checks/process.ts b/scripts/lib/cross-os-release-checks/process.ts index f2b39dad104b..4f4b7465c0fb 100644 --- a/scripts/lib/cross-os-release-checks/process.ts +++ b/scripts/lib/cross-os-release-checks/process.ts @@ -593,6 +593,22 @@ export async function withAllocatedGatewayPort(lane: LaneState, callback: () ); } +export async function reserveGatewayPortForLane(lane: LaneState) { + const reservation = await reservePort(); + lane.gatewayPort = reservation.port; + let released = false; + return { + port: reservation.port, + release: async () => { + if (released) { + return; + } + released = true; + await reservation.release(); + }, + }; +} + function reservePort(): Promise<{ port: number; release: () => Promise }> { return new Promise((resolvePromise, rejectPromise) => { const server = createNetServer(); diff --git a/test/scripts/openclaw-cross-os-release-checks.test.ts b/test/scripts/openclaw-cross-os-release-checks.test.ts index ae875e1dba14..e8c9af988a53 100644 --- a/test/scripts/openclaw-cross-os-release-checks.test.ts +++ b/test/scripts/openclaw-cross-os-release-checks.test.ts @@ -69,6 +69,7 @@ import { readInstalledVersion, readBoundedCrossOsResponseText, readRunnerOverrideEnv, + reserveGatewayPortForLane, resolveDashboardAssetUrls, resolveCrossOsAgentTurnOptional, runCommand, @@ -1794,6 +1795,19 @@ describe("scripts/openclaw-cross-os-release-checks", () => { expect(await canConnectToLoopbackPort(port, 100)).toBe(false); }); + it("keeps a release gateway port reserved until the lane is ready to start", async () => { + const lane = { gatewayPort: 0 } as Parameters[0]; + const reservation = await reserveGatewayPortForLane(lane); + try { + expect(lane.gatewayPort).toBe(reservation.port); + expect(await canConnectToLoopbackPort(reservation.port)).toBe(true); + } finally { + await reservation.release(); + } + await reservation.release(); + expect(await canConnectToLoopbackPort(reservation.port, 100)).toBe(false); + }); + it("writes Discord smoke config using the strict guild channel schema", () => { expect(buildDiscordSmokeGuildsConfig("guild-123", "channel-456")).toEqual({ "guild-123": {