History

Make e2e port claiming atomic: lock port per block, walk past collisions

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.

Rhys Sullivan committed 5 months ago

Commit 3f3ed8f1 parent acb96656 files · +183 −86Browse files

Unified · Split

6 files
  1. e2e/AGENTS.md +7 −4
  2. e2e/scripts/ports.ts +6 −3
  3. e2e/setup/boot.ts +1 −39
  4. e2e/setup/cloud.globalsetup.ts +26 −24
  5. e2e/setup/selfhost.globalsetup.ts +20 −7
  6. e2e/src/ports.ts +123 −9
e2e/AGENTS.md+7−4
@@ -89,10 +89,13 @@ E2E_CLOUD_URL=http://127.0.0.1:<port> ../node_modules/.bin/vitest run --project · 88 unmodified lines
89E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file>89E2E_SELFHOST_URL=http://localhost:<port> ../node_modules/.bin/vitest run --project selfhost <file>
90```90```
91 91
92Ports are derived per checkout (hash of the repo root — see `src/ports.ts`),92Ports are claimed at boot (see `src/ports.ts`): each checkout hashes its repo
93so suites in different worktrees never fight. If a port is somehow taken, the93root to a preferred block, atomically locks it (a held lock port makes races
94boot fails fast naming the squatting process; kill it or override with94impossible), and walks to the next free block if it's locked or squatted — so
95`E2E_CLOUD_PORT`-style env vars.95concurrent suites in different worktrees can never collide or attach to each
96other's servers. `bun run ports` shows the preferred block; the boot log says
97if a suite moved. `E2E_*_PORT` env vars pin ports explicitly (no probing) and
98`E2E_<TARGET>_URL` attaches to a running instance.
96 99
97Each run writes `runs/<target>/<slug>/result.json` plus any browser artifacts100Each 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 ×
e2e/scripts/ports.ts+6−3
@@ -1,5 +1,8 @@
1// Print this checkout's derived e2e ports (see src/ports.ts) so an agent or1// 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'`.
3import {6import {
4 AUTUMN_EMULATOR_PORT,7 AUTUMN_EMULATOR_PORT,
5 CLOUD_DB_PORT,8 CLOUD_DB_PORT,
@@ -9,7 +12,7 @@ import { · 3 unmodified lines
9import { SELFHOST_PORT } from "../targets/selfhost";12import { SELFHOST_PORT } from "../targets/selfhost";
10import { repoRoot } from "../src/ports";13import { repoRoot } from "../src/ports";
11 14
12console.log(`e2e ports for ${repoRoot}`);15console.log(`preferred e2e ports for ${repoRoot}`);
13console.log(` cloud http://127.0.0.1:${CLOUD_PORT}`);16console.log(` cloud http://127.0.0.1:${CLOUD_PORT}`);
14console.log(` cloud dev-db ${CLOUD_DB_PORT}`);17console.log(` cloud dev-db ${CLOUD_DB_PORT}`);
15console.log(` workos emulator ${WORKOS_EMULATOR_PORT}`);18console.log(` workos emulator ${WORKOS_EMULATOR_PORT}`);
e2e/setup/boot.ts+1−39
@@ -2,8 +2,7 @@ · 1 unmodified line
2// server, wait until it answers HTTP, and hand vitest a teardown. The apps own2// 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 process3// 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.
5import { execFileSync, spawn, type ChildProcess } from "node:child_process";5import { spawn, type ChildProcess } from "node:child_process";
6import { connect } from "node:net";
7 6
8export interface BootedProcesses {7export 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.
80export 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
113export const waitForHttp = async (75export 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 } = {},
e2e/setup/cloud.globalsetup.ts+26−24
@@ -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).
11import { createEmulator } from "@executor-js/emulate";11import { createEmulator } from "@executor-js/emulate";
12 12
13import { bootProcesses, ensurePortsFree, waitForHttp } from "./boot";13import { claimPorts } from "../src/ports";
14import {14import { E2E_COOKIE_PASSWORD, E2E_WORKOS_CLIENT_ID } from "../targets/cloud";
15 CLOUD_BASE_URL,15import { 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
24const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));17const 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. The39 // 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 persisted40 // 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 the46 // 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}
e2e/setup/selfhost.globalsetup.ts+20−7
@@ -6,8 +6,9 @@ import { rmSync } from "node:fs"; · 5 unmodified lines
6import { resolve } from "node:path";6import { resolve } from "node:path";
7import { fileURLToPath } from "node:url";7import { fileURLToPath } from "node:url";
8 8
9import { bootProcesses, ensurePortsFree, waitForHttp } from "./boot";9import { claimPorts } from "../src/ports";
10import { SELFHOST_ADMIN, SELFHOST_BASE_URL, SELFHOST_PORT } from "../targets/selfhost";10import { SELFHOST_ADMIN } from "../targets/selfhost";
11import { bootProcesses, waitForHttp } from "./boot";
11 12
12const selfhostDir = fileURLToPath(new URL("../../apps/host-selfhost/", import.meta.url));13const 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 from31 // 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 the48 // The harness boots loopback MCP/OAuth test servers and points the
40 // instance at them; the hosted SSRF guard would otherwise block49 // 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}
e2e/src/ports.ts+123−9
@@ -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 concurrent2// /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 is3// concurrent suites normally never fight over a shared default. The hash is
4// brutal: vite's --strictPort exit is swallowed by the boot glue and4// only a preference, not a guarantee (28 checkouts over 400 blocks is
5// waitForHttp happily attaches to the OTHER checkout's server, failing dozens5// 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 still7// 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.
15import { connect, createServer, type Server } from "node:net";
9import { resolve } from "node:path";16import { resolve } from "node:path";
10import { fileURLToPath } from "node:url";17import { 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 dev33// 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
28export 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).
37const BLOCK_BASE = 42000;
38const BLOCK_SIZE = 10;
39const BLOCK_COUNT = 400;
40const LOCK_OFFSET = BLOCK_SIZE - 1;
41export const portBlock = BLOCK_BASE + (hash(repoRoot) % BLOCK_COUNT) * BLOCK_SIZE;
29 42
30export const e2ePort = (envVar: string, offset: number): number => {43export 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
48const 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
62export interface PortClaim {
63 readonly envVar: string;
64 readonly offset: number;
65 readonly label: string;
66}
67
68export 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).
77const 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 */
95export 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
147const heldLocks = new Map<number, Server>();

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t