Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
15 changes: 14 additions & 1 deletion packages/adapter-ruby/src/ruby-debug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
getRubyVersion,
getRdbgVersion,
getRubySearchPaths,
buildRdbgInvocation
buildRdbgInvocation,
ensureRubySyncHelper
} from './utils/ruby-utils.js';

interface RubyPathCacheEntry {
Expand Down Expand Up @@ -267,18 +268,30 @@ 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 || [])
];
}

return [
config.executablePath,
...rubyArgs,
config.scriptPath,
...(config.scriptArgs || [])
];
Expand Down
40 changes: 40 additions & 0 deletions packages/adapter-ruby/src/utils/ruby-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
return new Promise((resolve) => {
const child = spawn(rubyPath, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
Expand Down
69 changes: 66 additions & 3 deletions packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, { path: string; timestamp: number }> })
.rdbgPathCache.set('default', { path: '/usr/bin/rdbg', timestamp: Date.now() });
Expand All @@ -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<string, { path: string; timestamp: number }> })
.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<string, { path: string; timestamp: number }> })
.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',
Expand Down
65 changes: 64 additions & 1 deletion packages/adapter-ruby/tests/unit/ruby-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>;
Expand Down Expand Up @@ -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');
});
});
21 changes: 17 additions & 4 deletions tests/adapters/ruby/integration/ruby-session-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand All @@ -69,7 +75,7 @@ describe('Ruby adapter - session smoke (integration)', () => {
executablePath: 'ruby',
adapterHost,
adapterPort,
logDir: fakeLogDir,
logDir,
scriptPath: sampleScriptPath,
scriptArgs: [],
launchConfig: {}
Expand All @@ -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 <script>` under the debugger.
// Command mode: rdbg runs `ruby <script>` under the debugger, with the
// stdout-sync prelude injected so puts output streams mid-run (#317).
const dashC = command.args.indexOf('-c');
expect(dashC).toBeGreaterThan(-1);
expect(command.args.slice(dashC)).toEqual(['-c', '--', 'ruby', sampleScriptPath]);
expect(command.args.slice(dashC)).toEqual([
'-c',
'--',
'ruby',
expect.stringMatching(/^-r.*mcp_stdout_sync\.rb$/),
sampleScriptPath
]);
});

it('normalizes launch config for Ruby scripts', async () => {
Expand Down
8 changes: 4 additions & 4 deletions tests/e2e/comprehensive-mcp-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const LANGUAGES: LangDef[] = [
{ language: 'rust', script: RUST_SCRIPT, bpLine: RUST_BP_LINE, available: hasRust, skipReason: hasRust ? undefined : 'Rust toolchain not installed',
outputMarker: 'Hello, MCP Debugger!' },
{ language: 'ruby', script: RUBY_SCRIPT, bpLine: RUBY_BP_LINE, available: hasRuby, skipReason: hasRuby ? undefined : 'Ruby/rdbg not installed',
outputMarker: '1: 1' }, // iteration 1's puts the loop breakpoint re-arms, so later output isn't guaranteed
outputMarker: '1: 1' }, // iteration 1's puts, streamed mid-run via the sync prelude (#317); the loop breakpoint re-arms, so later output isn't guaranteed
{ language: 'go', script: GO_SCRIPT, bpLine: GO_BP_LINE, available: hasGo, skipReason: hasGo ? undefined : 'Go/Delve not installed',
dapLaunchArgs: { mode: 'exec' }, outputMarker: 'Hello, World!' }, // launchScript set in beforeAll after build
{ language: 'dotnet', script: DOTNET_SCRIPT, bpLine: DOTNET_BP_LINE, available: hasDotnet, skipReason: hasDotnet ? undefined : '.NET/netcoredbg not installed',
Expand Down Expand Up @@ -578,9 +578,9 @@ describe(`Comprehensive MCP Debugger Test — ${ALL_TOOLS.length} Tools × ${LAN

/* ---- Tool 16: get_output (issue #218) ---- */
// Languages with an outputMarker must capture the script's own
// output (issues #223/#225). Others stay lenient: entries may
// legitimately be empty (e.g. Ruby routes debuggee stdio to the
// adapter process) — only the tool contract is asserted.
// output (issues #223/#225, #317). Others stay lenient: entries may
// legitimately be empty (adapters differ in how debuggee output is
// routed) — only the tool contract is asserted.
t0 = Date.now();
try {
const outRes = await callToolSafely(mcpClient!, 'get_output', { sessionId: currentSessionId });
Expand Down
16 changes: 1 addition & 15 deletions tests/e2e/mcp-server-break-on-exceptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { spawn, execSync, type ChildProcess } from 'child_process';
import net from 'net';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { parseSdkToolResult, callToolSafely } from './smoke-test-utils.js';
import { parseSdkToolResult, callToolSafely, pollUntil } from './smoke-test-utils.js';
import { prepareJavaExample } from './java-example-utils.js';
import { prepareRustExample } from './rust-example-utils.js';
import { skipIfSpawnBlocked } from '../test-utils/helpers/adapter-spawn.js';
Expand Down Expand Up @@ -63,20 +63,6 @@ async function getSessionSnapshot(client: Client, sessionId: string): Promise<Se
return sessions.find(s => s.id === sessionId);
}

async function pollUntil<T>(
fn: () => Promise<T | undefined>,
timeoutMs: number,
intervalMs = 250
): Promise<T | undefined> {
const deadline = Date.now() + timeoutMs;
for (;;) {
const value = await fn();
if (value !== undefined) return value;
if (Date.now() > deadline) return undefined;
await new Promise(r => setTimeout(r, intervalMs));
}
}

async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
Expand Down
Loading
Loading