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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Debuggee exit code surfaced** — the exit code from the DAP `exited` event is stored on the session and returned as `exitCode` in `list_debug_sessions`, making a crash (non-zero) distinguishable from a clean exit (#220)

### Fixed
- **Ruby and Rust-on-Windows debuggee output captured** — adapters whose debuggee inherits the adapter process's stdio (rdbg `-c` on all platforms, CodeLLDB's console mode on Windows) now opt into proxy-side forwarding: the adapter's stdout/stderr lines are synthesized into DAP `output` events and land in the same per-session buffer `get_output` reads. rdbg's own `DEBUGGER:` stderr banners are excluded (they still go to the session log, sanitized, as before); the forwarded copy is raw, matching how debugpy/js-debug output reaches the buffer. Ruby attach mode is unchanged — no adapter process exists there, so output stays on the target's own terminal (fixes #222, fixes #223)
- **Go debuggee output captured** — the Go adapter now launches Delve with `outputMode: 'remote'`, so the target program's stdout/stderr arrives as DAP output events and shows up in `get_output` instead of vanishing into dlv's own stdio (fixes #225)
- **Rust launch config uses CodeLLDB's canonical `terminal` attribute** — the adapter emitted a debugpy-style `console` key that CodeLLDB only honors as a legacy alias; it now emits `terminal: 'console'`, translating any user-supplied legacy `console` values (#223 — the Windows output-capture gap the issue uncovered is tracked separately: LLDB's console mode inherits the debuggee's stdio rather than emitting DAP output events)
- **js-debug launch no longer hangs on fast-exiting scripts** — the launch barrier now settles when the debuggee emits `terminated`/`exited` during the launch window, and `dispose()` rejects a still-pending wait as a structural backstop; `start_debugging` for a JavaScript script that crashes (or completes) within seconds of launch now returns promptly with state `stopped` instead of hanging past the MCP client timeout. Also removes an intermittent ~10s stall when js-debug's `initialized` event raced the handshake listener (fixes #242)
Expand Down
13 changes: 13 additions & 0 deletions docs/ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ reports a `Local variables` scope), `get_local_variables`, `evaluate_expression`
(evaluated in rdbg's `repl` context — expressions can read and modify program state),
`step_over` / `step_into` / `step_out`, and `continue_execution`.

### Program output

`rdbg -c` runs the script as a child of the adapter process with inherited stdio, so the
program's `puts`/`warn` output lands on the adapter's pipes rather than in DAP output
events. The proxy forwards those lines as synthesized `stdout`/`stderr` entries, so
`get_output` (and the `debug://sessions/{id}/output` resource) returns the script's
output as usual; rdbg's own `DEBUGGER:` stderr banners are excluded and only appear in
the session log.

### Bundler projects

Pass `useBundler` through the launch configuration to run the target via `bundle exec`:
Expand Down Expand Up @@ -106,6 +115,10 @@ detach_from_process { "sessionId": "...", "terminateProcess": false }
The target keeps running after detach, and `rdbg` keeps listening — you can re-attach
later. Pass `terminateProcess: true` to kill the target instead.

Note: in attach mode there is no adapter process between mcp-debugger and the target, so
`get_output` captures nothing — the program's stdio stays on whatever terminal (or pod
log) the process was started in.

## Remote attach (containers and Kubernetes)

