Skip to content

feat: forward adapter-process stdio as debuggee output events — Ruby all platforms, Rust on Windows (#222, #223) - #254

Merged
debugmcpdev merged 3 commits into
mainfrom
feat/ruby-stdio-output
Aug 4, 2026
Merged

feat: forward adapter-process stdio as debuggee output events — Ruby all platforms, Rust on Windows (#222, #223)#254
debugmcpdev merged 3 commits into
mainfrom
feat/ruby-stdio-output

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Summary

Closes the cluster:output-capture cluster: adapters whose debuggee inherits the adapter process's stdio now surface the program's output through get_output. Stacked on #253.

Two adapters have this topology:

Mechanism

Mirrors the #247 worker-side synthesis pattern:

  1. AdapterSpawnConfig's spawn variant gains an opt-in forwardStdio?: { excludeStderrLinePattern?: RegExp }. RubyAdapterPolicy sets it for launch — attach returns mode: 'connect' (no adapter process), so launch-only is enforced by construction — with a /^DEBUGGER: / exclusion for rdbg's stderr banners. RustAdapterPolicy sets it on win32 only. No other policy sets it → zero behavior change elsewhere (debugpy/js-debug adapter stdio is never forwarded).
  2. GenericAdapterManager fans each stream's single LineBuffer out to the raw callback (all lines, blank included, unsanitized — the client must see the program's real output, parity with how debugpy/js-debug output reaches the buffer) while the persisted-log path keeps its blank-drop + sanitizeStderr redaction byte-for-byte.
  3. DapProxyWorker synthesizes sendDapEvent('output', { category, output }), so entries land in the existing per-session ring buffer with no changes above the proxy.

The exit-flush race (found in live verification, not in planning)

A debuggee printing to a block-buffered pipe (Ruby's default) flushes everything at exit — milliseconds after the adapter's terminated event and DAP socket close. The SessionManager reacts to either signal by stripping listeners and stopping the proxy, so the first live run captured the stderr marker but lost all 15 stdout lines. When forwarding is active, the worker now holds exited/terminated forwarding and the dap_connection_closed status behind a stdio-drain barrier (both streams' close events, 2 s backstop). Stream data fires before close and IPC is FIFO, so the flushed output deterministically reaches the buffer first. A worker unit test pins the ordering.

Verified live (Windows, dev proxy)

  • Ruby fizzbuzz: all 15 puts lines as stdout (including the exit flush), warn marker as stderr, zero DEBUGGER: entries.
  • Rust hello_world: complete program output as stdout, including the post-Process exited tail.
  • Ruby attach regression: e2e attach smoke test passes unchanged.

While verifying, found a pre-existing Windows CodeLLDB quirk (unrelated to this change): continue_execution from a breakpoint re-stops at the same breakpoint — filed separately. The Rust e2e output assertion is designed around it (both markers print before the breakpoint, no continue needed).

Test plan

  • tests/unit/proxy/dap-proxy-adapter-manager.test.ts — fan-out: raw callback + redacted log copy (the load-bearing parity assertion), blank lines, chunk-straddle, single flush on end+close, no-callback unchanged
  • tests/proxy/dap-proxy-worker.test.ts — Ruby launch wires the forwarder (banner exclusion, blank → "\n"); Python gets none; drain barrier holds terminated/dap_connection_closed until streams close, output ordered first
  • tests/adapters/ruby/unit/adapter-policy-ruby.test.ts + packages/shared/tests/unit/adapter-policy-rust.test.ts — opt-in shape, banner pattern semantics, win32-only gating
  • tests/e2e/mcp-server-smoke-ruby.test.ts — asserts 6: Fizz (stdout), fizzbuzz complete (stderr), no DEBUGGER: entries
  • tests/e2e/mcp-server-smoke-rust.test.ts — asserts both hello_world markers as stdout
  • tests/e2e/mcp-server-smoke-ruby-attach.test.ts — attach unchanged
  • Full unit tier: 159 files / 2627 tests green; lint clean

Fixes #222. Fixes #223.

🤖 Generated with Claude Code

cynarlab and others added 2 commits August 4, 2026 15:37
…eLLDB's canonical terminal attribute (#225, #223)

Go (#225): the launch transform now sets outputMode: 'remote' (user
override wins), so Delve forwards the target's stdout/stderr as DAP
output events instead of writing them to dlv's own stdio where they
never reached get_output. Verified against dlv 1.26.3: the exec-mode
e2e smoke test now asserts 'Hello, World!' arrives with category
stdout.

Rust (#223): the launch transform emitted a debugpy-style 'console'
key; CodeLLDB deserializes that only as a legacy alias of its real
'terminal' attribute. Emit terminal: 'console' (translating legacy
console values, explicit terminal wins). Investigation with a DAP
trace showed the key rename does not by itself fix output capture on
Windows: CodeLLDB's TerminalKind::Console performs no stdio
redirection (launch.rs:460), so the debuggee inherits the adapter
process's pipes — same topology as Ruby (#222). That gap is fixed by
the adapter-stdio forwarding follow-up; docs updated to scope the
known issue to Windows.

Also: comprehensive-mcp-tools gains an outputMarker per-language check
(enabled for go), and the go/rust skill references reflect the new
state.

Fixes #225

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…222, fixes #223)

Some adapters hand the debuggee their own stdio instead of converting
it to DAP output events: rdbg -c runs the target as a child of the
adapter with inherited pipes on every platform, and CodeLLDB's console
mode performs no stdio redirection at all — on Windows (where LLDB
cannot intercept via its own pipes) the target inherits the adapter's
handles. In both cases the program's output only ever reached the
proxy log, never get_output.

Mechanism (mirrors the #247 worker-side synthesis pattern):

- AdapterSpawnConfig's spawn variant gains an opt-in forwardStdio
  field; RubyAdapterPolicy sets it for launch (attach has no adapter
  process by construction) with a /^DEBUGGER: / stderr exclusion for
  rdbg's banners; RustAdapterPolicy sets it on win32 only (POSIX gets
  output via CodeLLDB's own DAP events from LLDB's STDOUT/STDERR
  broadcasts — the channels are mutually exclusive).
- GenericAdapterManager fans each stream's LineBuffer out to the new
  raw callback (all lines, blank included, unsanitized — the client
  must see the program's real output, parity with debugpy/js-debug)
  while the persisted-log path keeps its blank-drop + sanitizeStderr
  redaction byte-for-byte.
- DapProxyWorker synthesizes sendDapEvent('output', {category, output})
  from the callback, so entries land in the existing per-session ring
  buffer with no changes above the proxy.

Exit-flush race (found in live verification): a debuggee printing to a
block-buffered pipe flushes everything at exit, milliseconds AFTER the
adapter's terminated event and socket close — and the SessionManager
reacts to either by stripping listeners and stopping the proxy, so the
flushed output was dropped. When forwarding is active the worker now
holds exited/terminated forwarding and the dap_connection_closed
status behind a stdio-drain barrier (streams' close events, 2s
backstop). Stream data fires before close and IPC is FIFO, so the
output deterministically wins the race. Verified live on Windows:
Ruby fizzbuzz yields all 15 stdout lines + the stderr marker with
rdbg banners excluded; Rust hello_world yields the full program
output including the exit-time tail.

Also: fizzbuzz.rb gains a warn marker (appended — all breakpoint line
numbers preserved), Ruby/Rust e2e smoke tests assert get_output, the
comprehensive matrix gains outputMarkers for ruby+rust, and docs/skill
references now describe launch-works/attach-doesn't for Ruby.

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

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

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

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

📢 Thoughts on this report? Let us know!

@debugmcpdev
debugmcpdev merged commit 753a9d1 into main Aug 4, 2026
10 checks passed
debugmcpdev added a commit that referenced this pull request Aug 5, 2026
…h sessions end 'stopped', not 'error' (fixes #258) (#263)

Two defects, both exposed by the #254 stdio drain barrier:

1. Ordering: onExited/onTerminated/onClose all await the same drain
   barrier, and onTerminated's extra await (#252 synthesis) let onClose
   win by a microtask - the codeless dap_connection_closed status reached
   the parent before the terminated DAP event, stripping the handlers
   that would have marked the session stopped. A FIFO terminal-signal
   queue in the proxy worker now preserves arrival order, and the
   adapter process 'exit' status rides the same queue.

2. Fabricated exit code: ProxyManager emitted 'exit' with
   `message.code ?? 1` (and dap-core with `message.code || 1`, mangling
   a real 0), so every codeless closure became exit code 1 and
   SessionManager mapped it to ERROR - even for a clean run.

Terminal statuses now carry an explicit `expected` flag (terminal DAP
event already forwarded, or shutdown underway) and pass the code through
untouched. SessionManager maps expected teardowns to STOPPED (recording
the debuggee exit code), unexpected closures to ERROR, and keeps the
legacy rule for real proxy-process exits. The duplicate unlatched 'exit'
emit from the functional core is suppressed.

Ruby also gains adapterExitCodeIsDebuggeeExitCode: rdbg -c propagates
the debuggee's exit status but never sends a DAP exited event, so the
worker now synthesizes one - clean run records exitCode 0, unhandled
raise records exitCode 1, matching Python/js.

Deferred (cosmetic): Ruby's isSessionReady still resolves the
start_debugging ready-wait via the exit path for quick scripts, logging
'proxy exited during startup'; the reported state is now accurate.

Co-authored-by: JF <john.franklin@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@debugmcpdev
debugmcpdev deleted the feat/ruby-stdio-output branch August 5, 2026 13:05
debugmcpdev added a commit that referenced this pull request Aug 12, 2026
…317) (#319)

* fix(ruby): stream debuggee stdout mid-run via injected sync prelude (#317)

Ruby block-buffers $stdout when it is a pipe, and rdbg -c hands the
debuggee the adapter process's piped stdio — so puts output only reached
the proxy's stdio scraper at process exit (the #254/#258 exit-flush
drain), never mid-run. The comprehensive matrix's ruby get_output cell
soft-failed on exactly this: the session sits paused at a re-armed loop
breakpoint, the process never exits, nothing flushes.

Fix: buildTargetCommand now materializes a two-line prelude
($stdout.sync = true; $stderr.sync = true) in the session log dir and
injects it into the target ruby argv as a single -r<path> element (both
plain and bundler branches). Argv insertion is space-safe, unlike
RUBYOPT which splits on whitespace; the log dir is product-owned,
avoiding a predictable require path in shared /tmp. On write failure
the launch proceeds without the prelude (exit-only flushing, as
before). Launch mode only — attach connects to a process we did not
start. Verified: rdbg's stop-at-load still lands on the main script,
not the prelude.

Regression coverage: hard e2e test asserting the marker arrives while
the session is still paused (cannot be satisfied by the exit flush),
plus unit tests for prelude materialization and argv shape. The matrix
cell flips FAIL->PASS (175/0/6, was 174/1/6).

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

* test: dedupe pollUntil into smoke-test-utils; hermetic logDir in ruby integration smoke

Review follow-ups on #317: the new e2e test added a third verbatim copy
of pollUntil — promote it to smoke-test-utils.ts and point all three
suites at it. The ruby integration smoke test's logDir was
<cwd>/logs/tests, which buildAdapterCommand now really writes into
(sync prelude); use a per-test temp dir so the test stays hermetic and
the -r assertion never depends on repo-tree writability.

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

---------

Co-authored-by: JF <john.franklin@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants