fix(runner-e2e): reserve conflict-free local ports
This commit is contained in:
parent
8952181bb3
commit
d238d6c01e
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue