The hash-derived block was only collision-unlikely (28 checkouts over 400 blocks is birthday-paradox territory), and probe-then-bind raced. Each block now reserves its last port as a lock held for the suite's lifetime: claimPorts binds the lock (atomic — two racing suites can't both win), probes the remaining ports for squatters, and walks forward block-by-block until it owns a fully free block, publishing the claimed ports via E2E_*_PORT env so test workers agree. Explicit env pins skip claiming entirely. Verified by squatting the preferred block's lock+selfhost ports and watching the suite relocate one block over and pass 17/17.
Unified · Split
| @@ -89,10 +89,13 @@ E2E_CLOUD_URL=http://127.0.0.1:<port> ../node_modules/.bin/vitest run --project · 88 unmodified lines | |||
| 89 | E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file> | 89 | E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file> |
| 90 | ``` | 90 | ``` |
| 91 | 91 | ||
| 92 | Ports are derived per checkout (hash of the repo root — see `src/ports.ts`), | 92 | Ports are claimed at boot (see `src/ports.ts`): each checkout hashes its repo |
| 93 | so suites in different worktrees never fight. If a port is somehow taken, the | 93 | root to a preferred block, atomically locks it (a held lock port makes races |
| 94 | boot fails fast naming the squatting process; kill it or override with | 94 | impossible), and walks to the next free block if it's locked or squatted — so |
| 95 | `E2E_CLOUD_PORT`-style env vars. | 95 | concurrent suites in different worktrees can never collide or attach to each |
| 96 | other's servers. `bun run ports` shows the preferred block; the boot log says | ||
| 97 | if a suite moved. `E2E_*_PORT` env vars pin ports explicitly (no probing) and | ||
| 98 | `E2E_<TARGET>_URL` attaches to a running instance. | ||
| 96 | 99 | ||
| 97 | Each run writes `runs/<target>/<slug>/result.json` plus any browser artifacts | 100 | Each run writes `runs/<target>/<slug>/result.json` plus any browser artifacts |
| 98 | (trace.zip / session.mp4 / screenshots). `bun run serve` hosts the scenario × | 101 | (trace.zip / session.mp4 / screenshots). `bun run serve` hosts the scenario × |
| @@ -1,5 +1,8 @@ | |||
| 1 | // Print this checkout's derived e2e ports (see src/ports.ts) so an agent or | 1 | // Print this checkout's PREFERRED e2e ports (see src/ports.ts). These are |
| 2 | // human can curl the booted servers or attach with E2E_<TARGET>_URL. | 2 | // where a suite normally boots; if the block is locked or squatted at boot |
| 3 | // time, claimPorts walks to the next free block and the suite logs the move. | ||
| 4 | // When attaching mid-run, the booted vite's actual port is authoritative — | ||
| 5 | // check the suite's log line or `ps | grep 'vite dev'`. | ||
| 3 | import { | 6 | import { |
| 4 | AUTUMN_EMULATOR_PORT, | 7 | AUTUMN_EMULATOR_PORT, |
| 5 | CLOUD_DB_PORT, | 8 | CLOUD_DB_PORT, |
| @@ -9,7 +12,7 @@ import { · 3 unmodified lines | |||
| 9 | import { SELFHOST_PORT } from "../targets/selfhost"; | 12 | import { SELFHOST_PORT } from "../targets/selfhost"; |
| 10 | import { repoRoot } from "../src/ports"; | 13 | import { repoRoot } from "../src/ports"; |
| 11 | 14 | ||
| 12 | console.log(`e2e ports for ${repoRoot}`); | 15 | console.log(`preferred e2e ports for ${repoRoot}`); |
| 13 | console.log(` cloud http://127.0.0.1:${CLOUD_PORT}`); | 16 | console.log(` cloud http://127.0.0.1:${CLOUD_PORT}`); |
| 14 | console.log(` cloud dev-db ${CLOUD_DB_PORT}`); | 17 | console.log(` cloud dev-db ${CLOUD_DB_PORT}`); |
| 15 | console.log(` workos emulator ${WORKOS_EMULATOR_PORT}`); | 18 | console.log(` workos emulator ${WORKOS_EMULATOR_PORT}`); |
| @@ -2,8 +2,7 @@ · 1 unmodified line | |||
| 2 | // server, wait until it answers HTTP, and hand vitest a teardown. The apps own | 2 | // server, wait until it answers HTTP, and hand vitest a teardown. The apps own |
| 3 | // what runs (their dev stack, their stub flags); this file only owns process | 3 | // what runs (their dev stack, their stub flags); this file only owns process |
| 4 | // lifecycle, so it stays target-agnostic. | 4 | // lifecycle, so it stays target-agnostic. |
| 5 | import { execFileSync, spawn, type ChildProcess } from "node:child_process"; | 5 | import { spawn, type ChildProcess } from "node:child_process"; |
| 6 | import { connect } from "node:net"; | ||
| 7 | 6 | ||
| 8 | export interface BootedProcesses { | 7 | export interface BootedProcesses { |
| 9 | readonly teardown: () => Promise<void>; | 8 | readonly teardown: () => Promise<void>; |
| @@ -73,43 +72,6 @@ export const bootProcesses = ( · 63 unmodified lines | |||
| 73 | }; | 72 | }; |
| 74 | }; | 73 | }; |
| 75 | 74 | ||
| 76 | // A port already in LISTEN before we boot means waitForHttp would silently | ||
| 77 | // attach to a FOREIGN server (a leaked dev server from another checkout or a | ||
| 78 | // crashed prior run) — every scenario then fails with baffling auth errors | ||
| 79 | // instead of one clear message. Fail fast and name the squatter. | ||
| 80 | export const ensurePortsFree = async ( | ||
| 81 | ports: ReadonlyArray<{ readonly port: number; readonly label: string }>, | ||
| 82 | ): Promise<void> => { | ||
| 83 | for (const { port, label } of ports) { | ||
| 84 | const inUse = await new Promise<boolean>((resolve) => { | ||
| 85 | const socket = connect({ port, host: "127.0.0.1" }); | ||
| 86 | socket.once("connect", () => { | ||
| 87 | socket.destroy(); | ||
| 88 | resolve(true); | ||
| 89 | }); | ||
| 90 | socket.once("error", () => resolve(false)); | ||
| 91 | socket.setTimeout(1_000, () => { | ||
| 92 | socket.destroy(); | ||
| 93 | resolve(false); | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | if (!inUse) continue; | ||
| 97 | let owner = "(lsof unavailable)"; | ||
| 98 | try { | ||
| 99 | owner = execFileSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN"], { | ||
| 100 | encoding: "utf8", | ||
| 101 | }).trim(); | ||
| 102 | } catch { | ||
| 103 | // lsof failing is fine — the error below carries the essential fact. | ||
| 104 | } | ||
| 105 | throw new Error( | ||
| 106 | `e2e: port ${port} (${label}) is already in use — likely a leaked dev server ` + | ||
| 107 | `from another checkout or a previous run. Kill it or set the E2E_*_PORT/URL ` + | ||
| 108 | `env vars to relocate.\n${owner}`, | ||
| 109 | ); | ||
| 110 | } | ||
| 111 | }; | ||
| 112 | |||
| 113 | export const waitForHttp = async ( | 75 | export const waitForHttp = async ( |
| 114 | url: string, | 76 | url: string, |
| 115 | options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean } = {}, | 77 | options: { readonly timeoutMs?: number; readonly expectRedirect?: boolean } = {}, |
| @@ -10,16 +10,9 @@ import { fileURLToPath } from "node:url"; · 9 unmodified lines | |||
| 10 | // Vendored fork import (same pattern as mcporter). | 10 | // Vendored fork import (same pattern as mcporter). |
| 11 | import { createEmulator } from "@executor-js/emulate"; | 11 | import { createEmulator } from "@executor-js/emulate"; |
| 12 | 12 | ||
| 13 | import { bootProcesses, ensurePortsFree, waitForHttp } from "./boot"; | 13 | import { claimPorts } from "../src/ports"; |
| 14 | import { | 14 | import { E2E_COOKIE_PASSWORD, E2E_WORKOS_CLIENT_ID } from "../targets/cloud"; |
| 15 | CLOUD_BASE_URL, | 15 | import { bootProcesses, waitForHttp } from "./boot"; |
| 16 | CLOUD_DB_PORT, | ||
| 17 | CLOUD_PORT, | ||
| 18 | WORKOS_EMULATOR_PORT, | ||
| 19 | AUTUMN_EMULATOR_PORT, | ||
| 20 | E2E_WORKOS_CLIENT_ID, | ||
| 21 | E2E_COOKIE_PASSWORD, | ||
| 22 | } from "../targets/cloud"; | ||
| 23 | 16 | ||
| 24 | const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); | 17 | const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url)); |
| 25 | 18 | ||
| @@ -29,12 +22,19 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 3 unmodified lines | |||
| 29 | return; | 22 | return; |
| 30 | } | 23 | } |
| 31 | 24 | ||
| 32 | await ensurePortsFree([ | 25 | // Claim a free port block (preferred block first, walk forward past |
| 33 | { port: CLOUD_PORT, label: "cloud vite dev" }, | 26 | // squatters/colliding checkouts) and publish via env so the test workers — |
| 34 | { port: CLOUD_DB_PORT, label: "cloud dev-db (PGlite)" }, | 27 | // spawned after this — derive the same URLs. The imported targets/cloud |
| 35 | { port: WORKOS_EMULATOR_PORT, label: "WorkOS emulator" }, | 28 | // constants were computed BEFORE the claim, so use the claimed values here. |
| 36 | { port: AUTUMN_EMULATOR_PORT, label: "Autumn emulator" }, | 29 | const { ports, release } = await claimPorts([ |
| 30 | { envVar: "E2E_CLOUD_PORT", offset: 0, label: "cloud vite dev" }, | ||
| 31 | { envVar: "E2E_CLOUD_DB_PORT", offset: 1, label: "cloud dev-db (PGlite)" }, | ||
| 32 | { envVar: "E2E_WORKOS_EMULATOR_PORT", offset: 2, label: "WorkOS emulator" }, | ||
| 33 | { envVar: "E2E_AUTUMN_EMULATOR_PORT", offset: 3, label: "Autumn emulator" }, | ||
| 37 | ]); | 34 | ]); |
| 35 | const cloudPort = ports.E2E_CLOUD_PORT!; | ||
| 36 | const dbPort = ports.E2E_CLOUD_DB_PORT!; | ||
| 37 | const baseUrl = `http://127.0.0.1:${cloudPort}`; | ||
| 38 | 38 | ||
| 39 | // Fresh dev DB per suite run — hermetic, like the selfhost data dir. The | 39 | // Fresh dev DB per suite run — hermetic, like the selfhost data dir. The |
| 40 | // WorkOS emulator mints org ids from a per-process counter, so a persisted | 40 | // WorkOS emulator mints org ids from a per-process counter, so a persisted |
| @@ -46,8 +46,8 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 5 unmodified lines | |||
| 46 | // MCP access tokens minted by the emulator's OAuth server must carry the | 46 | // MCP access tokens minted by the emulator's OAuth server must carry the |
| 47 | // app's client id as audience (what the resource server verifies). | 47 | // app's client id as audience (what the resource server verifies). |
| 48 | process.env.EMULATE_WORKOS_AUDIENCE = E2E_WORKOS_CLIENT_ID; | 48 | process.env.EMULATE_WORKOS_AUDIENCE = E2E_WORKOS_CLIENT_ID; |
| 49 | const workos = await createEmulator({ service: "workos", port: WORKOS_EMULATOR_PORT }); | 49 | const workos = await createEmulator({ service: "workos", port: ports.E2E_WORKOS_EMULATOR_PORT! }); |
| 50 | const autumn = await createEmulator({ service: "autumn", port: AUTUMN_EMULATOR_PORT }); | 50 | const autumn = await createEmulator({ service: "autumn", port: ports.E2E_AUTUMN_EMULATOR_PORT! }); |
| 51 | 51 | ||
| 52 | const env = { | 52 | const env = { |
| 53 | // Real client, emulated service. | 53 | // Real client, emulated service. |
| @@ -58,16 +58,16 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 4 unmodified lines | |||
| 58 | WORKOS_COOKIE_PASSWORD: E2E_COOKIE_PASSWORD, | 58 | WORKOS_COOKIE_PASSWORD: E2E_COOKIE_PASSWORD, |
| 59 | AUTUMN_SECRET_KEY: "am_test_emulate", | 59 | AUTUMN_SECRET_KEY: "am_test_emulate", |
| 60 | ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | 60 | ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", |
| 61 | DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${CLOUD_DB_PORT}/postgres`, | 61 | DATABASE_URL: `postgresql://postgres:postgres@127.0.0.1:${dbPort}/postgres`, |
| 62 | EXECUTOR_DIRECT_DATABASE_URL: "true", | 62 | EXECUTOR_DIRECT_DATABASE_URL: "true", |
| 63 | CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", | 63 | CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", |
| 64 | VITE_PUBLIC_SITE_URL: CLOUD_BASE_URL, | 64 | VITE_PUBLIC_SITE_URL: baseUrl, |
| 65 | // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too. | 65 | // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too. |
| 66 | MCP_AUTHKIT_DOMAIN: workos.url, | 66 | MCP_AUTHKIT_DOMAIN: workos.url, |
| 67 | MCP_RESOURCE_ORIGIN: CLOUD_BASE_URL, | 67 | MCP_RESOURCE_ORIGIN: baseUrl, |
| 68 | ALLOW_LOCAL_NETWORK: "true", | 68 | ALLOW_LOCAL_NETWORK: "true", |
| 69 | // Throwaway PGlite on its own port + dir so it never fights `bun dev`. | 69 | // Throwaway PGlite on its own port + dir so it never fights `bun dev`. |
| 70 | DEV_DB_PORT: String(CLOUD_DB_PORT), | 70 | DEV_DB_PORT: String(dbPort), |
| 71 | DEV_DB_PATH: dbPath, | 71 | DEV_DB_PATH: dbPath, |
| 72 | }; | 72 | }; |
| 73 | 73 | ||
| @@ -76,7 +76,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 2 unmodified lines | |||
| 76 | { cmd: "bun", args: ["run", "scripts/dev-db.ts"], cwd: cloudDir, env }, | 76 | { cmd: "bun", args: ["run", "scripts/dev-db.ts"], cwd: cloudDir, env }, |
| 77 | { | 77 | { |
| 78 | cmd: "bunx", | 78 | cmd: "bunx", |
| 79 | args: ["vite", "dev", "--port", String(CLOUD_PORT), "--strictPort", "--host", "127.0.0.1"], | 79 | args: ["vite", "dev", "--port", String(cloudPort), "--strictPort", "--host", "127.0.0.1"], |
| 80 | cwd: cloudDir, | 80 | cwd: cloudDir, |
| 81 | env, | 81 | env, |
| 82 | }, | 82 | }, |
| @@ -85,18 +85,20 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 2 unmodified lines | |||
| 85 | ); | 85 | ); |
| 86 | 86 | ||
| 87 | try { | 87 | try { |
| 88 | await waitForHttp(CLOUD_BASE_URL); | 88 | await waitForHttp(baseUrl); |
| 89 | // The API plane is ready when login actually redirects to AuthKit. | 89 | // The API plane is ready when login actually redirects to AuthKit. |
| 90 | await waitForHttp(`${CLOUD_BASE_URL}/api/auth/login`, { expectRedirect: true }); | 90 | await waitForHttp(`${baseUrl}/api/auth/login`, { expectRedirect: true }); |
| 91 | } catch (error) { | 91 | } catch (error) { |
| 92 | await procs.teardown(); | 92 | await procs.teardown(); |
| 93 | await workos.close(); | 93 | await workos.close(); |
| 94 | await autumn.close(); | 94 | await autumn.close(); |
| 95 | await release(); | ||
| 95 | throw error; | 96 | throw error; |
| 96 | } | 97 | } |
| 97 | return async () => { | 98 | return async () => { |
| 98 | await procs.teardown(); | 99 | await procs.teardown(); |
| 99 | await workos.close(); | 100 | await workos.close(); |
| 100 | await autumn.close(); | 101 | await autumn.close(); |
| 102 | await release(); | ||
| 101 | }; | 103 | }; |
| 102 | } | 104 | } |
| @@ -6,8 +6,9 @@ import { rmSync } from "node:fs"; · 5 unmodified lines | |||
| 6 | import { resolve } from "node:path"; | 6 | import { resolve } from "node:path"; |
| 7 | import { fileURLToPath } from "node:url"; | 7 | import { fileURLToPath } from "node:url"; |
| 8 | 8 | ||
| 9 | import { bootProcesses, ensurePortsFree, waitForHttp } from "./boot"; | 9 | import { claimPorts } from "../src/ports"; |
| 10 | import { SELFHOST_ADMIN, SELFHOST_BASE_URL, SELFHOST_PORT } from "../targets/selfhost"; | 10 | import { SELFHOST_ADMIN } from "../targets/selfhost"; |
| 11 | import { bootProcesses, waitForHttp } from "./boot"; | ||
| 11 | 12 | ||
| 12 | const selfhostDir = fileURLToPath(new URL("../../apps/host-selfhost/", import.meta.url)); | 13 | const selfhostDir = fileURLToPath(new URL("../../apps/host-selfhost/", import.meta.url)); |
| 13 | 14 | ||
| @@ -17,7 +18,15 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 3 unmodified lines | |||
| 17 | return; | 18 | return; |
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | await ensurePortsFree([{ port: SELFHOST_PORT, label: "selfhost vite dev" }]); | 21 | // Claim a free port (preferred block first, walk forward past squatters) |
| 22 | // and publish via env so the test workers derive the same URL. The imported | ||
| 23 | // targets/selfhost constants were computed BEFORE the claim — don't use them | ||
| 24 | // for ports/URLs here. | ||
| 25 | const { ports, release } = await claimPorts([ | ||
| 26 | { envVar: "E2E_SELFHOST_PORT", offset: 4, label: "selfhost vite dev" }, | ||
| 27 | ]); | ||
| 28 | const port = ports.E2E_SELFHOST_PORT!; | ||
| 29 | const baseUrl = `http://localhost:${port}`; | ||
| 21 | 30 | ||
| 22 | // Fresh data dir per suite run — hermetic; in-suite isolation comes from | 31 | // Fresh data dir per suite run — hermetic; in-suite isolation comes from |
| 23 | // fresh identities, not resets. | 32 | // fresh identities, not resets. |
| @@ -28,14 +37,14 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 4 unmodified lines | |||
| 28 | [ | 37 | [ |
| 29 | { | 38 | { |
| 30 | cmd: "bunx", | 39 | cmd: "bunx", |
| 31 | args: ["--bun", "vite", "dev", "--port", String(SELFHOST_PORT), "--strictPort"], | 40 | args: ["--bun", "vite", "dev", "--port", String(port), "--strictPort"], |
| 32 | cwd: selfhostDir, | 41 | cwd: selfhostDir, |
| 33 | env: { | 42 | env: { |
| 34 | EXECUTOR_DATA_DIR: dataDir, | 43 | EXECUTOR_DATA_DIR: dataDir, |
| 35 | BETTER_AUTH_SECRET: "executor-selfhost-e2e-secret-0123456789", | 44 | BETTER_AUTH_SECRET: "executor-selfhost-e2e-secret-0123456789", |
| 36 | EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email, | 45 | EXECUTOR_BOOTSTRAP_ADMIN_EMAIL: SELFHOST_ADMIN.email, |
| 37 | EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password, | 46 | EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD: SELFHOST_ADMIN.password, |
| 38 | EXECUTOR_WEB_BASE_URL: SELFHOST_BASE_URL, | 47 | EXECUTOR_WEB_BASE_URL: baseUrl, |
| 39 | // The harness boots loopback MCP/OAuth test servers and points the | 48 | // The harness boots loopback MCP/OAuth test servers and points the |
| 40 | // instance at them; the hosted SSRF guard would otherwise block | 49 | // instance at them; the hosted SSRF guard would otherwise block |
| 41 | // outbound probes/dials to localhost. Hermetic test instance only. | 50 | // outbound probes/dials to localhost. Hermetic test instance only. |
| @@ -47,10 +56,14 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 5 unmodified lines | |||
| 47 | ); | 56 | ); |
| 48 | 57 | ||
| 49 | try { | 58 | try { |
| 50 | await waitForHttp(SELFHOST_BASE_URL); | 59 | await waitForHttp(baseUrl); |
| 51 | } catch (error) { | 60 | } catch (error) { |
| 52 | await procs.teardown(); | 61 | await procs.teardown(); |
| 62 | await release(); | ||
| 53 | throw error; | 63 | throw error; |
| 54 | } | 64 | } |
| 55 | return procs.teardown; | 65 | return async () => { |
| 66 | await procs.teardown(); | ||
| 67 | await release(); | ||
| 68 | }; | ||
| 56 | } | 69 | } |
| @@ -1,11 +1,18 @@ | |||
| 1 | // Per-checkout port derivation: every checkout (main repo, agent worktree, | 1 | // Per-checkout port derivation: every checkout (main repo, agent worktree, |
| 2 | // /tmp rig) gets its own deterministic block of e2e ports, so concurrent | 2 | // /tmp rig) hashes its repo root into a PREFERRED block of e2e ports, so |
| 3 | // suites never fight over a shared default. The collision failure mode is | 3 | // concurrent suites normally never fight over a shared default. The hash is |
| 4 | // brutal: vite's --strictPort exit is swallowed by the boot glue and | 4 | // only a preference, not a guarantee (28 checkouts over 400 blocks is |
| 5 | // waitForHttp happily attaches to the OTHER checkout's server, failing dozens | 5 | // birthday-paradox territory) — the globalsetups call `claimPorts`, which |
| 6 | // of scenarios with baffling auth errors instead of one clear bind error. | 6 | // probes the preferred block and walks forward to the next fully-free one, |
| 7 | // Individual E2E_*_PORT env vars still override, and E2E_<TARGET>_URL still | 7 | // then publishes the claimed ports via the E2E_*_PORT env vars so vitest's |
| 8 | // attaches to a running instance. | 8 | // test workers (spawned after globalsetup) compute the same URLs. The |
| 9 | // collision failure mode this kills is brutal: vite's --strictPort exit is | ||
| 10 | // swallowed by the boot glue and waitForHttp happily attaches to the OTHER | ||
| 11 | // checkout's server, failing dozens of scenarios with baffling auth errors | ||
| 12 | // instead of one clear bind error. Individual E2E_*_PORT env vars still | ||
| 13 | // override everything, and E2E_<TARGET>_URL still attaches to a running | ||
| 14 | // instance. | ||
| 15 | import { connect, createServer, type Server } from "node:net"; | ||
| 9 | import { resolve } from "node:path"; | 16 | import { resolve } from "node:path"; |
| 10 | import { fileURLToPath } from "node:url"; | 17 | import { fileURLToPath } from "node:url"; |
| 11 | 18 | ||
| @@ -24,10 +31,117 @@ const hash = (text: string): number => { · 12 unmodified lines | |||
| 24 | }; | 31 | }; |
| 25 | 32 | ||
| 26 | // 400 blocks of 10 ports in 42000..45999: unprivileged, clear of common dev | 33 | // 400 blocks of 10 ports in 42000..45999: unprivileged, clear of common dev |
| 27 | // servers, and below macOS's ephemeral range (49152+). | 34 | // servers, and below macOS's ephemeral range (49152+). Offsets 0-8 are |
| 28 | export const portBlock = 42000 + (hash(repoRoot) % 400) * 10; | 35 | // claimable; offset 9 is the block's lock port (held for the suite's |
| 36 | // lifetime to make claims atomic across concurrent suites). | ||
| 37 | const BLOCK_BASE = 42000; | ||
| 38 | const BLOCK_SIZE = 10; | ||
| 39 | const BLOCK_COUNT = 400; | ||
| 40 | const LOCK_OFFSET = BLOCK_SIZE - 1; | ||
| 41 | export const portBlock = BLOCK_BASE + (hash(repoRoot) % BLOCK_COUNT) * BLOCK_SIZE; | ||
| 29 | 42 | ||
| 30 | export const e2ePort = (envVar: string, offset: number): number => { | 43 | export const e2ePort = (envVar: string, offset: number): number => { |
| 31 | const fromEnv = process.env[envVar]; | 44 | const fromEnv = process.env[envVar]; |
| 32 | return fromEnv ? Number(fromEnv) : portBlock + offset; | 45 | return fromEnv ? Number(fromEnv) : portBlock + offset; |
| 33 | }; | 46 | }; |
| 47 | |||
| 48 | const isListening = (port: number): Promise<boolean> => | ||
| 49 | new Promise((done) => { | ||
| 50 | const socket = connect({ port, host: "127.0.0.1" }); | ||
| 51 | socket.once("connect", () => { | ||
| 52 | socket.destroy(); | ||
| 53 | done(true); | ||
| 54 | }); | ||
| 55 | socket.once("error", () => done(false)); | ||
| 56 | socket.setTimeout(1_000, () => { | ||
| 57 | socket.destroy(); | ||
| 58 | done(false); | ||
| 59 | }); | ||
| 60 | }); | ||
| 61 | |||
| 62 | export interface PortClaim { | ||
| 63 | readonly envVar: string; | ||
| 64 | readonly offset: number; | ||
| 65 | readonly label: string; | ||
| 66 | } | ||
| 67 | |||
| 68 | export interface ClaimedPorts { | ||
| 69 | readonly ports: Record<string, number>; | ||
| 70 | /** Releases the block's lock port — call from the suite teardown. */ | ||
| 71 | readonly release: () => Promise<void>; | ||
| 72 | } | ||
| 73 | |||
| 74 | // Binding is atomic where probing is not: holding the block's lock port for | ||
| 75 | // the suite's lifetime means two suites racing for the same block can never | ||
| 76 | // both win (the second bind EADDRINUSEs and walks on). | ||
| 77 | const tryLockBlock = (block: number): Promise<Server | undefined> => | ||
| 78 | new Promise((done) => { | ||
| 79 | const server = createServer(); | ||
| 80 | server.once("error", () => done(undefined)); | ||
| 81 | server.listen(block + LOCK_OFFSET, "127.0.0.1", () => done(server)); | ||
| 82 | }); | ||
| 83 | |||
| 84 | /** | ||
| 85 | * Claim a free set of ports for a target and publish them via env. Starts at | ||
| 86 | * this checkout's preferred block and walks forward block-by-block until it | ||
| 87 | * can atomically lock a block whose requested ports are all free — so two | ||
| 88 | * checkouts whose hashes collide (or a leaked server squatting the preferred | ||
| 89 | * block) degrade to "boot one block over" instead of attaching to a foreign | ||
| 90 | * server. Explicit env overrides win and are never probed or locked: if you | ||
| 91 | * pin a port and it's busy, vite's --strictPort fails visibly. A target | ||
| 92 | * re-claiming inside an already-locked process (cloud + selfhost projects in | ||
| 93 | * one vitest run) shares the block via disjoint offsets. | ||
| 94 | */ | ||
| 95 | export const claimPorts = async (claims: ReadonlyArray<PortClaim>): Promise<ClaimedPorts> => { | ||
| 96 | const ports: Record<string, number> = {}; | ||
| 97 | const unpinned = claims.filter((claim) => { | ||
| 98 | const pinned = process.env[claim.envVar]; | ||
| 99 | if (pinned) ports[claim.envVar] = Number(pinned); | ||
| 100 | return !pinned; | ||
| 101 | }); | ||
| 102 | if (unpinned.length === 0) return { ports, release: async () => {} }; | ||
| 103 | |||
| 104 | for (let attempt = 0; attempt < BLOCK_COUNT; attempt++) { | ||
| 105 | const block = | ||
| 106 | BLOCK_BASE + ((portBlock - BLOCK_BASE + attempt * BLOCK_SIZE) % (BLOCK_COUNT * BLOCK_SIZE)); | ||
| 107 | // This process may already hold the block's lock (the other target's | ||
| 108 | // globalsetup in the same vitest run); reuse it instead of re-locking. | ||
| 109 | let lock = heldLocks.get(block); | ||
| 110 | if (!lock) { | ||
| 111 | lock = await tryLockBlock(block); | ||
| 112 | if (!lock) { | ||
| 113 | console.warn(`[e2e] port block ${block} is locked by another suite; trying next block`); | ||
| 114 | continue; | ||
| 115 | } | ||
| 116 | heldLocks.set(block, lock); | ||
| 117 | } | ||
| 118 | const busy = await Promise.all(unpinned.map((claim) => isListening(block + claim.offset))); | ||
| 119 | if (busy.some(Boolean)) { | ||
| 120 | const taken = unpinned | ||
| 121 | .filter((_, index) => busy[index]) | ||
| 122 | .map((claim) => `${block + claim.offset} (${claim.label})`); | ||
| 123 | console.warn( | ||
| 124 | `[e2e] port block ${block} has squatters — ${taken.join(", ")}; trying next block`, | ||
| 125 | ); | ||
| 126 | continue; // Keep the lock: a half-busy block is still ours, just unusable now. | ||
| 127 | } | ||
| 128 | for (const claim of unpinned) { | ||
| 129 | const port = block + claim.offset; | ||
| 130 | ports[claim.envVar] = port; | ||
| 131 | // Workers spawn after globalsetup, so they inherit these and agree. | ||
| 132 | process.env[claim.envVar] = String(port); | ||
| 133 | } | ||
| 134 | return { | ||
| 135 | ports, | ||
| 136 | release: async () => { | ||
| 137 | const held = heldLocks.get(block); | ||
| 138 | if (!held) return; | ||
| 139 | heldLocks.delete(block); | ||
| 140 | await new Promise<void>((done) => held.close(() => done())); | ||
| 141 | }, | ||
| 142 | }; | ||
| 143 | } | ||
| 144 | throw new Error("e2e: no free port block found — the 42000-45999 range is exhausted?"); | ||
| 145 | }; | ||
| 146 | |||
| 147 | const heldLocks = new Map<number, Server>(); | ||