Skip to content

Commit 8f2983d

Browse files
debugmcpdevCI Botclaude
authored
fix(#355,#353,#357,#363): mock stop reason, .NET attach pause, ruby live-sync hint, Docker sourceMap derivation (#377)
* fix(#355,#363,#357,#353): misc adapter fixes — mock function-bp reason, Docker rust/cpp sourceMap derivation, ruby attach sync guidance, dotnet attach pause - #355: mock adapter now reports stop reason 'function breakpoint' (with hitBreakpointIds) when a function breakpoint is hit; line breakpoints keep 'breakpoint' and also carry hitBreakpointIds. When a line and a function breakpoint share a line, the plain line breakpoint wins the tiebreak. DAP-level tests spawn the compiled process over TCP. - #363: host-built rust/cpp binaries debugged in the container never bound file+line breakpoints (host DWARF paths vs /workspace). New deriveSourceMapFromBinary in @debugmcp/codelldb-common scans the binary for embedded absolute path strings and derives hostPrefix -> workspaceRoot sourceMap entries (rustc std-lib paths and no-op mappings excluded, capped at 3, ranked by match count). Wired into rust/cpp transformLaunchConfig for container mode with prebuilt binaries only; caller-supplied sourceMap always wins. Docs added for both languages with the manual sourceMap fallback. - #357: the ruby attach live-sync warning for rdbg's "<path> is not available" now appends topology guidance (target-side paths, localfsMap) instead of just scaring; launch sessions and other languages unchanged. Ruby docs gain an "Attaching across a container boundary" subsection. - #353: reproduced on Linux — netcoredbg does not suspend the target on attach, so the session reported PAUSED while the debuggee kept running: stackTrace failed with 0x80131302 (process not synchronized) and pause_execution short-circuited with "Already paused". DotnetAdapterPolicy now declares getAttachBehavior pauseAfterAttach (same contract as python/ruby/js), making the post-attach PAUSED state real. New dotnet attach-by-PID e2e smoke test (self-skips without netcoredbg). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: CI fixes — bind mock DAP test to 127.0.0.1; scrub example home paths - mock-adapter-process test passes --host 127.0.0.1: the default 'localhost' bind can resolve to ::1 on CI runners while the client dials 127.0.0.1 (ECONNREFUSED x4 on ubuntu CI) - replace /home/you and /home/host examples with /home/user to satisfy the personal-path audit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: linear-time path splitting (CodeQL js/polynomial-redos); skip g++ fixtures on win32 - split on a single separator char + filter instead of /[\\/]+/ over binary-derived strings; trailing-separator strip without a regex - the derive fixtures only exercise a Linux-container feature; MinGW's 8.3 short paths (C:\Users\RUNNER~1\...) made them unstable on Windows CI Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: CI Bot <ci@example.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 5834387 commit 8f2983d

17 files changed

Lines changed: 1274 additions & 35 deletions

‎docs/cpp/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,23 @@ Data breakpoints (hardware watchpoints), disassembly view, instruction stepping,
9494
- The launch default `"uncaught"` sets **no filter**: LLDB has no uncaught-only C++ filter, and an uncaught exception reaches `std::terminate` → SIGABRT, which the debugger stops on natively — you still land at the crash with stack and locals.
9595
- `cpp_catch` (break on catch) is available via explicit filter configuration.
9696

97+
## Debugging host-built binaries in Docker
98+
99+
A binary compiled on the host embeds host-absolute source paths (e.g. `/home/user/proj/src/main.cpp`) in its DWARF debug info. When the mcp-debugger container mounts the project at `/workspace`, breakpoint requests use `/workspace/...` paths and CodeLLDB cannot match them against the DWARF paths — file+line breakpoints and logpoints silently never bind (function breakpoints and pause still work; issue #363).
100+
101+
In container mode (`MCP_CONTAINER=true`) the adapter auto-derives a best-effort `sourceMap` for prebuilt binaries by scanning the binary for embedded host paths whose suffixes exist under the workspace mount (`MCP_WORKSPACE_ROOT`, default `/workspace`), so this usually just works. If auto-derivation misses (unusual layouts, stripped path strings), pass the mapping explicitly — a caller-supplied `sourceMap` always wins:
102+
103+
```json
104+
{
105+
"scriptPath": "/workspace/build/app",
106+
"dapLaunchArgs": {
107+
"sourceMap": { "/home/user/proj": "/workspace" }
108+
}
109+
}
110+
```
111+
112+
Alternatively, compile inside the container (or with `-fdebug-prefix-map=/home/user/proj=/workspace`) so the DWARF paths match the mount directly.
113+
97114
## Troubleshooting
98115

99116
- **"CodeLLDB executable not found"** — run `pnpm --filter @debugmcp/codelldb-common run build:adapter`, or set `CODELLDB_PATH`.

‎docs/ruby/README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,35 @@ Breakpoints, conditional breakpoints, locals, and expression evaluation all work
187187
the pod exactly as they do locally. For a pod on a real cluster, the only difference is
188188
where `kubectl port-forward` points.
189189

190+
### Attaching across a container boundary
191+
192+
Attach sessions send breakpoint paths to the remote rdbg **verbatim** — rdbg checks them
193+
against the debug **target's** filesystem, not yours. When the debugger and the debuggee
194+
see the project at different paths (host mcp-debugger + containerized app, or a
195+
containerized mcp-debugger + host app), a path that is valid on your side can fail on the
196+
target with `<path> is not available`. That warning is topology, not breakage:
197+
198+
- **Prefer target-side paths.** Set breakpoints using the path the debuggee sees
199+
(`/app/app.rb` inside the container, as reported by `get_stack_trace`), not the path on
200+
your machine.
201+
- **Or map paths with `localfsMap`.** rdbg accepts a `localfsMap` attach option
202+
(`"remote_prefix:local_prefix"`, comma-separated for multiple pairs) that translates
203+
paths between the two filesystems. Pass it through `attach_to_process`:
204+
205+
```text
206+
attach_to_process {
207+
"sessionId": "...",
208+
"host": "127.0.0.1",
209+
"port": 12345,
210+
"localfsMap": "/app:/home/user/project"
211+
}
212+
```
213+
214+
- **Reaching a host-side rdbg from a containerized mcp-debugger:** use
215+
`host.docker.internal` as the attach host (add
216+
`--add-host=host.docker.internal:host-gateway` on Linux Docker), and remember the
217+
target-side rule still applies — the host rdbg needs host paths.
218+
190219
## Troubleshooting
191220

192221
| Symptom | Likely cause / fix |

‎docs/rust-debugging.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,23 @@ Response will show Rust types clearly:
291291
}
292292
```
293293

294+
## Debugging host-built binaries in Docker
295+
296+
A binary built by `cargo build` on the host embeds host-absolute source paths (e.g. `/home/user/proj/src/main.rs`) in its DWARF debug info. When the mcp-debugger container mounts the project at `/workspace`, breakpoint requests use `/workspace/...` paths and CodeLLDB cannot match them — file+line breakpoints silently never bind (function breakpoints and pause still work; issue #363).
297+
298+
In container mode (`MCP_CONTAINER=true`) the adapter auto-derives a best-effort `sourceMap` for prebuilt binaries by scanning the binary for embedded host paths whose suffixes exist under the workspace mount (`MCP_WORKSPACE_ROOT`, default `/workspace`). If derivation misses, pass the mapping explicitly — a caller-supplied `sourceMap` always wins:
299+
300+
```json
301+
{
302+
"scriptPath": "/workspace/target/debug/myapp",
303+
"dapLaunchArgs": {
304+
"sourceMap": { "/home/user/proj": "/workspace" }
305+
}
306+
}
307+
```
308+
309+
Alternatively, build inside the container (or with `RUSTFLAGS="--remap-path-prefix=/home/user/proj=/workspace"`) so the DWARF paths match the mount directly.
310+
294311
## Troubleshooting
295312

296313
### Breakpoints Not Hit

‎packages/adapter-cpp/src/cpp-debug-adapter.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
buildCodeLLDBArgs,
5151
configurePythonEnvironment,
5252
resolveTerminalKind,
53+
deriveSourceMapFromBinary,
5354
BinaryInfo
5455
} from '@debugmcp/codelldb-common';
5556
import {
@@ -539,6 +540,11 @@ export class CppDebugAdapter extends EventEmitter implements IDebugAdapter {
539540
postRunCommands: postRunCommands || []
540541
};
541542

543+
// Tracks whether the binary was compiled during this launch — a binary
544+
// built inside the container already has container paths in its DWARF,
545+
// so sourceMap derivation (issue #363) is unnecessary.
546+
let compiledAtLaunch = false;
547+
542548
if (program) {
543549
const programPath = String(program);
544550

@@ -558,6 +564,7 @@ export class CppDebugAdapter extends EventEmitter implements IDebugAdapter {
558564
throw new Error(`C/C++ compile failed: ${result.error}`);
559565
}
560566
launchConfig.program = result.binaryPath!;
567+
compiledAtLaunch = true;
561568
} else {
562569
this.dependencies.logger?.info('[CppDebugAdapter] Using existing binary (up to date)');
563570
launchConfig.program = outputPath;
@@ -577,6 +584,27 @@ export class CppDebugAdapter extends EventEmitter implements IDebugAdapter {
577584
await this.evaluateToolchain(launchConfig.program);
578585
}
579586

587+
// Container mode (issue #363): a host-built binary embeds host-absolute
588+
// DWARF paths, so /workspace breakpoint requests never match. Best-effort:
589+
// derive a hostPrefix -> workspaceRoot sourceMap from the binary's
590+
// embedded path strings. Caller-supplied sourceMap entries always win.
591+
if (
592+
process.env.MCP_CONTAINER === 'true' &&
593+
!compiledAtLaunch &&
594+
Object.keys(sourceMap || {}).length === 0 &&
595+
typeof launchConfig.program === 'string' &&
596+
launchConfig.program.length > 0
597+
) {
598+
const workspaceRoot = process.env.MCP_WORKSPACE_ROOT || '/workspace';
599+
const derived = deriveSourceMapFromBinary(launchConfig.program, workspaceRoot);
600+
if (Object.keys(derived).length > 0) {
601+
launchConfig.sourceMap = derived;
602+
this.dependencies.logger?.info(
603+
`[CppDebugAdapter] Container mode: derived sourceMap from binary DWARF paths: ${JSON.stringify(derived)}`
604+
);
605+
}
606+
}
607+
580608
return launchConfig;
581609
}
582610

‎packages/adapter-cpp/tests/cpp-adapter.test.ts‎

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ vi.mock('@debugmcp/codelldb-common', async (importOriginal) => ({
1515
resolveCodeLLDBExecutable: vi.fn(),
1616
resolveCodeLLDBExecutableSyncImpl: vi.fn(),
1717
getCodeLLDBVersion: vi.fn(),
18-
detectBinaryFormat: vi.fn()
18+
detectBinaryFormat: vi.fn(),
19+
deriveSourceMapFromBinary: vi.fn()
1920
}));
2021

2122
vi.mock('../src/utils/compile-utils.js', async (importOriginal) => ({
@@ -30,7 +31,8 @@ import {
3031
resolveCodeLLDBExecutable,
3132
resolveCodeLLDBExecutableSyncImpl,
3233
getCodeLLDBVersion,
33-
detectBinaryFormat
34+
detectBinaryFormat,
35+
deriveSourceMapFromBinary
3436
} from '@debugmcp/codelldb-common';
3537
import {
3638
findCompiler,
@@ -78,6 +80,7 @@ describe('CppDebugAdapter', () => {
7880
vi.mocked(findAnyCompiler).mockReset();
7981
vi.mocked(needsRecompile).mockReset();
8082
vi.mocked(compileSourceFile).mockReset();
83+
vi.mocked(deriveSourceMapFromBinary).mockReset().mockReturnValue({});
8184
dependencies = createDependencies();
8285
adapter = new CppDebugAdapter(dependencies);
8386
});
@@ -260,6 +263,100 @@ describe('CppDebugAdapter', () => {
260263
code: 'SCRIPT_NOT_FOUND'
261264
});
262265
});
266+
267+
describe('container sourceMap derivation (#363)', () => {
268+
it('injects a derived sourceMap for a prebuilt binary in container mode', async () => {
269+
vi.stubEnv('MCP_CONTAINER', 'true');
270+
vi.mocked(deriveSourceMapFromBinary).mockReturnValue({
271+
'/home/user/proj': '/workspace'
272+
});
273+
try {
274+
const result = await adapter.transformLaunchConfig({
275+
program: 'build/app',
276+
cwd: '/work'
277+
} as never);
278+
279+
expect(deriveSourceMapFromBinary).toHaveBeenCalledWith(
280+
path.resolve('/work', 'build/app'),
281+
'/workspace'
282+
);
283+
expect(result.sourceMap).toEqual({ '/home/user/proj': '/workspace' });
284+
} finally {
285+
vi.unstubAllEnvs();
286+
}
287+
});
288+
289+
it('respects MCP_WORKSPACE_ROOT for derivation', async () => {
290+
vi.stubEnv('MCP_CONTAINER', 'true');
291+
vi.stubEnv('MCP_WORKSPACE_ROOT', '/mnt/project');
292+
try {
293+
await adapter.transformLaunchConfig({ program: 'build/app', cwd: '/work' } as never);
294+
295+
expect(deriveSourceMapFromBinary).toHaveBeenCalledWith(
296+
path.resolve('/work', 'build/app'),
297+
'/mnt/project'
298+
);
299+
} finally {
300+
vi.unstubAllEnvs();
301+
}
302+
});
303+
304+
it('skips derivation when the caller supplies sourceMap entries', async () => {
305+
vi.stubEnv('MCP_CONTAINER', 'true');
306+
try {
307+
const result = await adapter.transformLaunchConfig({
308+
program: 'build/app',
309+
cwd: '/work',
310+
sourceMap: { '/custom': '/workspace' }
311+
} as never);
312+
313+
expect(deriveSourceMapFromBinary).not.toHaveBeenCalled();
314+
expect(result.sourceMap).toEqual({ '/custom': '/workspace' });
315+
} finally {
316+
vi.unstubAllEnvs();
317+
}
318+
});
319+
320+
it('skips derivation outside container mode', async () => {
321+
const result = await adapter.transformLaunchConfig({
322+
program: 'build/app',
323+
cwd: '/work'
324+
} as never);
325+
326+
expect(deriveSourceMapFromBinary).not.toHaveBeenCalled();
327+
expect(result.sourceMap).toEqual({});
328+
});
329+
330+
it('skips derivation when the binary was compiled at launch', async () => {
331+
vi.stubEnv('MCP_CONTAINER', 'true');
332+
const src = path.resolve('/work/hello.cpp');
333+
const out = getDefaultOutputPath(src, process.platform);
334+
vi.mocked(needsRecompile).mockResolvedValue(true);
335+
vi.mocked(compileSourceFile).mockResolvedValue({ success: true, binaryPath: out });
336+
try {
337+
await adapter.transformLaunchConfig({ program: src } as never);
338+
339+
expect(deriveSourceMapFromBinary).not.toHaveBeenCalled();
340+
} finally {
341+
vi.unstubAllEnvs();
342+
}
343+
});
344+
345+
it('leaves sourceMap empty when derivation finds nothing', async () => {
346+
vi.stubEnv('MCP_CONTAINER', 'true');
347+
vi.mocked(deriveSourceMapFromBinary).mockReturnValue({});
348+
try {
349+
const result = await adapter.transformLaunchConfig({
350+
program: 'build/app',
351+
cwd: '/work'
352+
} as never);
353+
354+
expect(result.sourceMap).toEqual({});
355+
} finally {
356+
vi.unstubAllEnvs();
357+
}
358+
});
359+
});
263360
});
264361

265362
describe('attach support', () => {

‎packages/adapter-mock/src/mock-adapter-process.ts‎

Lines changed: 34 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -412,16 +412,39 @@ class MockDebugAdapterProcess {
412412
} as DebugProtocol.StoppedEvent);
413413
}
414414

415+
/**
416+
* Emit a stopped event for a hit breakpoint. Function breakpoints report
417+
* reason 'function breakpoint' (issue #355); plain line breakpoints keep
418+
* 'breakpoint'. Both carry hitBreakpointIds when the record has an id.
419+
*/
420+
private sendBreakpointStopped(bp: DebugProtocol.Breakpoint & { isFunctionBreakpoint?: boolean }): void {
421+
this.sendEvent({
422+
seq: 0,
423+
type: 'event',
424+
event: 'stopped',
425+
body: {
426+
reason: bp.isFunctionBreakpoint ? 'function breakpoint' : 'breakpoint',
427+
threadId: 1,
428+
allThreadsStopped: true,
429+
...(bp.id !== undefined ? { hitBreakpointIds: [bp.id] } : {})
430+
}
431+
} as DebugProtocol.StoppedEvent);
432+
}
433+
415434
/**
416435
* Walk the sorted breakpoint list from a line: logpoint lines emit an
417436
* output event (naive {expr} interpolation) and do NOT stop; the first
418437
* plain breakpoint after the line is the next stop (issue #235).
419438
*/
420-
private findNextStop(fromLine: number): (DebugProtocol.Breakpoint & { logMessage?: string }) | undefined {
421-
const allBreakpoints = Array.from(this.breakpoints.values())
422-
.flat()
439+
private findNextStop(fromLine: number): (DebugProtocol.Breakpoint & { logMessage?: string; isFunctionBreakpoint?: boolean }) | undefined {
440+
const allBreakpoints = (Array.from(this.breakpoints.values())
441+
.flat() as (DebugProtocol.Breakpoint & { logMessage?: string; isFunctionBreakpoint?: boolean })[])
423442
.filter(bp => bp.line !== undefined)
424-
.sort((a, b) => (a.line || 0) - (b.line || 0));
443+
// Sort by line; when a line breakpoint and a function breakpoint share a
444+
// line, the plain line breakpoint wins the tiebreak (issue #355)
445+
.sort((a, b) =>
446+
((a.line || 0) - (b.line || 0)) ||
447+
((a.isFunctionBreakpoint ? 1 : 0) - (b.isFunctionBreakpoint ? 1 : 0)));
425448

426449
for (const bp of allBreakpoints) {
427450
if ((bp.line || 0) <= fromLine) continue;
@@ -480,16 +503,7 @@ class MockDebugAdapterProcess {
480503
if (firstStop) {
481504
this.currentLine = firstStop.line || 1;
482505
this.log(`Hit first breakpoint at line ${this.currentLine}`);
483-
this.sendEvent({
484-
seq: 0,
485-
type: 'event',
486-
event: 'stopped',
487-
body: {
488-
reason: 'breakpoint',
489-
threadId: 1,
490-
allThreadsStopped: true
491-
}
492-
} as DebugProtocol.StoppedEvent);
506+
this.sendBreakpointStopped(firstStop);
493507
} else if (this.shouldSimulateException()) {
494508
this.sendExceptionStopped();
495509
} else {
@@ -603,8 +617,11 @@ class MockDebugAdapterProcess {
603617
id: Math.floor(Math.random() * 100000),
604618
verified: true,
605619
line,
606-
source: { path: this.programPath || 'mock://program' }
607-
});
620+
source: { path: this.programPath || 'mock://program' },
621+
// Marker so the run simulation can report 'function breakpoint'
622+
// as the stop reason (issue #355)
623+
isFunctionBreakpoint: true
624+
} as DebugProtocol.Breakpoint & { isFunctionBreakpoint: boolean });
608625
} else {
609626
breakpoints.push({
610627
id: Math.floor(Math.random() * 100000),
@@ -777,16 +794,7 @@ class MockDebugAdapterProcess {
777794
// Hit the next breakpoint
778795
this.currentLine = nextBreakpoint.line;
779796
this.log(`Stopping at breakpoint on line ${this.currentLine}`);
780-
this.sendEvent({
781-
seq: 0,
782-
type: 'event',
783-
event: 'stopped',
784-
body: {
785-
reason: 'breakpoint',
786-
threadId: 1,
787-
allThreadsStopped: true
788-
}
789-
} as DebugProtocol.StoppedEvent);
797+
this.sendBreakpointStopped(nextBreakpoint);
790798
} else if (this.shouldSimulateException()) {
791799
// Crash before completion: uncaught exception past the last breakpoint
792800
this.sendExceptionStopped();

0 commit comments

Comments
 (0)