From 3d6483d117197e52005084b480a4acfdd48b117e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Sat, 13 Jun 2026 10:09:29 -0700 Subject: [PATCH] e2e viewer: bind any free port instead of crashing on a busy 8901 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewer's static server pinned port 8901 with no error handler, so it hard-crashed on EADDRINUSE — exactly the collision the per-checkout port machinery exists to prevent. Two worktrees, or a leaked previous viewer, would wedge each other. The server now walks forward from 8901 to the next free port and prints the actual bound URL. An explicit PORT still pins strictly and fails loudly if busy (the strictPort rule). The built SPA was already port- and mount-agnostic (relative assets + hash routing), so whatever port the server lands on just works in the browser. --- RUNNING.md | 10 ++++++++-- e2e/scripts/serve.ts | 43 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/RUNNING.md b/RUNNING.md index 82de3e832..4c4b55238 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -47,8 +47,14 @@ working instance of X" — read them before inventing a boot path. scenario source as `test.ts`. - `cd e2e && bun run serve` builds the viewer and serves the scenario × target matrix over HTTP, bound to all interfaces (reachable over the - tailnet). Individual runs are at `#//` hash routes — when - handing results to the user, link those directly, not the bare matrix. + tailnet). It prefers port 8901 but walks forward to the next free port if + that's taken (so concurrent worktrees, or a leaked previous viewer, never + wedge each other) — read the printed `e2e viewer → …` URL for the actual + port. `PORT=…` pins a port explicitly and fails loudly if it's busy. The + built SPA is port- and mount-agnostic (relative assets + hash routing), so + whatever port it lands on just works. Individual runs are at + `#//` hash routes — when handing results to the user, link + those directly, not the bare matrix. - `bun e2e/scripts/pr-media.ts e2e/runs//` converts a run's recording to a gif, uploads it to the `e2e-media` branch, and prints PR-ready markdown. diff --git a/e2e/scripts/serve.ts b/e2e/scripts/serve.ts index 46b78f37f..a1f53eb97 100644 --- a/e2e/scripts/serve.ts +++ b/e2e/scripts/serve.ts @@ -1,15 +1,24 @@ // Static server for runs/ — the review URL. Supports range requests so the // session videos seek/stream, gzips text assets, and marks vite's hashed // /assets/ as immutable so Monaco/React chunks download once, ever. -// `bun e2e/scripts/serve.ts` → http://host:8901 +// `bun e2e/scripts/serve.ts` → prints the bound URL (default port 8901, but +// it walks forward to the next free port if that's taken, so two worktrees — +// or a leaked previous viewer — never wedge each other). `PORT=…` pins a port +// explicitly and fails loudly if it's busy (the strictPort rule from +// src/ports.ts). The SPA itself is port- and mount-agnostic (relative assets + +// hash routing), so any port the server lands on just works in the browser. import { createReadStream, existsSync, statSync } from "node:fs"; import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { extname, join, normalize } from "node:path"; import { fileURLToPath } from "node:url"; import { createGzip } from "node:zlib"; const ROOT = fileURLToPath(new URL("../runs/", import.meta.url)); -const PORT = Number(process.env.PORT ?? 8901); +// Explicit PORT pins (and fails visibly if busy); otherwise 8901 is just a +// starting preference we walk forward from. +const PINNED = process.env.PORT !== undefined; +const PREFERRED = Number(process.env.PORT ?? 8901); const MIME: Record = { ".html": "text/html; charset=utf-8", @@ -27,7 +36,7 @@ const MIME: Record = { const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]); -createServer((req, res) => { +const server = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://x"); let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, ""); if (path === "" || path === ".") path = "index.html"; @@ -78,4 +87,30 @@ createServer((req, res) => { } res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" }); createReadStream(file).pipe(res); -}).listen(PORT, () => console.log(`e2e viewer → http://localhost:${PORT}/`)); +}); + +// Host omitted → bind every interface (reachable over the tailnet). On a busy +// port: a pinned PORT is a hard error (predictable, matches --strictPort); an +// unpinned default walks forward to the next free port instead of crashing. +const MAX_WALK = 50; +const listen = (port: number, attempt = 0): void => { + server.once("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EADDRINUSE") throw err; + if (PINNED) { + console.error(`e2e viewer: PORT=${port} is in use — free it or pick another port.`); + process.exit(1); + } + if (attempt >= MAX_WALK) { + console.error(`e2e viewer: no free port found in ${PREFERRED}..${PREFERRED + MAX_WALK}.`); + process.exit(1); + } + console.warn(`e2e viewer: port ${port} in use, trying ${port + 1}…`); + listen(port + 1, attempt + 1); + }); + server.listen(port, () => { + const actual = (server.address() as AddressInfo).port; + console.log(`e2e viewer → http://localhost:${actual}/`); + }); +}; + +listen(PREFERRED);