Skip to content

fix(proxy): the DAP connect window ends with the adapter's exit or the parent's deadline; the worker's stdout pipe is drained (#963) - #964

Merged
debugmcpdev merged 3 commits into
mainfrom
fix/963-connect-window
Oct 20, 2026
Merged

debugmcpdev merged 3 commits into
mainfrom
fix/963-connect-window

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Closes #963.

What changed

The DAP connect window is bounded by what it waits for, not by a count. DapConnectionManager.connectWithRetry was 500 ms + 60 attempts × 200 ms ≈ 12.5 s for every adapter, alive or dead, with no relation to the parent's 30 s initialization ceiling. An adapter that needed more than ~12.5 s to listen failed the launch with Failed to connect DAP client: connect ECONNREFUSED while it was still booting, 17.5 s before the parent would have given up. Now:

  • The parent's deadline travels in the init payload (ProxyInitPayload.initializationTimeoutMs, written by ProxyManager, validated by the parser like adapterPort).
  • connectWithRetry(host, port, { budgetMs, abort }) is bounded by wall clock. In spawn mode the worker passes the parent's deadline + 500 ms (the parent's timer was armed first and its stage-aware message — adapter process spawned (PID n) … DAP connection was never established — is the one a slow boot should get) and an AbortSignal the adapter's exit handler fires with { code, signal, stderrTail }, so a dead adapter ends the wait at once and the error quotes the exit. Connect mode (a remote rdbg/debugpy already listening) and legacy payloads keep the historical 12.5 s window as the default. The 500 ms / 200 ms cadence is unchanged.
  • Three exits, each its own message, all keeping the Failed to connect DAP client: prefix.

Found on the way, fixed here: the worker's stdout pipe was never read. ProxyManager spawns the worker with stdio: ['pipe','pipe','pipe','ipc'] and drained only stderr. Outside the server CLI (which sets CONSOLE_OUTPUT_SILENCED=1, inherited by the worker), the worker's winston console transport writes every log line to stdout; once the OS pipe buffer filled (64 KB on Windows) the worker blocked solid — CPU flat, deaf to IPC (terminate ignored, the parent force-killed it), its DAP socket unread, no exception anywhere. The new 14 s integration case was the first test to log that much; it stalled three different ways at a fixed ~13.8 s until the cause was found. The parent now drains stdout like stderr ([ProxyManager STDOUT] at debug, sanitized). Every ProxyManager embedder that is not the CLI was exposed.

Diagnostics. The stdio-client suites' servers now log to logs/tests/stdio/, so their per-session proxy logs land inside the Windows CI diagnostics artifact; before, they went to os.tmpdir() and were lost with the runner — the connect timeline that would have settled #963 was unrecoverable. The adversarial suite's new case prints the proxy log's connect lines in its failure message; ADVERSARIAL_KEEP_TMP=1 keeps its temp dir.

Measured

CI, windows-latest, 2026-10-19 (runs 39336731176 and 39339665769) failing Ruby case 15.8 s and 15.5 s before ECONNREFUSED, rdbg alive (PID, no exit code); a sibling launch in the same suite succeeded in 12.9 s under the same load; normal boots 3.5–4.8 s
rdbg boot to first accepted connect, this box (Ruby 3.4.9, debug 1.11.0), 10 samples idle: median 380 ms, max 1909 ms (one cold start); during npm run build: 308–386 ms
Adversarial fixture, listenDelayMs: 14_000, parent deadline 25 s main: rejects at 12.5 s with ECONNREFUSED; this branch: connects, start() resolves in 15.4 s
Same fixture, listen delays 8 / 11 / 13 s vs 13.5 / 14 s, before the stdout drain resolved / resolved / resolved vs stalled / stalled — the stall tracked logged bytes, not the step in flight; CONSOLE_OUTPUT_SILENCED=1 or logLevel: warn made it vanish, UV_THREADPOOL_SIZE=64 changed nothing

The runner, not rdbg, is the variable: locally rdbg listens in under half a second even under a build.

Tests

  • tests/unit/proxy/dap-proxy-connection-manager.test.ts: past 60 attempts while the budget remains; default budget expires with the "nothing accepted a connection … within 12.5s" message; at least one attempt however small the budget; abort ends the wait at once quoting code/signal/stderr and no attempt follows; a pre-aborted signal makes no attempt; a bare abort() (an AbortError, whose own code is 20) is not read as an exit code.
  • tests/proxy/dap-proxy-worker.test.ts: spawn mode passes the deadline-derived budget and a signal; a legacy payload passes no budget; connect mode passes neither; an adapter exit during the connect aborts the signal with the exit and the stderr tail.
  • tests/unit/proxy/dap-proxy-message-parser.test.ts, tests/unit/proxy/proxy-manager.start.test.ts: the payload field; the init command carries the manager's deadline (default and injected); the stdout drain (listener attached, lines logged, secret line redacted, trailing partial flushed on end).
  • tests/integration/adversarial-dap/adversarial-adapter.test.ts: the 14 s listen-delay case (red on main, green here, ~15 s).

