Closes #2270. Also fixes the second half of #2271 — see the note at the bottom, the first half turned out to be a false positive.
Why
bun test shares one module registry across every file in the process, and spyOn does not auto-restore. A stub left installed therefore applies to every file that runs afterwards, and which files those are depends on bun's file order, which differs per platform. That is exactly the shape of #2267, where a leaked spawn stub hung the gateway CLI tests on Linux only.
Proof
A probe that runs the six offending files and then checks whether the shared exports are still real:
BEFORE LEAKED: ["Hosts.addHosts","Hosts.checkHosts","Hosts.removeHosts",
"Https.cleanupCertificates","Start.startServer"]
AFTER LEAKED: []Five shared exports were being replaced by no-op stubs for the remainder of every run. Worth stressing that these fail silently — a leaked addHosts no-op means a later test asserting hosts-file behaviour passes without exercising anything, unlike #2267's leak which at least hung loudly.
Per file
| file | problem | fix |
|---|---|---|
hosts.test.ts | 3 spies on ../src/hosts in beforeEach, no restore anywhere | afterEach(() => mock.restore()) |
start-lb-passthrough.test.ts | Start.startServer stub, no restore — the file's own comment notes others leak this export onto it | same |
integration.test.ts | 2 spies only ever asserted with toBeDefined(), never invoked | assert the real exports; drop the spies |
cleanup.test.ts | mock.module('../src/hosts') / ('../src/https') replaced each namespace with a single export, permanently — mock.restore() does not undo a module mock | converted to restorable spies, the technique start.test.ts already uses |
start-single-proxy-production-certs.test.ts | restores sat at the end of the it, so a throwing assertion left the stubs installed | moved into afterEach |
start-on-demand.test.ts | same conditional pattern | file-level afterEach — the second describe needs the real redirect server |
vite-plugin.test.ts | 3 dead mock.modules; nothing in the file imports the modules they mocked | deleted (module mocks are permanent, so restoring is not an option) |
site-supervisor.test.ts | the supervisor in 'stopAll signals every running site' was the only one of eight never registered with supervisors, so the afterEach could not reach its 30s reaper | one line, registered before the first await that can reject |
Idiom throughout is afterEach(() => { mock.restore() }), already the convention in start.test.ts / process-manager.test.ts / https.test.ts. Unlike a trailing .mockRestore() it also fires when an assertion throws, and being global it launders inbound leaks too.
Left alone deliberately: sni-memory.test.ts (already correct), header-modification.test.ts's beforeAll/afterAll console spy (a blanket afterEach there would drop the spy after the first test), and cleanup.test.ts's node:process mock (Proxy-delegated, does not reach later files).
Two corrections to the filed issues
- #2271's
:48080listener leak is a false positive. I probed it directly — a later file in the same process binds:48080fine, both before and after. I had added aStart.cleanup()to drain it and removed that again rather than ship a fix for a bug that does not exist, especially one that globally drains servers other files may own. The other half of #2271, the untrackedSiteSupervisor, is real and fixed here. - The
vite-plugin.test.tsmocks are not demonstrably breaking anything. #2270 predicted plainbun testat the repo root would fail; it passes onmain(549/0). The deletion still stands — the code is dead and holds permanent global state — but it is hygiene, not a live fix.
Verification
bun run test: 542 pass / 0 fail, plus 7/0 for vite-plugin- plain
bun testat repo root (one process, all 49 files): 549 pass / 0 fail tsc --noEmit: 0 errorspickier: 0 errors (1 pre-existing warning indataplane/README.md, untouched here)
🤖 Generated with Claude Code