| 1 | // Ports for the servers end-to-end tests start (`wrangler dev` and its inspector, the sandbox pool, ...). |
| 2 | // |
| 3 | // Asking the kernel for a free port (listen on 0, close, hand the number on) races: until the server |
| 4 | // binds it, the port is free again, and anything that binds port 0 meanwhile can be given the same |
| 5 | // number: workerd's own sockets in another `wrangler dev` or `wrangler d1 execute`, a docs server, an |
| 6 | // outgoing connection. Then wrangler dies with "bind(): Address already in use". So ports here come |
| 7 | // from below the kernel's ephemeral range, which port 0 binds and outgoing connections never use, and |
| 8 | // each is reserved with a lock file naming this process, so test files running side by side |
| 9 | // (--test-concurrency) never pick the same one. A process keeps its ports until it exits; a lock left |
| 10 | // by a process that's gone is taken over. |
| 11 | import { linkSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; |
| 12 | import { createServer } from "node:net"; |
| 13 | import { tmpdir } from "node:os"; |
| 14 | import { join } from "node:path"; |
| 15 | |
| 16 | const LOCKS = join(tmpdir(), "diffinery-e2e-ports"); |
| 17 | const [FIRST, LAST] = range(); |
| 18 | const held = new Set(); |
| 19 | let next = FIRST + Math.floor(Math.random() * (LAST - FIRST + 1)); |
| 20 | |
| 21 | process.on("exit", () => { |
| 22 | for (const port of held) { |
| 23 | try { |
| 24 | unlinkSync(join(LOCKS, String(port))); |
| 25 | } catch {} |
| 26 | } |
| 27 | }); |
| 28 | |
| 29 | /** A port on 127.0.0.1 that's free now and that no other caller, in this process or another, will be given. */ |
| 30 | export async function freePort() { |
| 31 | mkdirSync(LOCKS, { recursive: true }); |
| 32 | for (let tried = 0; tried <= LAST - FIRST; tried++) { |
| 33 | const port = next; |
| 34 | next = next === LAST ? FIRST : next + 1; |
| 35 | if (held.has(port) || !reserve(port)) continue; |
| 36 | if (await bindable(port)) { |
| 37 | held.add(port); |
| 38 | return port; |
| 39 | } |
| 40 | // Something outside the tests is listening there; leave the port to it. |
| 41 | unlinkSync(join(LOCKS, String(port))); |
| 42 | } |
| 43 | throw new Error(`no free port left in ${FIRST}-${LAST}`); |
| 44 | } |
| 45 | |
| 46 | /** Ports from 20000 up to the kernel's ephemeral range (32768 to 60999 on Linux by default; macOS's starts at 49152). */ |
| 47 | function range() { |
| 48 | let [low, high] = [32768, 60999]; |
| 49 | try { |
| 50 | [low, high] = readFileSync("/proc/sys/net/ipv4/ip_local_port_range", "utf8").trim().split(/\s+/).map(Number); |
| 51 | } catch {} |
| 52 | if (low >= 21000) return [20000, low - 1]; |
| 53 | // An ephemeral range starting this low leaves too few ports under it; use the ports above it. |
| 54 | if (high <= 64000) return [high + 1, 65535]; |
| 55 | throw new Error(`the ephemeral port range (${low}-${high}) leaves no room for test servers`); |
| 56 | } |
| 57 | |
| 58 | /** Takes the lock file for `port`. It's written whole under a name of its own, then linked into place, so it never exists half written. */ |
| 59 | function reserve(port) { |
| 60 | const file = join(LOCKS, String(port)); |
| 61 | const mine = `${file}.${process.pid}`; |
| 62 | writeFileSync(mine, String(process.pid)); |
| 63 | try { |
| 64 | for (let attempt = 0; attempt < 2; attempt++) { |
| 65 | try { |
| 66 | linkSync(mine, file); |
| 67 | return true; |
| 68 | } catch (e) { |
| 69 | if (e.code !== "EEXIST") throw e; |
| 70 | } |
| 71 | if (!takeOver(file)) return false; |
| 72 | } |
| 73 | return false; |
| 74 | } finally { |
| 75 | unlinkSync(mine); |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Moves a lock whose process is gone (killed before it could clean up) out of the way. It's renamed |
| 81 | * before it's read again, so of two processes taking over the same lock only one moves it, and a live |
| 82 | * lock moved by mistake goes back. |
| 83 | */ |
| 84 | function takeOver(file) { |
| 85 | if (alive(pidIn(file))) return false; |
| 86 | const moved = `${file}.stale-${process.pid}`; |
| 87 | try { |
| 88 | renameSync(file, moved); |
| 89 | } catch { |
| 90 | return false; |
| 91 | } |
| 92 | const dead = !alive(pidIn(moved)); |
| 93 | if (!dead) { |
| 94 | try { |
| 95 | linkSync(moved, file); |
| 96 | } catch {} |
| 97 | } |
| 98 | unlinkSync(moved); |
| 99 | return dead; |
| 100 | } |
| 101 | |
| 102 | function pidIn(file) { |
| 103 | try { |
| 104 | return Number(readFileSync(file, "utf8")); |
| 105 | } catch { |
| 106 | return 0; |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | function alive(pid) { |
| 111 | if (!pid) return false; |
| 112 | try { |
| 113 | process.kill(pid, 0); |
| 114 | return true; |
| 115 | } catch (e) { |
| 116 | return e.code === "EPERM"; |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | const bindable = (port) => |
| 121 | new Promise((res) => { |
| 122 | const s = createServer(); |
| 123 | s.once("error", () => res(false)); |
| 124 | s.listen(port, "127.0.0.1", () => s.close(() => res(true))); |
| 125 | }); |