fix(release): reserve installer gateway port

This commit is contained in:
Peter Steinberger
2026-07-17 15:38:04 +01:00
parent 0476243d5d
commit 5a8b25cc9a
4 changed files with 58 additions and 3 deletions
@@ -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: {
+17 -1
View File
@@ -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,
@@ -593,6 +593,22 @@ export async function withAllocatedGatewayPort<T>(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<void> }> {
return new Promise((resolvePromise, rejectPromise) => {
const server = createNetServer();
@@ -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<typeof reserveGatewayPortForLane>[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": {