History

e2e viewer: bind any free port instead of crashing on a busy 8901 (#998)

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.

Rhys Sullivan committed 5 months ago

Commit 27da9401 parent affc2892 files · +47 −6Browse files

Unified · Split

2 files
  1. RUNNING.md +8 −2
  2. e2e/scripts/serve.ts +39 −4
RUNNING.md+8−2
@@ -47,8 +47,14 @@ working instance of X" — read them before inventing a boot path. · 46 unmodified lines
4747 scenario source as `test.ts`.
4848 - `cd e2e && bun run serve` builds the viewer and serves the scenario ×
4949 target matrix over HTTP, bound to all interfaces (reachable over the
50− tailnet). Individual runs are at `#/<target>/<slug>` hash routes — when
51− handing results to the user, link those directly, not the bare matrix.
50+ tailnet). It prefers port 8901 but walks forward to the next free port if
51+ that's taken (so concurrent worktrees, or a leaked previous viewer, never
52+ wedge each other) — read the printed `e2e viewer → …` URL for the actual
53+ port. `PORT=…` pins a port explicitly and fails loudly if it's busy. The
54+ built SPA is port- and mount-agnostic (relative assets + hash routing), so
55+ whatever port it lands on just works. Individual runs are at
56+ `#/<target>/<slug>` hash routes — when handing results to the user, link
57+ those directly, not the bare matrix.
5258 - `bun e2e/scripts/pr-media.ts e2e/runs/<target>/<slug>` converts a run's
5359 recording to a gif, uploads it to the `e2e-media` branch, and prints
5460 PR-ready markdown.
e2e/scripts/serve.ts+39−4
@@ -1,15 +1,24 @@
11 // Static server for runs/ — the review URL. Supports range requests so the
22 // session videos seek/stream, gzips text assets, and marks vite's hashed
33 // /assets/ as immutable so Monaco/React chunks download once, ever.
4−// `bun e2e/scripts/serve.ts` → http://host:8901
4+// `bun e2e/scripts/serve.ts` → prints the bound URL (default port 8901, but
5+// it walks forward to the next free port if that's taken, so two worktrees —
6+// or a leaked previous viewer — never wedge each other). `PORT=…` pins a port
7+// explicitly and fails loudly if it's busy (the strictPort rule from
8+// src/ports.ts). The SPA itself is port- and mount-agnostic (relative assets +
9+// hash routing), so any port the server lands on just works in the browser.
510 import { createReadStream, existsSync, statSync } from "node:fs";
611 import { createServer } from "node:http";
12+import type { AddressInfo } from "node:net";
713 import { extname, join, normalize } from "node:path";
814 import { fileURLToPath } from "node:url";
915 import { createGzip } from "node:zlib";
1016
1117 const ROOT = fileURLToPath(new URL("../runs/", import.meta.url));
12−const PORT = Number(process.env.PORT ?? 8901);
18+// Explicit PORT pins (and fails visibly if busy); otherwise 8901 is just a
19+// starting preference we walk forward from.
20+const PINNED = process.env.PORT !== undefined;
21+const PREFERRED = Number(process.env.PORT ?? 8901);
1322
1423 const MIME: Record<string, string> = {
1524 ".html": "text/html; charset=utf-8",
@@ -27,7 +36,7 @@ const MIME: Record<string, string> = { · 11 unmodified lines
2736
2837 const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]);
2938
30−createServer((req, res) => {
39+const server = createServer((req, res) => {
3140 const url = new URL(req.url ?? "/", "http://x");
3241 let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
3342 if (path === "" || path === ".") path = "index.html";
@@ -78,4 +87,30 @@ createServer((req, res) => { · 44 unmodified lines
7887 }
7988 res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" });
8089 createReadStream(file).pipe(res);
81−}).listen(PORT, () => console.log(`e2e viewer → http://localhost:${PORT}/`));
90+});
91+
92+// Host omitted → bind every interface (reachable over the tailnet). On a busy
93+// port: a pinned PORT is a hard error (predictable, matches --strictPort); an
94+// unpinned default walks forward to the next free port instead of crashing.
95+const MAX_WALK = 50;
96+const listen = (port: number, attempt = 0): void => {
97+ server.once("error", (err: NodeJS.ErrnoException) => {
98+ if (err.code !== "EADDRINUSE") throw err;
99+ if (PINNED) {
100+ console.error(`e2e viewer: PORT=${port} is in use — free it or pick another port.`);
101+ process.exit(1);
102+ }
103+ if (attempt >= MAX_WALK) {
104+ console.error(`e2e viewer: no free port found in ${PREFERRED}..${PREFERRED + MAX_WALK}.`);
105+ process.exit(1);
106+ }
107+ console.warn(`e2e viewer: port ${port} in use, trying ${port + 1}…`);
108+ listen(port + 1, attempt + 1);
109+ });
110+ server.listen(port, () => {
111+ const actual = (server.address() as AddressInfo).port;
112+ console.log(`e2e viewer → http://localhost:${actual}/`);
113+ });
114+};
115+
116+listen(PREFERRED);