Skip to content

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

Merged
debugmcpdev merged 2 commits into
mainfrom
fix/317-ruby-stdout-sync
Aug 12, 2026
Merged

fix(ruby): stream debuggee stdout mid-run via injected sync prelude (#317)#319
debugmcpdev merged 2 commits into
mainfrom
fix/317-ruby-stdout-sync

Conversation

@debugmcpdev

Copy link
Copy Markdown
Collaborator

Fixes #317.

Problem

Ruby block-buffers $stdout when it is a pipe ($stdout.sync == false; $stderr defaults to sync == true — which is exactly why stderr lines arrived mid-run while stdout didn't). 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 while the session sat paused at a breakpoint. The comprehensive matrix's ruby get_output cell soft-failed on exactly this: the workflow pauses at a re-armed loop breakpoint, the process never exits, nothing flushes (entries=1).

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, in both the plain and bundler branches.

Design notes:

  • Argv insertion, not RUBYOPT: a single argv element is space-safe end-to-end (spawn argv array; rdbg -c execs the command after -- verbatim). RUBYOPT splits on whitespace and would break for install paths containing spaces.
  • Session log dir, not os.tmpdir(): the file is required into the debuggee; a predictable name in shared world-writable /tmp would be a local code-injection surface on POSIX. The log dir is product-owned. Runtime generation also means no npm-bundle asset plumbing.
  • Graceful degradation: if the prelude can't be written, the launch proceeds without it (exit-only flushing, as before) and logs an error.
  • Unconditional, launch mode only — same spirit as Python's PYTHONUNBUFFERED=1. A script can set $stdout.sync = false itself to opt out. Attach connects to a process the server did not start, so nothing is injected there (documented).
  • Verified empirically that rdbg's stop-at-load still lands on the main script (line 1), not the prelude.

Tests

  • New hard e2e regression test (mcp-server-smoke-ruby.test.ts): breakpoint in the fizzbuzz loop → continue → poll get_output for iteration 1's marker → assert it arrives while the session is still paused, so the exit-flush path cannot satisfy it. Red against the pre-fix build (poll timeout), green after (<2s).
  • Unit tests for prelude materialization (idempotent reuse, tampered-content rewrite, intermediate dirs, graceful null on write failure) and argv shape (plain, bundler, degraded).
  • Integration args-tail assertion updated (ruby-session-smoke.test.ts — runs in CI with the real buildAdapterCommand).
  • Comprehensive matrix: ruby get_output flips FAIL→PASS — 175 PASS / 0 FAIL / 6 SKIP (was 174/1/6); the matrix cell stays soft by design, the new e2e test is the hard guard.
  • Full pre-push suite green on Windows; docs updated (docs/ruby/README.md Program output).

Follow-up filed: #318 (launchConfig.env silently ignored on the ruby launch path — pre-existing, out of scope here).

🤖 Generated with Claude Code

…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>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

… 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>
@debugmcpdev

Copy link
Copy Markdown
Collaborator Author

Post-PR multi-angle self-review outcome (all angles verified against the real Ruby 3.4/debug-1.11 toolchain):

Applied (second commit):

  • pollUntil was a third verbatim copy across e2e suites — promoted to tests/e2e/smoke-test-utils.ts, all three suites now import it.
  • The ruby integration smoke test's logDir pointed at <cwd>/logs/tests, which buildAdapterCommand now genuinely writes into — switched to a per-test temp dir (hermetic, and the -r assertion no longer depends on repo-tree writability).

Dismissed with rationale:

  • Dry-run writes the helper file: dryRunSpawn already creates the run log dir today; materializing the command is the point of a dry run, and the 40-byte write is part of the command. Benign.
  • mkdirSync redundant under SessionManager: deliberate — it keeps ensureRubySyncHelper self-contained for direct callers (unit/integration tests, future adapters).
  • Read-compare-rewrite "dead in production": per-run dirs make it rare, but shared-dir callers (tests, custom logDir configs) do exercise it, and it keeps concurrent same-content writes benign.
  • Hardcoded line: 15 vs RUBY_BP_LINE: matches the existing test in the same file; importing language-matrix-utils would execute module-level toolchain probes as an import side effect.
  • Helper lives under the session log dir whose default root is os.tmpdir(): acknowledged nuance to the "product-owned dir" rationale on multi-user Linux — an attacker owning the first-come /tmp/debug-mcp-server parent could race the write. Reviewers judged the runtime-write approach defensible (Ruby has no PYTHONUNBUFFERED equivalent; static assets don't survive the esbuild-only npx bundle without new plumbing). A dedicated assets dir / shipped static asset is the future hardening if we ever want it.

@debugmcpdev
debugmcpdev merged commit e8e7e1b into main Aug 12, 2026
10 checks passed
@debugmcpdev
debugmcpdev deleted the fix/317-ruby-stdout-sync branch August 12, 2026 18:05
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.

[BUG] comprehensive e2e matrix: ruby get_output marker '1: 1' not captured mid-run (soft FAIL cell)

2 participants