|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #12620 — when the dev port search runs out of ports, `os serve` SAYS SO, |
| 5 | + * carrying the message the search itself threw. |
| 6 | + * |
| 7 | + * ## The defect was a discarded sentence, not a wrong behaviour |
| 8 | + * |
| 9 | + * `getAvailablePort` throws `Could not find an available port starting from |
| 10 | + * <n>` — a sentence that names the problem exactly. The caller's `catch` used |
| 11 | + * to read `// Ignore — fall through and try the requested port` and did exactly |
| 12 | + * that: it dropped the sentence and bound `requestedPort` anyway, the port the |
| 13 | + * search had just proven was taken. The boot then died on the kernel's raw |
| 14 | + * `EADDRINUSE` with nothing anywhere explaining it. |
| 15 | + * |
| 16 | + * ⛔ The fallthrough itself is NOT changed and this file does not argue for |
| 17 | + * changing it. Whether an exhausted search should refuse instead is #11113's |
| 18 | + * production/development policy split and belongs to that card. |
| 19 | + * |
| 20 | + * ## ⛔ Why this file binds no sockets |
| 21 | + * |
| 22 | + * The obvious test — hold 101 real ports and boot — is the wrong test: slow, |
| 23 | + * flaky, and hostile to a shared many-agent container whose ephemeral range is |
| 24 | + * already crowded. #12441 measured that contention taking a full CLI suite red |
| 25 | + * (`1 failed | 2101 passed`, clean on an isolated re-run), which is why |
| 26 | + * `serve-port-bind-probe.test.ts` exists at all. So the SEAM is driven instead: |
| 27 | + * `getAvailablePort` now takes its port probe as a parameter, defaulting to the |
| 28 | + * real one, and every runtime assertion below drives that parameter. Zero |
| 29 | + * sockets, zero spawns. |
| 30 | + * |
| 31 | + * ⚠️ That parameter is the change that made this card testable at all. Without |
| 32 | + * it the exhausted path is reachable only by exhausting a real range, which is |
| 33 | + * the test this card's ruling forbids. |
| 34 | + * |
| 35 | + * ## THE THREE-WAY DISCRIMINATION (the anti-vacuity requirement) |
| 36 | + * |
| 37 | + * A test asserting only "the notice appears when the search is exhausted" |
| 38 | + * passes just as green against code that prints it unconditionally. Three arms, |
| 39 | + * each pinned at the level it is actually decidable at: |
| 40 | + * |
| 41 | + * 1. **exhausted search** → the notice appears, carrying the thrown message. |
| 42 | + * RUNTIME, at the seam. |
| 43 | + * 2. **ordinary auto-shift** (`port !== requestedPort`, search SUCCEEDED) → |
| 44 | + * this notice does not appear; #12543's drift notice does. RUNTIME, at the |
| 45 | + * seam — and exactly, not by proxy: a successful search RETURNS, so the |
| 46 | + * `catch` never runs and the notice is unreachable by construction. The |
| 47 | + * drift half of that arm has its own landed runtime pin in |
| 48 | + * `test/serve-port-drift-notice.e2e.test.ts`, which spawns a real boot |
| 49 | + * against a real HTTP neighbour; it is not re-spawned here. |
| 50 | + * 3. **production branch** (`portAutoShiftAllowed` false) → neither notice; |
| 51 | + * the existing `Port … is already in use` line fires. STRUCTURAL, against |
| 52 | + * the live source: this notice's only call site is lexically inside the |
| 53 | + * `if (portAutoShiftAllowed)` block and the production line is in the |
| 54 | + * `else if`, so a production boot cannot reach it. The production line's |
| 55 | + * own runtime pin is landed in |
| 56 | + * `test/serve-node-env-production-default.e2e.test.ts`. |
| 57 | + * |
| 58 | + * ⚠️ Arm 3 is deliberately not a fourth spawner file. This package's own |
| 59 | + * `vitest.config.ts` records that its 39 spawner files carry 89.4% of its test |
| 60 | + * wall at a ~5.5-6.0s floor each, and arms 2 and 3 already hold the landed |
| 61 | + * runtime pins named above. What was missing from them was the discrimination |
| 62 | + * against THIS notice, and that is what the source-anchored arm supplies. |
| 63 | + * |
| 64 | + * ## The mutual-exclusion arm, and why it is not decoration |
| 65 | + * |
| 66 | + * The three notices are also asserted to be pairwise non-matching. One of those |
| 67 | + * pairs is load-bearing beyond legibility: `PORT_TAKEN_PATTERNS` in |
| 68 | + * `test/helpers/serve-process.ts` turns `/Port (\d+) is already in use/` and |
| 69 | + * `/EADDRINUSE[^\n]*?:(\d+)/` into a "port contention" verdict for every |
| 70 | + * spawner in this package. This notice names EADDRINUSE in prose on purpose — |
| 71 | + * that is what the operator is about to see — so if it ever grew a `:<digits>` |
| 72 | + * after that word, an exhausted-search boot would be mis-reported as a lost |
| 73 | + * port race by an unrelated file. |
| 74 | + */ |
| 75 | + |
| 76 | +import { describe, it, expect } from 'vitest'; |
| 77 | +import { readFileSync } from 'node:fs'; |
| 78 | +import { resolve } from 'node:path'; |
| 79 | +import { fileURLToPath } from 'node:url'; |
| 80 | + |
| 81 | +import { formatExhaustedPortSearchNotice, getAvailablePort } from './serve.js'; |
| 82 | + |
| 83 | +/** Seeded from `import.meta.url`, the spelling `check:cross-package-test-inputs` recognises. */ |
| 84 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 85 | + |
| 86 | +/** The live source, for the arms decided lexically rather than at runtime. */ |
| 87 | +const SERVE_SOURCE = readFileSync(resolve(HERE, 'serve.ts'), 'utf8'); |
| 88 | + |
| 89 | +/** |
| 90 | + * Strip SGR escapes. `chalk` is inert under a non-TTY runner but not |
| 91 | + * guaranteed to be, and an assertion that only passes when colour happens to be |
| 92 | + * off is a flake waiting for the first person who runs this attached. |
| 93 | + */ |
| 94 | +const plain = (text: string): string => text.replace(/\u001B\[[0-9;]*m/g, ''); |
| 95 | + |
| 96 | +/** An arbitrary start port. Nothing is bound, so it only has to be a number. */ |
| 97 | +const START = 34_500; |
| 98 | + |
| 99 | +/** A probe that never finds a free port — the exhausted path, without a socket. */ |
| 100 | +function alwaysBusy(): { probe: (port: number) => Promise<boolean>; probed: number[] } { |
| 101 | + const probed: number[] = []; |
| 102 | + return { |
| 103 | + probed, |
| 104 | + probe: async (port: number) => { |
| 105 | + probed.push(port); |
| 106 | + return false; |
| 107 | + }, |
| 108 | + }; |
| 109 | +} |
| 110 | + |
| 111 | +/** #12543's notice, as its landed text. Held against the source below so it cannot go stale. */ |
| 112 | +const DRIFT_NOTICE = /Port (\d+) is in use — serving on (\d+) instead\./; |
| 113 | +/** #11113's production refusal — and the first of `PORT_TAKEN_PATTERNS`. */ |
| 114 | +const PRODUCTION_REFUSAL = /Port (\d+) is already in use/; |
| 115 | + |
| 116 | +describe('#12620: an exhausted port search is announced, in the words the search threw', () => { |
| 117 | + it('ARM 1 — the notice CARRIES the thrown message verbatim, as its headline', async () => { |
| 118 | + const { probe } = alwaysBusy(); |
| 119 | + |
| 120 | + const thrown = await getAvailablePort(START, probe).then( |
| 121 | + (port) => { |
| 122 | + throw new Error(`the search returned ${port} against a probe that never says yes`); |
| 123 | + }, |
| 124 | + (err: unknown) => err, |
| 125 | + ); |
| 126 | + |
| 127 | + expect(thrown, 'the exhausted search no longer throws').toBeInstanceOf(Error); |
| 128 | + const message = (thrown as Error).message; |
| 129 | + |
| 130 | + // Guard the guard: an empty message would make the assertion below |
| 131 | + // vacuously true. |
| 132 | + expect(message, 'the thrown message no longer names the start port').toContain(String(START)); |
| 133 | + |
| 134 | + const notice = plain(formatExhaustedPortSearchNotice(START, thrown)); |
| 135 | + |
| 136 | + // ⭐ The pin. Not "says something similar" — the error's own text, and as |
| 137 | + // the headline, so a reader meets it first rather than digging for it. |
| 138 | + expect( |
| 139 | + notice.split('\n').find((line) => line.includes('⚠')), |
| 140 | + 'the notice paraphrases the thrown message instead of carrying it', |
| 141 | + ).toBe(` ⚠ ${message}`); |
| 142 | + }); |
| 143 | + |
| 144 | + it('ARM 1 — the width it reports is the width it actually probed, endpoints included', async () => { |
| 145 | + const { probe, probed } = alwaysBusy(); |
| 146 | + const thrown = await getAvailablePort(START, probe).catch((err: unknown) => err); |
| 147 | + |
| 148 | + // ⚠️ The search is a plain contiguous `port++` walk. A neighbouring card |
| 149 | + // described it as skipping ports; it does not, and a notice written from |
| 150 | + // that reading would name a range it never looked at. |
| 151 | + expect( |
| 152 | + probed.every((port, i) => port === START + i), |
| 153 | + `the search is no longer contiguous: ${probed.slice(0, 5).join(',')}…`, |
| 154 | + ).toBe(true); |
| 155 | + expect(probed[0], 'the walk no longer starts where it was asked to').toBe(START); |
| 156 | + |
| 157 | + // ⭐ The anti-off-by-one arm, and the reason it compares against `probed` |
| 158 | + // rather than against a literal: an inaccurate number inside a diagnostic |
| 159 | + // that exists to be accurate would be this card's own defect. BOTH terms |
| 160 | + // are measured here — how many ports the walk touched, and the last one it |
| 161 | + // reached. |
| 162 | + const notice = plain(formatExhaustedPortSearchNotice(START, thrown)); |
| 163 | + expect(notice).toContain(`${probed.length} ports (${START}–${probed[probed.length - 1]})`); |
| 164 | + }); |
| 165 | + |
| 166 | + it('makes NO span claim for a rejection that is not an exhausted walk', async () => { |
| 167 | + // ⚠️ The `catch` in serve.ts catches every rejection, not only exhaustion. |
| 168 | + // `isPortAvailable` rejects synchronously with ERR_SOCKET_BAD_PORT for any |
| 169 | + // port outside 0–65535 — reachable when the walk crosses the ceiling, and |
| 170 | + // when `--port` text parses to NaN. Measured, not supposed: `net`'s |
| 171 | + // `listen()` throws for both, inside the probe's promise executor. |
| 172 | + // |
| 173 | + // ⭐ On those paths nothing was probed, so a body claiming a range would |
| 174 | + // print `NaN–NaN` and assert a search that never ran — an inaccurate |
| 175 | + // diagnostic inside the diagnostic added to stop exactly that. |
| 176 | + const badPort = new RangeError('options.port should be >= 0 and < 65536. Received NaN.'); |
| 177 | + const notice = plain(formatExhaustedPortSearchNotice(Number.NaN, badPort)); |
| 178 | + |
| 179 | + // The thrown text is still carried — that ruling does not bend by branch. |
| 180 | + expect(notice).toContain(badPort.message); |
| 181 | + |
| 182 | + // …but the claim that is false here is simply not made. |
| 183 | + // |
| 184 | + // ⚠️ The predicate here is `/probed/`, NOT `/probed \d+ ports/`, and the |
| 185 | + // difference is the whole test. Removing the branch guard does not produce |
| 186 | + // a WRONG number — it produces `undefined`, because the span body reads |
| 187 | + // `startPort`/`lastPort`/`probedCount` off an error that does not carry |
| 188 | + // them. A `\d+` pattern does not match `probed undefined ports`, so the |
| 189 | + // narrow spelling passed against precisely the regression this case exists |
| 190 | + // to catch. Measured: an ablation that neutered the guard left all eight |
| 191 | + // cases green. |
| 192 | + expect(notice, 'a span was claimed for a search that never walked one').not.toMatch(/probed/); |
| 193 | + expect( |
| 194 | + notice, |
| 195 | + 'the notice rendered a placeholder where a number belongs', |
| 196 | + ).not.toMatch(/undefined|NaN–/); |
| 197 | + |
| 198 | + // Anti-vacuity for the two negatives above: the real exhausted notice DOES |
| 199 | + // make both claims, so their absence here is a discrimination and not a |
| 200 | + // regex that stopped matching anything. |
| 201 | + const { probe } = alwaysBusy(); |
| 202 | + const real = await getAvailablePort(START, probe).catch((err: unknown) => err); |
| 203 | + const exhausted = plain(formatExhaustedPortSearchNotice(START, real)); |
| 204 | + expect(exhausted).toMatch(/probed \d+ ports/); |
| 205 | + expect(exhausted).toContain(`${START}–`); |
| 206 | + }); |
| 207 | + |
| 208 | + it('ARM 2 — a search that SUCCEEDS returns, so the notice is unreachable on the drift path', async () => { |
| 209 | + // Busy at the requested port, free at the next one: the ordinary auto-shift. |
| 210 | + const probed: number[] = []; |
| 211 | + const port = await getAvailablePort(START, async (candidate) => { |
| 212 | + probed.push(candidate); |
| 213 | + return candidate !== START; |
| 214 | + }); |
| 215 | + |
| 216 | + // The drift really is a drift — without this the arm proves nothing. |
| 217 | + expect(port, 'the probe did not produce a shift to discriminate against').not.toBe(START); |
| 218 | + expect(port).toBe(START + 1); |
| 219 | + expect(probed).toEqual([START, START + 1]); |
| 220 | + |
| 221 | + // ⭐ THE DISCRIMINATION. `getAvailablePort` RESOLVED, so the caller's |
| 222 | + // `catch` — the only place this notice is written — cannot run. The notice |
| 223 | + // is not merely absent on this path; it is unreachable. |
| 224 | + // |
| 225 | + // What fires instead is #12543's drift notice, guarded on |
| 226 | + // `port !== requestedPort`: true here, and false on the exhausted path, |
| 227 | + // where the assignment never happens at all. Both guards are read from the |
| 228 | + // live source rather than described. |
| 229 | + expect(SERVE_SOURCE, 'the notice moved out of the catch that guards it').toMatch( |
| 230 | + /catch \(searchExhausted\) \{[\s\S]*?printDiagnostic\(formatExhaustedPortSearchNotice\(requestedPort, searchExhausted\)\);/, |
| 231 | + ); |
| 232 | + expect(SERVE_SOURCE, "#12543's drift notice is no longer gated on a real shift").toContain( |
| 233 | + 'if (port !== requestedPort) {', |
| 234 | + ); |
| 235 | + expect(SERVE_SOURCE, "#12543's drift wording moved; this file's discrimination is stale").toMatch( |
| 236 | + /Port \$\{requestedPort\} is in use — serving on \$\{port\} instead\./, |
| 237 | + ); |
| 238 | + }); |
| 239 | + |
| 240 | + it('ARM 3 — the notice is inside the auto-shift branch, so production never reaches it', () => { |
| 241 | + const autoShift = SERVE_SOURCE.indexOf('if (portAutoShiftAllowed) {'); |
| 242 | + const productionBranch = SERVE_SOURCE.indexOf('} else if (!(await isPortAvailable(requestedPort)))'); |
| 243 | + const noticeCallSite = SERVE_SOURCE.indexOf('printDiagnostic(formatExhaustedPortSearchNotice('); |
| 244 | + const productionLine = SERVE_SOURCE.indexOf('is already in use.'); |
| 245 | + |
| 246 | + // Every anchor has to exist, or the ordering assertions below compare -1s |
| 247 | + // and pass while measuring nothing. |
| 248 | + expect(autoShift, 'the `portAutoShiftAllowed` branch head is gone').toBeGreaterThan(-1); |
| 249 | + expect(productionBranch, 'the production `else if` is gone').toBeGreaterThan(-1); |
| 250 | + expect(noticeCallSite, 'the exhausted-search notice has no call site').toBeGreaterThan(-1); |
| 251 | + expect(productionLine, 'the production in-use line is gone').toBeGreaterThan(-1); |
| 252 | + |
| 253 | + // ⭐ The notice sits between the branch head and the `else if`; the |
| 254 | + // production line sits after it. A production boot never enters the block |
| 255 | + // this notice lives in. |
| 256 | + expect(noticeCallSite).toBeGreaterThan(autoShift); |
| 257 | + expect(noticeCallSite).toBeLessThan(productionBranch); |
| 258 | + expect(productionLine).toBeGreaterThan(productionBranch); |
| 259 | + }); |
| 260 | + |
| 261 | + it('ARM 3 — the sink is `printDiagnostic`, which is stderr (#7915 stdout purity)', () => { |
| 262 | + // `stdout` is the JSON-RPC channel whenever the stdio MCP transport is |
| 263 | + // mounted, which is what `serve-stdio-stdout-purity.e2e.test.ts` pins. A |
| 264 | + // diagnostic written anywhere else reds that suite from this file. |
| 265 | + expect(SERVE_SOURCE).toContain('printDiagnostic(formatExhaustedPortSearchNotice('); |
| 266 | + expect(SERVE_SOURCE, '`printDiagnostic` no longer writes to stderr').toMatch( |
| 267 | + /const printDiagnostic = \(text = ''\) => \{\s*\n\s*if \(!bootQuiet\) process\.stderr\.write/, |
| 268 | + ); |
| 269 | + }); |
| 270 | + |
| 271 | + it('MUTUAL EXCLUSION — the three notices cannot be mistaken for one another', async () => { |
| 272 | + const { probe } = alwaysBusy(); |
| 273 | + const thrown = await getAvailablePort(START, probe).catch((err: unknown) => err); |
| 274 | + const notice = plain(formatExhaustedPortSearchNotice(START, thrown)); |
| 275 | + |
| 276 | + expect(notice, "the exhausted notice reads as #12543's drift notice").not.toMatch(DRIFT_NOTICE); |
| 277 | + |
| 278 | + // ⚠️ Load-bearing beyond legibility: this pattern is the first of |
| 279 | + // `PORT_TAKEN_PATTERNS` in `test/helpers/serve-process.ts`, which every |
| 280 | + // spawner in this package uses to decide that a boot lost a port race. |
| 281 | + expect(notice, 'the exhausted notice reads as the production refusal').not.toMatch( |
| 282 | + PRODUCTION_REFUSAL, |
| 283 | + ); |
| 284 | + |
| 285 | + // The other half of that helper's pattern. |
| 286 | + expect(notice, 'the notice now trips the EADDRINUSE contention pattern').not.toMatch( |
| 287 | + /EADDRINUSE[^\n]*?:(\d+)/, |
| 288 | + ); |
| 289 | + |
| 290 | + // …and both patterns are live instruments, not dead regexes: each must |
| 291 | + // still match the text it was written for, or the two negatives above |
| 292 | + // prove nothing at all. |
| 293 | + expect(' ⚠ Port 3000 is in use — serving on 3001 instead.').toMatch(DRIFT_NOTICE); |
| 294 | + expect(' ✗ Port 3000 is already in use.').toMatch(PRODUCTION_REFUSAL); |
| 295 | + }); |
| 296 | + |
| 297 | + it('carries a non-Error rejection intact rather than rendering it as [object Object]', () => { |
| 298 | + // The probe is injectable, so the seam can now reject with anything. The |
| 299 | + // ruling is to carry the text the seam produced, whatever it is. |
| 300 | + const notice = plain(formatExhaustedPortSearchNotice(START, 'probe socket exploded')); |
| 301 | + expect(notice).toContain('probe socket exploded'); |
| 302 | + expect(notice).not.toContain('[object Object]'); |
| 303 | + }); |
| 304 | +}); |
0 commit comments