Because attach connects directly to rdbg's TCP socket, anything that forwards a TCP port
Expand Down
2 changes: 1 addition & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ Gets the debuggee's output (stdout/stderr/console) captured for a session. Outpu
- Works while the program is running and after it finishes — output stays readable until `close_debug_session`. Re-launching a session starts a fresh buffer (seq restarts at 1).
- `hasMore: true` means more entries matched than `limit` allowed; call again with `since: nextSince`.
- Incremental polling recipe: call once, remember `nextSince`, and pass it as `since` on the next call — you'll only ever see new output.
- Adapter support: Python (`redirectOutput`), JavaScript (`outputCapture: 'std'`), Go (`outputMode: 'remote'`), and Java forward debuggee stdio as output events; .NET typically does as well. Ruby routes debuggee stdio to the adapter process, so no entries are captured (#222); Rust does the same on Windows, where LLDB's console mode inherits stdio instead of emitting output events (#223).
- Adapter support: Python (`redirectOutput`), JavaScript (`outputCapture: 'std'`), Go (`outputMode: 'remote'`), and Java forward debuggee stdio as output events; .NET typically does as well. Ruby launch mode and Rust on Windows route debuggee stdio to the adapter process; the proxy forwards those lines as synthesized `stdout`/`stderr` events (#222/#223), excluding rdbg's `DEBUGGER:` banners. Ruby **attach** captures nothing — the target's stdio stays wherever the process was started.

#### Output resources & subscriptions

Expand Down
1 change: 1 addition & 0 deletions examples/ruby/fizzbuzz.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ def main
end

main
warn 'fizzbuzz complete'
2 changes: 1 addition & 1 deletion packages/mcp-debugger/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@
"@debugmcp/shared": "workspace:*"
},
"author": "Sycamore LLC <debug@sycamore.llc> (https://github.com/debugmcp)"
}
}
8 changes: 7 additions & 1 deletion packages/shared/src/interfaces/adapter-policy-ruby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,13 @@ export const RubyAdapterPolicy: AdapterPolicy = {
host: payload.adapterHost,
port: payload.adapterPort,
logDir: payload.logDir,
env: payload.adapterCommand.env
env: payload.adapterCommand.env,
// rdbg -c runs the debuggee as a child of the adapter process with
// inherited stdio while DAP travels over TCP — the program's output
// only ever appears on the adapter's pipes (issue #222). rdbg's own
// stderr banners ("DEBUGGER: wait for debugger connection...") are
// excluded from forwarding but still logged.
forwardStdio: { excludeStderrLinePattern: /^DEBUGGER: / }
};
}
};
14 changes: 12 additions & 2 deletions packages/shared/src/interfaces/adapter-policy-rust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,14 @@ export const RustAdapterPolicy: AdapterPolicy = {
* Get the configuration for spawning the Rust debug adapter (CodeLLDB)
*/
getAdapterSpawnConfig: (payload, platform: NodeJS.Platform = process.platform, arch: NodeJS.Architecture = process.arch) => {
// Windows only (issue #223): CodeLLDB's console mode performs no stdio
// redirection, and unlike POSIX (where LLDB holds the debuggee's stdio
// pipes and CodeLLDB emits DAP output events from the STDOUT/STDERR
// process broadcasts), LLDB on Windows lets the debuggee inherit the
// adapter process's pipes. Forward those as output events there; on
// POSIX the channels are exclusive, so this stays off to avoid noise.
const forwardStdio = platform === 'win32' ? {} : undefined;

// If a custom adapter command was provided, use it directly
if (payload.adapterCommand) {
return {
Expand All @@ -278,7 +286,8 @@ export const RustAdapterPolicy: AdapterPolicy = {
host: payload.adapterHost,
port: payload.adapterPort,
logDir: payload.logDir,
env: payload.adapterCommand.env
env: payload.adapterCommand.env,
forwardStdio
};
}

Expand Down Expand Up @@ -320,7 +329,8 @@ export const RustAdapterPolicy: AdapterPolicy = {
...process.env,
// Windows specific: enable native PDB reader
...(platform === 'win32' ? { LLDB_USE_NATIVE_PDB_READER: '1' } : {})
}
},
forwardStdio
};
}
};
16 changes: 16 additions & 0 deletions packages/shared/src/interfaces/adapter-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,22 @@ export type AdapterSpawnConfig =
logDir: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
/**
* Forward the adapter process's stdio as synthesized DAP 'output'
* events (issue #222). Opt-in for adapters whose launch-mode debuggee
* inherits the adapter process's stdio instead of having it converted
* to DAP output events — e.g. rdbg -c (all platforms) and CodeLLDB's
* console mode on Windows. Policies that leave this unset keep the
* existing behavior: adapter stdio is drained to logs only.
*/
forwardStdio?: {
/**
* stderr lines matching this pattern are adapter diagnostics (e.g.
* rdbg's `DEBUGGER: ` banners): still logged, never forwarded as
* debuggee output.
*/
excludeStderrLinePattern?: RegExp;
};
}
| {
mode: 'connect';
Expand Down
27 changes: 27 additions & 0 deletions packages/shared/tests/unit/adapter-policy-rust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,33 @@ describe('RustAdapterPolicy', () => {
expect(config.args).toEqual(['--port', '9000']);
expect(config.env?.LLDB_USE_NATIVE_PDB_READER).toBe('1');
});

it('opts into adapter-stdio forwarding on win32 only (issue #223)', () => {
// Windows: LLDB's console mode lets the debuggee inherit the adapter
// process's stdio — forwarding is the only way output reaches get_output.
const win = RustAdapterPolicy.getAdapterSpawnConfig!({
adapterHost: '127.0.0.1',
adapterPort: 9000,
logDir: '/tmp/logs'
}, 'win32', 'x64');
expect(win.forwardStdio).toEqual({});

const winCustom = RustAdapterPolicy.getAdapterSpawnConfig!({
adapterCommand: { command: 'custom', args: [] },
adapterHost: '127.0.0.1',
adapterPort: 9000,
logDir: '/tmp/logs'
}, 'win32', 'x64');
expect(winCustom.forwardStdio).toEqual({});

// POSIX: CodeLLDB emits DAP output events itself (LLDB holds the pipes)
const linux = RustAdapterPolicy.getAdapterSpawnConfig!({
adapterHost: '127.0.0.1',
adapterPort: 9000,
logDir: '/tmp/logs'
}, 'linux', 'x64');
expect(linux.forwardStdio).toBeUndefined();
});
});

