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
47 scenario source as `test.ts`.47 scenario source as `test.ts`.
48- `cd e2e && bun run serve` builds the viewer and serves the scenario ×48- `cd e2e && bun run serve` builds the viewer and serves the scenario ×
49 target matrix over HTTP, bound to all interfaces (reachable over the49 target matrix over HTTP, bound to all interfaces (reachable over the
50 tailnet). Individual runs are at `#/<target>/<slug>` hash routes — when50 tailnet). It prefers port 8901 but walks forward to the next free port if
51 handing results to the user, link those directly, not the bare matrix.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.
52- `bun e2e/scripts/pr-media.ts e2e/runs/<target>/<slug>` converts a run's58- `bun e2e/scripts/pr-media.ts e2e/runs/<target>/<slug>` converts a run's
53 recording to a gif, uploads it to the `e2e-media` branch, and prints59 recording to a gif, uploads it to the `e2e-media` branch, and prints
54 PR-ready markdown.60 PR-ready markdown.
e2e/scripts/serve.ts+39−4
@@ -1,15 +1,24 @@
1// Static server for runs/ — the review URL. Supports range requests so the1// Static server for runs/ — the review URL. Supports range requests so the
2// session videos seek/stream, gzips text assets, and marks vite's hashed2// session videos seek/stream, gzips text assets, and marks vite's hashed
3// /assets/ as immutable so Monaco/React chunks download once, ever.3// /assets/ as immutable so Monaco/React chunks download once, ever.
4// `bun e2e/scripts/serve.ts` → http://host:89014// `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.
5import { createReadStream, existsSync, statSync } from "node:fs";10import { createReadStream, existsSync, statSync } from "node:fs";
6import { createServer } from "node:http";11import { createServer } from "node:http";
12import type { AddressInfo } from "node:net";
7import { extname, join, normalize } from "node:path";13import { extname, join, normalize } from "node:path";
8import { fileURLToPath } from "node:url";14import { fileURLToPath } from "node:url";
9import { createGzip } from "node:zlib";15import { createGzip } from "node:zlib";
10 16
11const ROOT = fileURLToPath(new URL("../runs/", import.meta.url));17const ROOT = fileURLToPath(new URL("../runs/", import.meta.url));
12const 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.
20const PINNED = process.env.PORT !== undefined;
21const PREFERRED = Number(process.env.PORT ?? 8901);
13 22
14const MIME: Record<string, string> = {23const MIME: Record<string, string> = {
15 ".html": "text/html; charset=utf-8",24 ".html": "text/html; charset=utf-8",
@@ -27,7 +36,7 @@ const MIME: Record<string, string> = { · 11 unmodified lines
27 36
28const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]);37const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]);
29 38
30createServer((req, res) => {39const server = createServer((req, res) => {
31 const url = new URL(req.url ?? "/", "http://x");40 const url = new URL(req.url ?? "/", "http://x");
32 let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");41 let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, "");
33 if (path === "" || path === ".") path = "index.html";42 if (path === "" || path === ".") path = "index.html";
@@ -78,4 +87,30 @@ createServer((req, res) => { · 44 unmodified lines
78 }87 }
79 res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" });88 res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" });
80 createReadStream(file).pipe(res);89 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.
95const MAX_WALK = 50;
96const 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
116listen(PREFERRED);