Skip to content

test: fix fork-worker process-listener leaks behind the windows CI flake (#159)#184

Merged
debugmcpdev merged 2 commits into
mainfrom
fix/159-fork-worker-listener-leaks
Jul 13, 2026
Merged

test: fix fork-worker process-listener leaks behind the windows CI flake (#159)#184
debugmcpdev merged 2 commits into
mainfrom
fix/159-fork-worker-listener-leaks

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Fixes #159.

Root cause

The failed run (28960586768 attempt 1) reported 157 passed | 2 skipped (160) files and 2462 passed | 5 skipped (2505) tests — exactly one file with exactly 38 tests never reported. The only 38-test file in the suite is tests/unit/proxy/dap-proxy-core.test.ts: its fork worker died before flushing results (unit project = pool: 'forks', isolate: true).

Why the fork died: the "registers handlers…" test spied process.on without mockImplementation, so the spy called through and setupGlobalErrorHandlers() attached 4 real listeners (uncaughtException, unhandledRejection, SIGTERM, SIGINT) to the vitest fork's process. afterEach only restored the spy — the listeners were never removed. The leaked uncaughtException handler runs errorShutdown().finally(() => process.exit(1)); after vi.resetAllMocks(), the captured shutdownFn returns undefined, so the handler either calls the real process.exit(1) or throws (.finally of undefined) — and a throw inside an uncaughtException handler is a fatal Node exit. Either way: [vitest-pool]: Worker exited unexpectedly, results lost, red CI over a green suite.

The same bug class was caught red-handed in the failed run's log for a second file — tests/unit/proxy/dap-proxy-dependencies.test.ts invokes the unhandledRejection handler (which schedules shutdownFn().finally(() => process.exit(1)) on the microtask queue) and restored the exit spy synchronously before it fired:

stderr | tests/unit/proxy/dap-proxy-dependencies.test.ts
[Test] UnhandledRejection: process.exit unexpectedly called with "1"

An audit found a third offender: tests/unit/cli/stdio-command.test.ts leaks real SIGTERM/SIGINT/exit listeners plus a 60s keepAlive interval per successful-start test.

Fix (no retries, no masking)

  • tests/vitest.setup.ts — process-listener leak guard: baselines rawListeners for 8 critical events per fork, and after each test removes + loudly reports ([process-listener-leak], with test name + file) anything not in the baseline. LEAK_GUARD_STRICT=1 upgrades to a hard failure. Diagnostics handlers now include the test file path.
  • tests/unit/proxy/dap-proxy-core.test.ts — capture handlers via mockImplementation (never attach); file-wide process.exit mock net (ProxyRunner.start() arms a real 10s setTimeout(() => process.exit(1))); inline exitSpy.mockRestore() calls removed so the net survives to restoreAllMocks.
  • tests/unit/proxy/dap-proxy-dependencies.test.ts — settle the async exit chain before restoring the spy; assert shutdownFn + exit(1) (previously unasserted).
  • tests/unit/cli/stdio-command.test.ts — capture process.on without attaching; drain the keepAlive interval via the captured SIGTERM handler; fake stdin everywhere (no more resume()/'end' listener on the fork's real stdin).
  • tests/unit/proxy/proxy-manager-test-setup.ts — deleted (dead code; would have nuked vitest's own unhandledRejection handlers if ever imported).
  • CI attributionjson reporter enabled on CI (written by the main process, so it survives fork death and records exactly which file never reported); test-results.json added to the windows-test-diagnostics artifact.

Failing-first proof

Guard landed before the test fixes flags exactly the killer (and the third offender):

[process-listener-leak] Removed leaked 'uncaughtException', 'unhandledRejection', 'SIGTERM', 'SIGINT'
listener(s) left by "ProxyRunner.setupGlobalErrorHandlers > registers handlers for uncaughtException,
unhandledRejection, SIGTERM, SIGINT" (tests/unit/proxy/dap-proxy-core.test.ts). ...

[process-listener-leak] Removed leaked 'SIGTERM', 'SIGINT', 'exit' listener(s) left by
"STDIO Command Handler > should start server successfully in stdio mode"
(tests/unit/cli/stdio-command.test.ts). ...   (×4 tests)

After the fixes, the same commands run clean under LEAK_GUARD_STRICT=1.

Verification

  • LEAK_GUARD_STRICT=1 vitest run --project unit on all three fixed files — green, zero leak messages
  • Full pnpm run test:ci-coverage (same command as the failing CI job) — green; full-output sweep: 0 [process-listener-leak] messages, 0 process.exit unexpectedly called messages (previously present), and the summary counts add up exactly (161+2 = 163 files, 2513+5 = 2518 tests — no lost workers)
  • Stress loop on Windows (same platform as the flake): 30× the three fixed files under LEAK_GUARD_STRICT=1 — 30/30 green
  • node scripts/flake-hunt.mjs 3 (within-file shuffle, fresh seeds) — 3/3 green
  • Follow-up filed: Inject a process handle into ProxyRunner, setupGlobalErrorHandlers, and stdio-command signal registration #183 — DI process-handle injection for ProxyRunner/setupGlobalErrorHandlers/stdio-command

🤖 Generated with Claude Code

…159)

A vitest fork worker on windows-latest occasionally died before flushing
its results, failing a fully green suite with "[vitest-pool]: Worker
exited unexpectedly". Attribution via summary counts: the dying file was
tests/unit/proxy/dap-proxy-core.test.ts, whose setupGlobalErrorHandlers
describe attached four REAL exit-bearing listeners to the worker process
(call-through process.on spy) and never removed them.

- dap-proxy-core.test.ts: capture handlers via mockImplementation
  instead of attaching; file-wide process.exit mock net (start() arms a
  real 10s exit(1) timeout); drop inline exitSpy.mockRestore() calls so
  the net survives until restoreAllMocks.
- dap-proxy-dependencies.test.ts: settle the async
  shutdownFn().finally(() => process.exit(1)) chain before restoring the
  exit spy (its intercepted exit(1) appears in the failed run's log);
  assert the exit.
- stdio-command.test.ts: capture process.on without attaching; drain the
  60s keepAlive interval via the captured SIGTERM handler; fake stdin
  everywhere.
- vitest.setup.ts: per-fork process-listener leak guard - baselines
  rawListeners for 8 critical events, removes + reports leaks after each
  test with test/file attribution; LEAK_GUARD_STRICT=1 makes it fail.
- CI attribution: json reporter on CI (survives fork death) and
  test-results.json in the windows-test-diagnostics artifact.
- Delete dead tests/unit/proxy/proxy-manager-test-setup.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 13, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@debugmcpdev debugmcpdev merged commit 802a60e into main Jul 13, 2026
10 checks passed
@debugmcpdev debugmcpdev deleted the fix/159-fork-worker-listener-leaks branch July 13, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI flake: vitest worker fork exits unexpectedly on windows-latest, failing a fully green suite

2 participants