Skip to content

fix(proxy): eliminate dangling rejections behind the seed-1038859894 flake (#420) - #433

Merged
debugmcpdev merged 1 commit into
mainfrom
fix/420-proxy-manager-flake
Aug 22, 2026
Merged

fix(proxy): eliminate dangling rejections behind the seed-1038859894 flake (#420)#433
debugmcpdev merged 1 commit into
mainfrom
fix/420-proxy-manager-flake

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Problem

Fixes #420. The 2026-08-14 nightly Flake Hunt failed 1 of 10 randomized Windows unit sweeps (seed 1038859894) with unhandled-rejection warnings around tests/unit/proxy/proxy-manager.start.test.ts. Investigation confirmed the issue's hypothesis and found two reinforcing causes:

  • Test-side: several tests created a promise, drove its rejection via vi.advanceTimersByTimeAsync, and attached the .rejects expectation only afterwards. Sinon's async fake-timer loop yields through real tick boundaries between fake-timer callbacks — exactly where Node's unhandled-rejection check runs — so whether the rejection was momentarily handler-less was scheduling-dependent. The file also had no top-level afterEach, leaving each ProxyManager wired to its FakeProxyProcess with real setImmediate emits able to fire post-test.
  • Source-side: real leaks in ProxyManager let settled operations keep live rejectors — the ~35s DAP parent backstop timer was never cleared on response; start() failure paths never detached the listeners setupEventHandlers installed (and the sendInitWithRetry throw bypassed the wait-promise's local cleanup entirely), so a stale process could drive handleProxyExitpendingDapRequests rejections into a later test; each successful init leaked its persistent init-received listener; stop()'s force-kill path leaked its once('exit') handler. DapProxyWorker's async onInitialized event listener is fire-and-forget, so its failures surfaced as unhandled rejections (in production too).

Replaying the seed on this Windows box reproduced the exact warnings from the issue in 2 of 5 baseline runs (Failed to initialize proxy after 6 attempts, Debug adapter did not respond to 'launch' request within 35s).

Fix

Source (behavior-preserving)src/proxy/proxy-manager.ts:

  • sendDapRequest: the parent backstop timer is stored on the pending entry, cleared at every settle point (handleDapResponse, handleProxyExit, cleanup(), the send-failure catch — which also no longer falls through to create the timer), and unref()d.
  • start(): listeners installed on the proxy process/stderr are tracked and detached on any start failure (covers the sendInitWithRetry throw path uniformly); the 30s init timeout runs its local cleanup before rejecting; the missing-pid throw clears proxyProcess so the manager isn't permanently stuck on the Proxy already running guard. Safe because SessionManager calls stop() after a failed start and stop() uses the process handle directly.
  • sendInitWithRetry removes its init-received listener on success, not only on timeout.
  • stop() removes its once('exit') listener in the force-kill-timeout and already-exited branches.
  • src/proxy/dap-proxy-worker.ts: the onInitialized listener contains and logs failures (consistent with the runner's existing log-don't-crash rejection policy).

Tests: top-level afterEach in proxy-manager.start.test.ts (real timers, removeAllListeners on both sides, macrotask flush); all create→advance→expect orderings now attach the .rejects expectation before driving the rejection (worst case: startPromise at the stop-during-start test sat handler-less across a 35s advance); same ordering fix in proxy-manager-message-handling.test.ts and go-initialized-fallback.test.ts; try/finally around the force-kill test's fake timers. Five new hygiene tests pin the source fixes (timer count after settle, listener counts after success/failure/stop, no manager events from a stale process after failed start).

Drive-by: scripts/flake-hunt.mjs's end-of-run summary now includes --sequence.shuffle.tests — without it the printed reproduce line couldn't reproduce within-file ordering failures (the committed config shuffles files only).

Verification (Windows box, LEAK_GUARD_STRICT=1 CI=true)

  • Baseline (pre-fix): 2 of 5 seed replays surfaced the issue's exact unhandled-rejection warnings; post-fix: 8/8 replays with zero UnhandledRejection lines, all 214 files passing (previously every run had 4 residual lines from the ProxyManager backstop timers and the worker's onInitialized).
  • Fresh-seed flake hunt (FLAKE_RUNS=5): 5/5 passed.
  • Full unit suite green in default order; lint clean; coverage 93.01% stmts / 83.0% branch (one unrelated Docker e2e — docker-entrypoint sse --help — flaked under parallel load during the coverage run and passes in isolation).

🤖 Generated with Claude Code

…flake (#420)

The Windows randomized unit sweep intermittently logged unhandled
rejections around proxy-manager.start.test.ts: rejections created in one
test surfaced at a later real-tick boundary and were attributed to
whichever shuffled test ran next. Replaying the seed reproduced the
warnings 2-in-5 on the original code; after this change 8/8 replays run
with zero unhandled-rejection lines.

Source fixes (behavior-preserving):
- ProxyManager.sendDapRequest: the ~35s parent backstop timer is now
  stored on the pending entry, cleared at every settle point, unref'd,
  and no longer created after a failed send - previously every settled
  request left a live timer holding its rejector.
- ProxyManager.start: listeners installed on the proxy process are
  tracked and detached on any start() failure (including the
  sendInitWithRetry throw path that bypassed the wait-promise cleanup),
  so a stale process can no longer drive handleProxyExit rejections into
  a later test; the 30s init timeout now runs its local cleanup before
  rejecting; the missing-pid throw no longer leaves the manager stuck on
  the 'Proxy already running' guard.
- sendInitWithRetry removes its persistent init-received listener on
  success, not only on timeout.
- stop() removes its once('exit') listener in the force-kill and
  already-exited branches.
- DapProxyWorker: the async onInitialized event listener is
  fire-and-forget, so it now contains and logs failures instead of
  leaking them as unhandled rejections.

Test hygiene:
- proxy-manager.start.test.ts gets a top-level afterEach (real timers,
  removeAllListeners on both sides, macrotask flush) and all
  create->advance->expect orderings now attach the .rejects expectation
  BEFORE driving the rejection; same fix in
  proxy-manager-message-handling.test.ts and
  go-initialized-fallback.test.ts; the force-kill test wraps its fake
  timers in try/finally.
- flake-hunt.mjs failure summary now includes --sequence.shuffle.tests
  so the printed reproduce line actually reproduces.

Closes #420

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

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.71429% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/proxy/dap-proxy-worker.ts 80.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@debugmcpdev
debugmcpdev merged commit af080fb into main Aug 22, 2026
10 checks passed
@debugmcpdev
debugmcpdev deleted the fix/420-proxy-manager-flake branch August 22, 2026 21:25
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.

Flake: Windows randomized unit sweep failed on seed 1038859894 (unhandled rejections around proxy-manager.start.test.ts)

2 participants