ReviewOS

stacks/rpx

Test files leak mocked state across the suite; green depends on file order

#2270
Closed chris opened this 10 days ago ·
10 days ago

Summary

bun test runs every test file in one process, in a deterministic but platform-dependent order. Several files mutate shared state and never restore it, so the mutation applies to every file that runs afterwards. Which files those are differs between macOS and Linux CI.

This is the bug class behind #2267, where process-manager.test.ts left a spyOn(childProcess, 'spawn') stub installed and gateway.test.ts hung for 45s on Linux only. That one instance is fixed (#2268). An audit of all 48 test files at 3c176a6 found the pattern is widespread.

The suite is currently 518 pass / 0 fail — but by luck of ordering, not by construction.

Findings

Ranked with silent leaks (later tests pass without exercising anything) above loud ones.

#File:lineWhat leaksBlast radius
1test/integration.test.ts:29-30spyOn(Hosts, 'addHosts'), spyOn(Hosts, 'checkHosts') installed inside an it; the file has no hook of any kindRuns at position ~#41 with nothing after it that launders — the worst position in the run. src/start.ts:16 static-imports both
2test/hosts.test.ts:22,26,31spyOn on checkHosts/addHosts/removeHosts in beforeEach; zero afterEach/mock.restore/mockRestore in the fileAny later file reaching startProxy(ies) with hosts management on gets checkHosts → [false] and a no-op addHosts. A regression that broke /etc/hosts writing could not fail a test
3test/cleanup.test.ts:36,40mock.module('../src/hosts', …) and mock.module('../src/https', …)mock.restore() does not undo mock.module — unrecoverable for the rest of the process. Also fragile: the rest of both namespaces survives only because line 4 pre-loads them; drop that import and later files hit undefined
4test/start-lb-passthrough.test.ts:30spyOn(Start, 'startServer'), no restore in the 60-line fileSilent: the non-shared per-proxy branch of startProxies becomes a no-op. Loud: spyOn on an already-spied property returns the same spy with its history, so a later file's own spy inherits this call — reproducible as a two-file run against start-single-proxy-production-certs.test.ts:63
5test/start-single-proxy-production-certs.test.ts:34-35createSharedProxyServer → null and startServer → no-op, restored at lines 65-66, the last two statements of the itSuccess path only. Armed whenever any assertion throws or startProxies rejects (the openssl exit code at line 32 is never checked)
6test/start-on-demand.test.ts:57-58, 83-84Same conditional pattern, restored only after all assertionsIn-file, a leaked no-op means nothing binds :49081 for the second describe, producing three ECONNREFUSED failures in a describe with no bug
7packages/vite-plugin/test/vite-plugin.test.ts:65,90,96Three unrestored mock.modules; the node:process one spreads { ...process }, dropping every EventEmitter.prototype methodZero impact today — "test" runs two separate bun test processes and that dir holds one file. Fires on plain bun test at the repo root, or on adding a second file there: 10 rpx modules import node:process, so signal registration and process.exit silently no-op

Why it is green today

Only three files in the suite launder anything: start.test.ts (afterEach), process-manager.test.ts (afterEach, the #2267 fix), and https.test.ts (beforeEach). Everything between them is an unprotected window.

The current victims are immune for incidental reasons — e.g. single-port.test.ts and local-ca.test.ts pass cleanup: false, which disables hosts management via src/start.ts:828-836. Drop that one option and #2 fires.

Useful mechanic, verified: Bun's mockRestore() restores the original function, not a previously-leaked spy. So a later file that spies-and-restores the same target also clears an inbound leak. That is exactly why #2267 reproduced on Linux but not macOS.

Suggested fix

One idiom, already the repo convention (start.test.ts:16-18, process-manager.test.ts, https.test.ts):

afterEach(() => {
  mock.restore()
})

placed in the describe that installs the spies. Unlike a trailing .mockRestore() it also fires when an assertion throws or the test times out, and being global it launders inbound leaks too.

Two exceptions:

  • cleanup.test.ts and vite-plugin.test.ts use mock.module, which mock.restore() does not undo. Those must be deleted or converted to spyOn.
  • header-modification.test.ts:17-24 installs spyOn(console,'log') in beforeAll and restores in afterAll — correct as is. A blanket afterEach(mock.restore) there would drop the spy after the first test. Noted so nobody "standardises" it.

sni-memory.test.ts looks partial by grep but is correct; leave it.

Sign in to comment on this issue.