fix(runner-e2e): reserve conflict-free local ports

This commit is contained in:
Dotta 2026-09-03 13:56:17 -05:00
parent 8952181bb3
commit d238d6c01e
3 changed files with 129 additions and 17 deletions

View File

@ -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<void>((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<void>((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 }),

92
tests/runner-e2e/ports.ts Normal file
View File

@ -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<void>;
}
export type OpenLoopbackPort = (
requestedPort: number,
) => Promise<LoopbackPortReservation>;
async function openLoopbackPort(
requestedPort: number,
): Promise<LoopbackPortReservation> {
const server = createServer();
await new Promise<void>((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<void>((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`,
);
}

View File

@ -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");