diff --git a/tests/runner-e2e/launch.ts b/tests/runner-e2e/launch.ts index 50e2ef9a39..9d4a739f20 100644 --- a/tests/runner-e2e/launch.ts +++ b/tests/runner-e2e/launch.ts @@ -2,7 +2,6 @@ import { randomBytes } from "node:crypto"; import { spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; import { createRequire } from "node:module"; -import { createServer } from "node:net"; import os from "node:os"; import path from "node:path"; import { @@ -46,6 +45,7 @@ import { reapNewDetachedDarwinSharedMemory, snapshotDarwinSharedMemory, } from "./shared-memory.js"; +import { reserveRunnerE2EServerPort } from "./ports.js"; const repositoryRoot = path.resolve(import.meta.dirname, "../.."); const localEnvPath = path.join(repositoryRoot, ".env.runner-e2e.local"); @@ -157,21 +157,6 @@ async function loadLocalEnvironment(target: NodeJS.ProcessEnv) { } } -async function reservePort() { - const server = createServer(); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - if (!address || typeof address === "string") - throw new Error("Failed to reserve a loopback port"); - await new Promise((resolve, reject) => - server.close((error) => (error ? reject(error) : resolve())), - ); - return address.port; -} - async function prepareProviderPath( temporaryRoot: string, inheritedPath: string | undefined, @@ -388,7 +373,7 @@ async function runAttempt(input: { instanceId, "config.json", ); - const port = await reservePort(); + const port = await reserveRunnerE2EServerPort(); await Promise.all([ mkdir(paperclipHome, { recursive: true }), mkdir(workspace, { recursive: true }), diff --git a/tests/runner-e2e/ports.ts b/tests/runner-e2e/ports.ts new file mode 100644 index 0000000000..10d0703cfc --- /dev/null +++ b/tests/runner-e2e/ports.ts @@ -0,0 +1,92 @@ +import { createServer } from "node:net"; +import { derivePaperclipViteHmrPort } from "../../packages/shared/src/runtime-exposure/ports.js"; + +export const RUNNER_E2E_EMBEDDED_POSTGRES_PORT = 54_329; + +export interface LoopbackPortReservation { + port: number; + close(): Promise; +} + +export type OpenLoopbackPort = ( + requestedPort: number, +) => Promise; + +async function openLoopbackPort( + requestedPort: number, +): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(requestedPort, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Failed to reserve a loopback port"); + } + return { + port: address.port, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} + +export function runnerE2EServerPortConflictsWithDatabase( + serverPort: number, + databasePort = RUNNER_E2E_EMBEDDED_POSTGRES_PORT, +) { + return ( + serverPort === databasePort || + derivePaperclipViteHmrPort(serverPort) === databasePort + ); +} + +function isAddressInUse(error: unknown) { + return (error as NodeJS.ErrnoException | undefined)?.code === "EADDRINUSE"; +} + +export async function reserveRunnerE2EServerPort( + options: { + databasePort?: number; + maxAttempts?: number; + openPort?: OpenLoopbackPort; + } = {}, +) { + const databasePort = + options.databasePort ?? RUNNER_E2E_EMBEDDED_POSTGRES_PORT; + const maxAttempts = options.maxAttempts ?? 32; + const openPort = options.openPort ?? openLoopbackPort; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const serverReservation = await openPort(0); + let hmrReservation: LoopbackPortReservation | undefined; + try { + if ( + runnerE2EServerPortConflictsWithDatabase( + serverReservation.port, + databasePort, + ) + ) { + continue; + } + const hmrPort = derivePaperclipViteHmrPort(serverReservation.port); + try { + hmrReservation = await openPort(hmrPort); + } catch (error) { + if (isAddressInUse(error)) continue; + throw error; + } + return serverReservation.port; + } finally { + await hmrReservation?.close(); + await serverReservation.close(); + } + } + + throw new Error( + `Failed to reserve a conflict-free Paperclip/Vite port pair after ${maxAttempts} attempts`, + ); +} diff --git a/tests/runner-e2e/support.test.ts b/tests/runner-e2e/support.test.ts index b3894baed2..f950318687 100644 --- a/tests/runner-e2e/support.test.ts +++ b/tests/runner-e2e/support.test.ts @@ -26,6 +26,11 @@ import { sanitizeJson, } from "./redaction.js"; import { parseDarwinSharedMemory } from "./shared-memory.js"; +import { + reserveRunnerE2EServerPort, + runnerE2EServerPortConflictsWithDatabase, + type LoopbackPortReservation, +} from "./ports.js"; import { acceptedPlanSessionResetFailures, isControlPlaneGovernedResponseWait, @@ -79,6 +84,36 @@ describe("runner E2E local binary resolution", () => { }); }); +describe("runner E2E server port allocation", () => { + it("rejects direct and derived embedded-Postgres collisions", () => { + expect(runnerE2EServerPortConflictsWithDatabase(44_329)).toBe(true); + expect(runnerE2EServerPortConflictsWithDatabase(54_329)).toBe(true); + expect(runnerE2EServerPortConflictsWithDatabase(64_329)).toBe(true); + expect(runnerE2EServerPortConflictsWithDatabase(43_123)).toBe(false); + }); + + it("retries a derived collision while closing every reservation", async () => { + const closed: number[] = []; + const ports = [44_329, 43_123, 53_123]; + const openPort = vi.fn(async (requestedPort: number) => { + const port = requestedPort === 0 ? ports.shift() : requestedPort; + if (port === undefined) throw new Error("Missing fake port"); + return { + port, + close: async () => { + closed.push(port); + }, + } satisfies LoopbackPortReservation; + }); + + await expect(reserveRunnerE2EServerPort({ openPort })).resolves.toBe( + 43_123, + ); + expect(openPort.mock.calls.map(([port]) => port)).toEqual([0, 0, 53_123]); + expect(closed).toEqual([44_329, 53_123, 43_123]); + }); +}); + describe("runner E2E sensitive API boundary", () => { it("keeps secret request bodies out of Playwright API tracing", async () => { vi.stubEnv("PAPERCLIP_RUNNER_E2E_PORT", "43123");