import * as fs from 'fs'; import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; /** * Reserves a port across parallel processes on the same host via an atomic * lock file. Avoids the TOCTOU race of probing port 0 and then binding it * seconds later — another parallel process could be handed the same port in * between. * * Starts at 6100 (outside the framework-default zone of 3000/4200/5173/8080/etc.) * so reserved ports never collide with parallel tests that generate apps * without explicitly pinning the dev-server port. * * Caveat: this reserves the port *number* against other `reservePort()` * callers; it does not hold the OS socket open. A consumer that binds the * port much later (e.g. after a long webpack compile) is still racing * anything that does not participate in this scheme. */ const LOCK_DIR = path.join(os.tmpdir(), 'nx-e2e-port-locks'); fs.mkdirSync(LOCK_DIR, { recursive: true }); const RANGE_FLOOR = 6100; const RANGE_CEILING = 65000; // Spread of the randomised scan origin (see reservePort). Wide enough that // reservations scatter thinly across nearly the whole range, so a squatter // (a leaked dev server, or anything binding a port mid-compile) is unlikely // to land on the specific port a test reserved. const SCAN_SPREAD = 55000; // A lock older than this is treated as abandoned even when its PID still // resolves (the PID may have been recycled). Comfortably above the longest // e2e test timeout so a legitimately long-held port is never stolen. const STALE_LOCK_MS = 60 * 60 * 1000; // Whether to reclaim abandoned lock files. In CI each agent is an ephemeral // container, so stale locks cannot carry across runs; combined with the wide // scan spread, the few a timeout-killed task may leave are immaterial. An // existing lock is simply treated as taken. Reclamation only earns its keep // on a developer's machine, where /tmp persists across runs. const RECLAIM_ABANDONED_LOCKS = !process.env.CI; // Lock files held by THIS process. A single exit handler frees them all, // rather than registering one listener per reservePort() call. const heldLocks = new Set(); process.once('exit', () => { for (const lock of heldLocks) { try { fs.unlinkSync(lock); } catch {} } }); function lockPath(port: number): string { return path.join(LOCK_DIR, `${port}.lock`); } /** * A lock is abandoned if its owning process is gone, or it is older than * STALE_LOCK_MS. A SIGKILLed e2e process never runs its exit handler, so * without this its locks would block those ports for every later run. */ function isAbandonedLock(lock: string): boolean { let stat: fs.Stats; let pid: number; try { stat = fs.statSync(lock); pid = parseInt(fs.readFileSync(lock, 'utf8').trim(), 10); } catch { return true; // vanished between checks — treat as free } if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) return true; if (!pid) return false; try { process.kill(pid, 0); // signal 0: existence check, does not kill return false; // owner still alive } catch (err) { // ESRCH: no such process → abandoned. EPERM: exists, not ours → alive. return (err as NodeJS.ErrnoException).code === 'ESRCH'; } } /** Atomically claim the lock for `port`; returns true on success. */ function claimLock(port: number): boolean { const lock = lockPath(port); for (let attempt = 0; attempt < 2; attempt++) { try { fs.writeFileSync(lock, String(process.pid), { flag: 'wx' }); heldLocks.add(lock); return true; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; } // Lock exists. Outside CI, reclaim it once if abandoned then retry the // claim; in CI an existing lock is simply treated as taken. if (!RECLAIM_ABANDONED_LOCKS || !isAbandonedLock(lock)) return false; try { fs.unlinkSync(lock); } catch {} } return false; } function releaseLock(port: number): void { const lock = lockPath(port); heldLocks.delete(lock); try { fs.unlinkSync(lock); } catch {} } export async function reservePort(start = RANGE_FLOOR): Promise { // Randomise the scan origin so parallel e2e processes spread across the // range instead of all converging on — and fighting over — the low ports. // The scan wraps around so the whole [start, RANGE_CEILING) range is covered // even when the origin lands high; otherwise the ports below it are skipped // and we could throw spuriously while ports are still free. const span = RANGE_CEILING - start; const origin = Math.floor(Math.random() * Math.min(SCAN_SPREAD, span)); for (let i = 0; i < span; i++) { const port = start + ((origin + i) % span); if (!claimLock(port)) continue; // Lock claimed; verify the OS port is actually free. Another e2e test on // the same agent may be using it via a generator default (i.e. without // participating in the lock scheme), so an exclusive lock is not enough. if (await isPortAvailable(port)) { return port; } releaseLock(port); } throw new Error('No available ports'); } /** * Reserves `count` *consecutive* ports and returns them in ascending order. * * The contiguity matters: module federation e2e tests pass `ports[0]` as the * host's `--devServerPort`, and the generator then wires the host to its * remotes at `ports[0] + 1`, `+ 2`, ... — so the reserved ports must be a * contiguous run for the host and remotes to line up. */ export async function reservePorts(count: number): Promise { if (count <= 1) { return count === 1 ? [await reservePort()] : []; } // Randomise the scan origin (same rationale as reservePort) and wrap around // so every valid window start is tried, then return the first window of // `count` consecutive ports that are all claimable and actually free. const lastStart = RANGE_CEILING - count; const numStarts = lastStart - RANGE_FLOOR + 1; const origin = Math.floor(Math.random() * Math.min(SCAN_SPREAD, numStarts)); for (let i = 0; i < numStarts; i++) { const start = RANGE_FLOOR + ((origin + i) % numStarts); const claimed: number[] = []; for (let port = start; port < start + count; port++) { if (!claimLock(port)) break; if (!(await isPortAvailable(port))) { releaseLock(port); break; } claimed.push(port); } if (claimed.length === count) { return claimed; } // Window failed partway — release whatever we managed to claim in it. for (const port of claimed) { releaseLock(port); } } throw new Error(`No available run of ${count} consecutive ports`); } /** * @deprecated Use {@link reservePort} — probing the OS for port 0 opens a * TOCTOU race across parallel e2e processes. Kept for backwards compatibility. */ export async function getAvailablePort(): Promise { return new Promise((resolve, reject) => { const server = net.createServer(); server.unref(); server.on('error', reject); server.listen(0, () => { const addressInfo = server.address(); if (!addressInfo || typeof addressInfo === 'string') { reject(new Error('Failed to get server address')); return; } const port = addressInfo.port; server.close(() => { resolve(port); }); }); }); } /** * @deprecated Use {@link reservePorts}. */ export async function getAvailablePorts(count: number): Promise { const ports: number[] = []; for (let i = 0; i < count; i++) { const port = await getAvailablePort(); ports.push(port); } return ports; } /** * Checks if a specific port is available * * @param port - Port number to check * @returns Promise - True if port is available, false otherwise */ export async function isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net.createServer(); server.once('error', (err: NodeJS.ErrnoException) => { resolve(false); }); server.once('listening', () => { server.close(); resolve(true); }); server.listen(port); }); }