it('handles reverse requests via DAP client behavior', async () => {
Expand Down
4 changes: 2 additions & 2 deletions skills/debugging/references/ruby.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ For containers/pods, use the **debuggee's** filesystem paths in `set_breakpoint`
## Quirks

- **Launch always stops at load.** rdbg suspends the script before the first line so breakpoints bind even for scripts that finish in milliseconds. With `stopOnEntry: false` (default) that entry pause is released automatically; with `dapLaunchArgs: { "stopOnEntry": true }` you get control at the first line.
- **KNOWN ISSUE — `get_output` captures nothing for Ruby (issue #222).** rdbg routes debuggee stdout/stderr to the adapter process, so no output entries are recorded. Do not diagnose by reading stdout. Instead: have the script write results to a file you can read, or pause at a breakpoint and use `evaluate_expression` / `get_local_variables` to inspect state directly.
- **Debuggee output is captured in launch mode.** rdbg hands the debuggee the adapter process's stdio; the proxy forwards it as `get_output` entries (categories `stdout`/`stderr`, rdbg's own `DEBUGGER:` banners excluded). **Attach mode captures nothing** — the target's stdio stays wherever the process was started; inspect state via `evaluate_expression` / `get_local_variables` there instead.
- **evaluate_expression runs in rdbg's `repl` context** — expressions can read *and modify* program state (`x = 5` works). Useful for testing fixes live.
- **Scope name is `Local variables`** (not "Locals"); `get_local_variables` handles this for you. Locals are only reported while stopped.
- **Windows `.bat` shim bypass is automatic** — if spawn fails anyway, set `RDBG_PATH` to the rdbg script inside your Ruby installation's `bin` directory.
Expand All @@ -85,4 +85,4 @@ For containers/pods, use the **debuggee's** filesystem paths in `set_breakpoint`
| Connect refused on attach | Target not listening | Start it with `rdbg --open --host <h> --port <p>`; rdbg prints `Debugger can attach via TCP/IP` when ready; verify port-forwarding |
| Breakpoint not verified on attach | Host path used for a remote/container target | Use the path as the debuggee sees it (e.g. `/app/app.rb` from `get_stack_trace`) |
| Locals empty | Session not paused | Hit a breakpoint or `pause_execution` first — rdbg reports locals only while stopped |
| `get_output` returns no entries | Issue #222 — Ruby debuggee stdio is not captured | Write to a file from the script, or inspect via `evaluate_expression` at a breakpoint |
| `get_output` returns no entries on attach | Attached target's stdio stays on its own terminal/pod | Read the target's own logs, or inspect via `evaluate_expression` at a breakpoint |
3 changes: 1 addition & 2 deletions skills/debugging/references/rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Not supported. The Rust adapter implements launch mode only — `attach_to_proce

- **Windows toolchain (critical):** MSVC-built binaries give control flow only — breakpoints/stepping work, but strings, Vecs, and structs show `<unavailable>` or corrupted values. `RUST_MSVC_BEHAVIOR` controls what happens when an MSVC binary is detected: `warn` (default — log and proceed), `error` (fail with `ENVIRONMENT_INVALID`), `continue` (silent). Check any binary first with `mcp-debugger check-rust-binary target/debug/app.exe` — it reports `Toolchain: GNU` or `MSVC`.
- **Windows initial stop:** debugging may first stop in system functions (not user code). Issue one `continue_execution` to reach your breakpoint; auto-continue through these system stops is not yet implemented.
- **KNOWN ISSUE — `get_output` may be empty on Windows (issue #223):** in CodeLLDB's console mode the debuggee inherits the adapter process's stdio, and on Windows LLDB does not convert it to DAP output events. Do not rely on print debugging there — inspect state with `evaluate_expression`, `get_local_variables`, and breakpoints instead.
- **Debuggee output is captured:** on POSIX CodeLLDB forwards the program's stdio as DAP output events; on Windows (where LLDB's console mode makes the debuggee inherit the adapter process's stdio) the proxy forwards the adapter's stdio instead. Either way the program's stdout/stderr arrives as `get_output` entries.
- Expression evaluation goes through LLDB: simple field access and method calls like `my_vec.len()` work, but Rust-specific syntax (closures, trait methods) may not.
- Debug builds only: release builds need `debug = true` in `[profile.release]` and still inline/optimize away variables. Prefer `opt-level = 0`.
- GNU builds of crates that import Windows DLLs (`tokio`, `windows-sys`, `parking_lot_core`, ...) need full MinGW binutils — rustup's self-contained toolchain lacks `as.exe`, so `dlltool` fails. Install via MSYS2 (`mingw-w64-x86_64-binutils`, `-gcc`) and prepend `C:\msys64\mingw64\bin` to PATH.
Expand All @@ -68,4 +68,3 @@ Not supported. The Rust adapter implements launch mode only — `attach_to_proce
| "Can't find CodeLLDB" | Adapter not vendored / npx package on non-Linux | Run `pnpm --filter @debugmcp/adapter-rust run build:adapter`, or set `CODELLDB_PATH` |
| First stop is in system/ntdll frames | Windows initial system breakpoint | `continue_execution` once, then you land on your breakpoint |
| `dlltool ... CreateProcess` build error | rustup GNU toolchain missing `as.exe` | Install MSYS2 mingw-w64 binutils/gcc; prepend `C:\msys64\mingw64\bin` to PATH |
| `get_output` returns no stdout (Windows) | Issue #223 — debuggee stdio inherited by adapter process | Use `evaluate_expression` / variables at breakpoints instead |
44 changes: 37 additions & 7 deletions src/proxy/dap-proxy-adapter-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
AdapterSpawnResult
} from './dap-proxy-interfaces.js';

/** Which adapter-process stream a forwarded line came from. */
export type AdapterStdioSource = 'stdout' | 'stderr';

/**
* Configuration for spawning any debug adapter
*/
Expand All @@ -22,6 +25,15 @@ export interface GenericAdapterConfig {
logDir: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
/**
* When set, every raw line read from the adapter process's stdout/stderr —
* including blank lines, unsanitized — is also delivered here (issue #222:
* some adapters hand the debuggee their own stdio, so these lines ARE the
* program's output). The sanitized, blank-dropping log path is unchanged;
* the redaction that protects persisted logs must not rewrite what the
* debugging client sees, matching debugpy/js-debug output-event behavior.
*/
onStdioLine?: (source: AdapterStdioSource, line: string) => void;
}

/**
Expand Down Expand Up @@ -129,7 +141,7 @@ export class GenericAdapterManager {
this.logger.info(`[AdapterManager] Spawned adapter process PID: ${adapterProcess.pid} (windowsHide=${!!spawnOptions.windowsHide}, detached=${!!spawnOptions.detached})`);

// Set up error handlers and stderr capture
this.setupProcessHandlers(adapterProcess);
this.setupProcessHandlers(adapterProcess, config.onStdioLine);

return {
process: adapterProcess,
Expand All @@ -140,7 +152,10 @@ export class GenericAdapterManager {
/**
* Set up process event handlers
*/
private setupProcessHandlers(adapterProcess: ChildProcess): void {
private setupProcessHandlers(
adapterProcess: ChildProcess,
onStdioLine?: (source: AdapterStdioSource, line: string) => void
): void {
adapterProcess.on('error', (err: Error) => {
this.logger.error('[AdapterManager] Adapter process spawn error:', err);
});
Expand All @@ -150,17 +165,21 @@ export class GenericAdapterManager {
// assignment split across two chunks would otherwise leak its tail past
// the key/value redaction patterns (issues #151/#153).
if (adapterProcess.stderr) {
this.consumeStream(adapterProcess.stderr, line =>
this.logger.error(`[AdapterManager STDERR] ${line}`)
this.consumeStream(
adapterProcess.stderr,
line => this.logger.error(`[AdapterManager STDERR] ${line}`),
onStdioLine && (line => onStdioLine('stderr', line))
);
}

// stdout is piped but carries no DAP traffic (that goes over TCP); drain
// it through the same sanitized path so a chatty adapter cannot fill the
// pipe buffer and stall, and its diagnostics land in the log at debug.
if (adapterProcess.stdout) {
this.consumeStream(adapterProcess.stdout, line =>
this.logger.debug(`[AdapterManager STDOUT] ${line}`)
this.consumeStream(
adapterProcess.stdout,
line => this.logger.debug(`[AdapterManager STDOUT] ${line}`),
onStdioLine && (line => onStdioLine('stdout', line))
);
}

Expand All @@ -175,9 +194,20 @@ export class GenericAdapterManager {
* process 'exit' — the pipe can still deliver the rest of a split line
* after exit, which would re-create the straddle leak (issue #151).
*/
private consumeStream(stream: Readable, logLine: (line: string) => void): void {
private consumeStream(
stream: Readable,
logLine: (line: string) => void,
forwardLine?: (line: string) => void
): void {
const buffer = new LineBuffer();
const record = (lines: string[]) => {
if (forwardLine) {
// Debuggee-output fan-out (issue #222): raw lines, blank lines
// included — they are program output, not log noise.
for (const line of lines) {
forwardLine(line);
}
}
for (const line of sanitizeStderr(lines.filter(l => l.trim().length > 0))) {
logLine(line);
}
Expand Down
Loading
Loading