Verification

Gate Result
npx vitest run --project unit 373 files, 7452 passed, 4 skipped
tests/integration/adversarial-dap/adversarial-adapter.test.ts 7 passed (new case 15.4 s)
Ruby stop-on-entry + JS launch-config stdio suites (real rdbg, real js-debug) 20 passed; proxy logs present under logs/tests/stdio/sessions/
Python integration pair 3 passed
npm run lint, npm run typecheck:all (ratchet 0/0), node scripts/check-docs.mjs, pnpm changelog:check clean
Dev proxy dogfood after rebuild see the last comment

Out of scope

The 500 ms / 200 ms cadence; a readiness signal from rdbg's DEBUGGER: banner; the parent's 30 s default; #875 (whose next sighting now gets a proxy log in the artifact).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y3ULnoVSoCZ5t2Y8d98Ctv

…e parent's deadline, and the worker's stdout pipe is drained (#963)

`DapConnectionManager.connectWithRetry` was a fixed 60 attempts (~12.5 s)
for every adapter, alive or dead, unrelated to the parent's 30 s init
ceiling: an adapter that took longer to listen (rdbg on a loaded Windows
runner, 2 of 5 CI runs on 2026-10-19) failed the launch with ECONNREFUSED
while still booting. The parent's deadline now travels in the init payload
(`initializationTimeoutMs`); in spawn mode the loop runs until the adapter
process exits (an AbortSignal from its exit handler, the error quoting the
exit and stderr tail) or 500 ms past the parent's deadline, whose
stage-aware message is the one a slow boot gets. Connect mode and legacy
payloads keep the historical window as the default; the cadence is
unchanged.

Found by the new 14 s integration case: `ProxyManager` spawned the worker
with a piped stdout it never read. Outside the server CLI (which silences
console output) the worker's console transport filled the 64 KB pipe and
the worker blocked solid, deaf to IPC. The parent now drains stdout like
stderr, logged at debug and sanitized.

The stdio-client test suites log to logs/tests/stdio/, so their proxy
logs reach the Windows CI diagnostics artifact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3ULnoVSoCZ5t2Y8d98Ctv
@codecov

codecov Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.92473% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/proxy/dap-proxy-connection-manager.ts 98.46% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

… mid-connect; state the elapsed time on expiry (#963)

Own-PR review follow-ups. The exited-adapter error quoted the worker's
whole 20-line stderr tail (up to ~8 KB); it now quotes the parent's
documented 10 lines / 2000 characters, most recent first to go, and says
the lines are those seen by the time the process exited — the snapshot is
taken in the exit handler, before the pipe has settled, because waiting
would hand the race to the parent's generic exit message. The expiry
message states the elapsed time beside the window (one connect can
outlive it: a remote host that drops SYNs answers ETIMEDOUT after the
OS's ~21 s). client.disconnect() on the failure path is guarded so it can
never mask the reason. The start test pins that the stdout drain's
listeners detach with the rest; the stdio test helper sweeps entries older
than a day from logs/tests/stdio, which nothing else cleans; the
event-management doc sample sanitizes as its comment says.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3ULnoVSoCZ5t2Y8d98Ctv
@debugmcpdev
debugmcpdev merged commit 9a1d12b into main Oct 20, 2026
14 checks passed
@debugmcpdev
debugmcpdev deleted the fix/963-connect-window branch October 20, 2026 13:50
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Dev proxy dogfood after the rebuild from this branch (Windows, the dev proxy at tools/dev-proxy): a Ruby launch of examples/ruby/fizzbuzz.rb with stopOnEntry: true paused at line 4 (reason: entry, stopOnEntrySuccessful: true), continue_execution then ran it to completion with exit code 0 and get_output held all 15 lines through 15: FizzBuzz plus the fizzbuzz complete stderr line; a Python launch of examples/python/fibonacci.py with stopOnEntry: true paused at its entry and ran to exit code 0. Both proxy workers went through the new connect loop and the drained stdout.

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: ruby-stop-on-entry on windows-latest — rdbg not listening within the DAP connect window (ECONNREFUSED), 2 of 4 runs on 2026-10-19

2 participants