diff --git a/docs/ruby/README.md b/docs/ruby/README.md index 327678e4..edb7cff5 100644 --- a/docs/ruby/README.md +++ b/docs/ruby/README.md @@ -74,6 +74,15 @@ events. The proxy forwards those lines as synthesized `stdout`/`stderr` entries, output as usual; rdbg's own `DEBUGGER:` stderr banners are excluded and only appear in the session log. +Because Ruby block-buffers `$stdout` when it is a pipe, launch mode also injects a small +prelude (via `ruby -r`) that sets `$stdout.sync = true` and `$stderr.sync = true` before +the script runs. Without it, `puts` output would only appear in `get_output` after the +process exits; with it, output streams in near-real-time — including while the session is +paused at a breakpoint (issue #317). A script that genuinely needs buffered stdout can +set `$stdout.sync = false` itself. Attach mode connects to a process the server did not +start, so no prelude is injected there — set `$stdout.sync = true` in your program if you +need mid-run output while attached. + ### Bundler projects Pass `useBundler` through the launch configuration to run the target via `bundle exec`: diff --git a/packages/adapter-ruby/src/ruby-debug-adapter.ts b/packages/adapter-ruby/src/ruby-debug-adapter.ts index 1e04a25d..196872f7 100644 --- a/packages/adapter-ruby/src/ruby-debug-adapter.ts +++ b/packages/adapter-ruby/src/ruby-debug-adapter.ts @@ -28,7 +28,8 @@ import { getRubyVersion, getRdbgVersion, getRubySearchPaths, - buildRdbgInvocation + buildRdbgInvocation, + ensureRubySyncHelper } from './utils/ruby-utils.js'; interface RubyPathCacheEntry { @@ -267,11 +268,22 @@ export class RubyDebugAdapter extends EventEmitter implements IDebugAdapter { } private buildTargetCommand(config: AdapterConfig, launchConfig: RubyLaunchConfig): string[] { + // Ruby block-buffers $stdout when it is a pipe, and rdbg -c hands the + // debuggee the adapter process's piped stdio — without intervention, + // puts output only reaches the proxy's stdio scraper at process exit + // (issue #317). Inject a tiny -r prelude enabling sync mode. A single + // argv element is space-safe (spawn argv array; rdbg execs the command + // after `--` verbatim), unlike RUBYOPT which splits on whitespace. On + // failure, launch proceeds with exit-only flushing. + const syncHelper = ensureRubySyncHelper(config.logDir, this.dependencies.logger); + const rubyArgs = syncHelper ? [`-r${syncHelper}`] : []; + if (launchConfig.useBundler) { return [ launchConfig.bundlePath || 'bundle', 'exec', config.executablePath, + ...rubyArgs, config.scriptPath, ...(config.scriptArgs || []) ]; @@ -279,6 +291,7 @@ export class RubyDebugAdapter extends EventEmitter implements IDebugAdapter { return [ config.executablePath, + ...rubyArgs, config.scriptPath, ...(config.scriptArgs || []) ]; diff --git a/packages/adapter-ruby/src/utils/ruby-utils.ts b/packages/adapter-ruby/src/utils/ruby-utils.ts index 70544dd9..e787310e 100644 --- a/packages/adapter-ruby/src/utils/ruby-utils.ts +++ b/packages/adapter-ruby/src/utils/ruby-utils.ts @@ -195,6 +195,46 @@ export function buildRdbgInvocation( return { command: rdbgPath, args }; } +/** + * Prelude injected into the debuggee via `ruby -r` (issue #317). Ruby + * block-buffers $stdout on pipes, and rdbg -c hands the debuggee the adapter + * process's piped stdio — without sync mode, puts output only reaches the + * proxy's stdio scraper when the process exits. + */ +export const RUBY_SYNC_HELPER_CONTENT = '$stdout.sync = true\n$stderr.sync = true\n'; +export const RUBY_SYNC_HELPER_FILENAME = 'mcp_stdout_sync.rb'; + +/** + * Materialize the stdout-sync prelude in `dir` (the session log dir — a path + * the server owns; never a shared world-writable location, since this file is + * `require`d into the debuggee). Idempotent: rewrites only on content + * mismatch. Returns null on any failure so the launch degrades to exit-only + * output flushing instead of failing. + */ +export function ensureRubySyncHelper(dir: string, logger: Logger = noopLogger): string | null { + const helperPath = path.join(dir, RUBY_SYNC_HELPER_FILENAME); + try { + fs.mkdirSync(dir, { recursive: true }); + let existing: string | null = null; + try { + existing = fs.readFileSync(helperPath, 'utf8'); + } catch { + // Not there yet. + } + if (existing !== RUBY_SYNC_HELPER_CONTENT) { + fs.writeFileSync(helperPath, RUBY_SYNC_HELPER_CONTENT, 'utf8'); + } + return helperPath; + } catch (error) { + logger.error?.( + `[ruby-utils] Cannot materialize stdout-sync helper at ${helperPath}: ` + + `${error instanceof Error ? error.message : String(error)}. ` + + `Debuggee stdout will only flush at process exit.` + ); + return null; + } +} + export async function getRubyVersion(rubyPath: string): Promise { return new Promise((resolve) => { const child = spawn(rubyPath, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); diff --git a/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts b/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts index 5be0a03a..1b72ad51 100644 --- a/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts +++ b/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts @@ -10,11 +10,12 @@ vi.mock('../../src/utils/ruby-utils.js', async (importOriginal) => { getRubyVersion: vi.fn(), findRdbgExecutable: vi.fn(), getRdbgVersion: vi.fn(), - getRubySearchPaths: vi.fn().mockReturnValue(['/usr/bin']) + getRubySearchPaths: vi.fn().mockReturnValue(['/usr/bin']), + ensureRubySyncHelper: vi.fn() }; }); -const { findRubyExecutable, getRubyVersion, findRdbgExecutable, getRdbgVersion, getRubySearchPaths } = await import('../../src/utils/ruby-utils.js'); +const { findRubyExecutable, getRubyVersion, findRdbgExecutable, getRdbgVersion, getRubySearchPaths, ensureRubySyncHelper } = await import('../../src/utils/ruby-utils.js'); const createDependencies = () => ({ fileSystem: {} as unknown, @@ -70,7 +71,8 @@ describe('RubyDebugAdapter', () => { expect(result.errors.map((entry) => entry.code)).toContain('RDBG_NOT_FOUND'); }); - it('builds an rdbg adapter command', () => { + it('builds an rdbg adapter command with the stdout-sync prelude', () => { + vi.mocked(ensureRubySyncHelper).mockReturnValue('/tmp/logs/mcp_stdout_sync.rb'); const adapter = new RubyDebugAdapter(createDependencies()); (adapter as unknown as { rdbgPathCache: Map }) .rdbgPathCache.set('default', { path: '/usr/bin/rdbg', timestamp: Date.now() }); @@ -87,6 +89,67 @@ describe('RubyDebugAdapter', () => { }); expect(command.command).toBe('/usr/bin/rdbg'); + expect(command.args).toEqual([ + '--open', + '--host', '127.0.0.1', + '--port', '8123', + '-c', + '--', + '/usr/bin/ruby', + '-r/tmp/logs/mcp_stdout_sync.rb', + '/workspace/app.rb', + 'one', + 'two' + ]); + expect(ensureRubySyncHelper).toHaveBeenCalledWith('/tmp/logs', expect.anything()); + }); + + it('injects the stdout-sync prelude in the bundler branch', () => { + vi.mocked(ensureRubySyncHelper).mockReturnValue('/tmp/logs/mcp_stdout_sync.rb'); + const adapter = new RubyDebugAdapter(createDependencies()); + (adapter as unknown as { rdbgPathCache: Map }) + .rdbgPathCache.set('default', { path: '/usr/bin/rdbg', timestamp: Date.now() }); + + const command = adapter.buildAdapterCommand({ + sessionId: 'ruby-session', + executablePath: '/usr/bin/ruby', + adapterHost: '127.0.0.1', + adapterPort: 8123, + logDir: '/tmp/logs', + scriptPath: '/workspace/app.rb', + scriptArgs: [], + launchConfig: { useBundler: true, bundlePath: '/usr/local/bin/bundle' } + }); + + const dashC = command.args.indexOf('-c'); + expect(command.args.slice(dashC)).toEqual([ + '-c', + '--', + '/usr/local/bin/bundle', + 'exec', + '/usr/bin/ruby', + '-r/tmp/logs/mcp_stdout_sync.rb', + '/workspace/app.rb' + ]); + }); + + it('launches without the prelude when the sync helper cannot be materialized', () => { + vi.mocked(ensureRubySyncHelper).mockReturnValue(null); + const adapter = new RubyDebugAdapter(createDependencies()); + (adapter as unknown as { rdbgPathCache: Map }) + .rdbgPathCache.set('default', { path: '/usr/bin/rdbg', timestamp: Date.now() }); + + const command = adapter.buildAdapterCommand({ + sessionId: 'ruby-session', + executablePath: '/usr/bin/ruby', + adapterHost: '127.0.0.1', + adapterPort: 8123, + logDir: '/tmp/logs', + scriptPath: '/workspace/app.rb', + scriptArgs: ['one', 'two'], + launchConfig: {} + }); + expect(command.args).toEqual([ '--open', '--host', '127.0.0.1', diff --git a/packages/adapter-ruby/tests/unit/ruby-utils.test.ts b/packages/adapter-ruby/tests/unit/ruby-utils.test.ts index f542a149..101b5cb6 100644 --- a/packages/adapter-ruby/tests/unit/ruby-utils.test.ts +++ b/packages/adapter-ruby/tests/unit/ruby-utils.test.ts @@ -13,7 +13,10 @@ import { findRdbgExecutable, getRubyVersion, getRdbgVersion, - buildRdbgInvocation + buildRdbgInvocation, + ensureRubySyncHelper, + RUBY_SYNC_HELPER_CONTENT, + RUBY_SYNC_HELPER_FILENAME } from '../../src/utils/ruby-utils.js'; const whichMock = vi.mocked(which) as unknown as ReturnType; @@ -117,3 +120,63 @@ describe('buildRdbgInvocation platform behavior', () => { }); }); }); + +describe('ensureRubySyncHelper', () => { + let scratchDir: string; + + beforeEach(() => { + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-sync-')); + }); + + afterEach(() => { + fs.rmSync(scratchDir, { recursive: true, force: true }); + }); + + it('creates the helper file with the sync prelude and returns its absolute path', () => { + const helperPath = ensureRubySyncHelper(scratchDir); + + expect(helperPath).toBe(path.join(scratchDir, RUBY_SYNC_HELPER_FILENAME)); + expect(fs.readFileSync(helperPath!, 'utf8')).toBe(RUBY_SYNC_HELPER_CONTENT); + expect(RUBY_SYNC_HELPER_CONTENT).toContain('$stdout.sync = true'); + expect(RUBY_SYNC_HELPER_CONTENT).toContain('$stderr.sync = true'); + }); + + it('creates intermediate directories when the log dir does not exist yet', () => { + const nestedDir = path.join(scratchDir, 'sessions', 'abc123'); + + const helperPath = ensureRubySyncHelper(nestedDir); + + expect(helperPath).toBe(path.join(nestedDir, RUBY_SYNC_HELPER_FILENAME)); + expect(fs.readFileSync(helperPath!, 'utf8')).toBe(RUBY_SYNC_HELPER_CONTENT); + }); + + it('reuses an existing helper and rewrites it when the content was tampered with', () => { + const first = ensureRubySyncHelper(scratchDir); + const untouchedMtime = fs.statSync(first!).mtimeMs; + + // Idempotent reuse: second call returns the same path without error. + expect(ensureRubySyncHelper(scratchDir)).toBe(first); + + // Tampered content is restored. + fs.writeFileSync(first!, '# tampered\n', 'utf8'); + const restored = ensureRubySyncHelper(scratchDir); + expect(restored).toBe(first); + expect(fs.readFileSync(first!, 'utf8')).toBe(RUBY_SYNC_HELPER_CONTENT); + expect(untouchedMtime).toBeDefined(); + }); + + it('returns null instead of throwing when the helper cannot be written', () => { + // A file where a directory component is expected makes mkdir fail on + // both Windows and POSIX. + const blocker = path.join(scratchDir, 'blocker'); + fs.writeFileSync(blocker, 'not a directory', 'utf8'); + const impossibleDir = path.join(blocker, 'sub'); + + const errors: string[] = []; + const helperPath = ensureRubySyncHelper(impossibleDir, { error: (msg: string) => errors.push(msg) }); + + expect(helperPath).toBeNull(); + expect(errors.length).toBe(1); + expect(errors[0]).toContain('stdout-sync helper'); + }); +}); diff --git a/tests/adapters/ruby/integration/ruby-session-smoke.test.ts b/tests/adapters/ruby/integration/ruby-session-smoke.test.ts index 6d1c5426..77eeed25 100644 --- a/tests/adapters/ruby/integration/ruby-session-smoke.test.ts +++ b/tests/adapters/ruby/integration/ruby-session-smoke.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; import path from 'path'; import type { AdapterDependencies } from '@debugmcp/shared'; @@ -39,20 +41,24 @@ describe('Ruby adapter - session smoke (integration)', () => { const adapterPort = 48767; const sessionId = 'session-ruby-smoke'; const adapterHost = '127.0.0.1'; - const fakeLogDir = path.join(process.cwd(), 'logs', 'tests'); const sampleScriptPath = path.join(process.cwd(), 'examples', 'ruby', 'fizzbuzz.rb'); // A real existing executable path so invocation construction is exercised // without requiring an actual Ruby toolchain. const fakeRdbgPath = process.execPath; + // buildAdapterCommand writes the stdout-sync prelude (#317) into the log + // dir, so use a throwaway temp dir to keep the test hermetic. + let logDir: string; let originalRdbgPath: string | undefined; beforeEach(() => { + logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruby-smoke-logs-')); originalRdbgPath = process.env.RDBG_PATH; process.env.RDBG_PATH = fakeRdbgPath; }); afterEach(() => { + fs.rmSync(logDir, { recursive: true, force: true }); if (typeof originalRdbgPath === 'string') { process.env.RDBG_PATH = originalRdbgPath; } else { @@ -69,7 +75,7 @@ describe('Ruby adapter - session smoke (integration)', () => { executablePath: 'ruby', adapterHost, adapterPort, - logDir: fakeLogDir, + logDir, scriptPath: sampleScriptPath, scriptArgs: [], launchConfig: {} @@ -83,10 +89,17 @@ describe('Ruby adapter - session smoke (integration)', () => { expect(command.args).not.toContain('--nonstop'); // Never the vscode frontend mode, which tries to launch an editor. expect(command.args.every(arg => !arg.includes('vscode'))).toBe(true); - // Command mode: rdbg runs `ruby