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
4 changes: 2 additions & 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`.
- **Initial stop varies by platform:** the first stop after `start_debugging` may be a launch-time system stop rather than your breakpoint (observed on Linux as a SIGSTOP-labeled stop; older Windows reports show ntdll frames, though current Windows traces usually land directly on the first breakpoint). If `get_stack_trace` shows no user frame, issue one `continue_execution` to reach your breakpoint.
- **Windows: continue re-stops at the same breakpoint (issue #255):** once paused at a breakpoint on Windows, `continue_execution` succeeds but immediately re-stops at the same line instead of advancing. Reproduced with MSVC binaries (both PDB readers) AND GNU/DWARF binaries — it is Windows-specific, not symbol-format-specific. **Workaround: `step_over` once, then `continue_execution` proceeds normally.** Does not occur on Linux. Note a same-line re-stop inside a loop is a legitimate breakpoint re-hit — only apply the workaround when the program clearly isn't advancing.
- **Continue can re-stop on the same line — macro lines hold several breakpoint locations (issue #255):** a breakpoint on a line that expands to multiple inlined call sites (`format!`, `println!`, `vec!`, and other macros) resolves to one location *per* call site — the `setBreakpoints` response says `Resolved locations: N`. Each `continue_execution` advances to the next location, so the session re-pauses on the same file:line, with the same breakpoint id, at a different program counter. This is normal LLDB behavior on every platform, not a defect: keep continuing (N times) and the program leaves the line, or `step_over` once to traverse the whole line in one call. Confirmed by driving CodeLLDB with a raw DAP client, mcp-debugger out of the loop; plain single-statement lines resolve to one location and need exactly one continue. Note the location count for a given line is **toolchain-dependent** — different rustc versions merge macro call sites differently in the line tables (the same `format!` line resolved to 1 location under rustc 1.83 and 3 under 1.91), so the same program can need a different number of continues after a toolchain upgrade.
- **Stop reasons can be mislabeled on Windows:** CodeLLDB 1.11.8 has been observed reporting step completions as `reason: 'breakpoint'` and a continue's stop as `'step'`. Judge progress by `get_stack_trace` line numbers, not the reason string alone.
- **Pause on Windows lands via a break-in thread:** `pause_execution` injects a debug break (`EXCEPTION_BREAKPOINT` 0x80000003); the stop reason is normalized to `'pause'` (`rawReason: 'exception'` preserved, issue #275). The paused thread is the synthetic break-in thread, so its locals are empty — use `list_threads` and inspect your program's threads' frames instead.
- **Panics pause by default (issue #244):** launch sessions arm CodeLLDB's `rust_panic` filter by default, so a `panic!` pauses at the panic site with the backtrace live (exit code 101 after continuing). The pause reports `lastStop.reason: 'exception'` (normalized from CodeLLDB's internal-breakpoint stop, issue #260 — `rawReason: 'breakpoint'` is preserved); the panic message itself arrives on stderr via `get_output`, not in `lastStop.description`. Pass `breakOnExceptions: "none"` to run panicking programs to termination instead.
Expand All @@ -72,5 +72,5 @@ Not supported. The Rust adapter implements launch mode only — `attach_to_proce
| Variables `<unavailable>` / garbage strings (Windows) | MSVC toolchain — PDB symbols | Rebuild: `cargo +stable-gnu build --target x86_64-pc-windows-gnu`; verify with `check-rust-binary` |
| "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 (or a SIGSTOP stop on Linux) | Launch-time system stop | `continue_execution` once, then you land on your breakpoint |
| `continue_execution` re-stops at the same breakpoint line (Windows) | CodeLLDB breakpoint re-hit quirk on Windows — MSVC and GNU builds alike (issue #255) | `step_over` once, then `continue_execution` |
| `continue_execution` re-stops on the same line | The line is a macro (`format!`, `println!`, …) whose expansion resolves to several breakpoint locations; each stop is a genuine hit at a different PC (issue #255) | Continue again until the line's locations are drained, or `step_over` once to cross the whole line |
| `dlltool ... CreateProcess` build error | rustup GNU toolchain missing `as.exe` | Install MSYS2 mingw-w64 binutils/gcc; prepend `C:\msys64\mingw64\bin` to PATH |
6 changes: 4 additions & 2 deletions tests/e2e/mcp-server-breakpoint-management.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ describe('Breakpoint management e2e (list/remove/clear)', () => {
expect(finalList.count).toBe(0);

// 6. continue → nothing left to stop at → program runs to completion.
// (#255: on Windows, CodeLLDB re-stops at the just-hit breakpoint on
// continue; a step_over first is the documented workaround.)
// All breakpoints were cleared above (count asserted 0), so the
// multi-location macro behavior (#255) cannot apply here; the win32
// step_over only guards the removal-sync race described in the header
// docblock (a clear that hasn't reached the adapter yet).
if (lang.language === 'rust' && process.platform === 'win32') {
await callToolSafely(mcpClient!, 'step_over', { sessionId: currentSessionId });
await waitForState(currentSessionId, ['paused']);
Expand Down
7 changes: 6 additions & 1 deletion tests/e2e/mcp-server-smoke-restart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ describe('restart_debugging e2e', () => {
throw new Error(`Timed out waiting for state in [${states.join(', ')}]; last state: ${lastState}`);
}

/** Continue (with the rust/win32 #255 step_over workaround) until the program exits. */
/**
* Continue until the program exits. The loop is what makes this robust: a
* breakpoint on a macro line resolves to several locations, so leaving that
* line takes one continue per location (issue #255). The rust/win32
* step_over just gets there in fewer round trips.
*/
async function driveToCompletion(sessionId: string, language: string): Promise<void> {
for (let i = 0; i < 25; i++) {
if (language === 'rust' && process.platform === 'win32') {
Expand Down
231 changes: 124 additions & 107 deletions tests/e2e/mcp-server-smoke-rust.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,9 @@ describe('MCP Server Rust Debugging Smoke Test', () => {
// Debuggee output must be retrievable (issue #223): POSIX gets it via
// CodeLLDB's own DAP output events, Windows via the proxy's adapter-stdio
// forwarding. Both markers print before the line-26 breakpoint, so no
// continue is needed (Windows re-hits the breakpoint on continue).
// continue is needed (line 26 is a multi-location macro line — further
// continues would just start draining its locations, see the
// multi-location test below).
const outputResult = await callToolSafely(mcpClient!, 'get_output', { sessionId });
expect(outputResult.success).toBe(true);
const outputEntries = outputResult.entries as Array<{ category: string; output: string }>;
Expand Down Expand Up @@ -336,126 +338,141 @@ describe('MCP Server Rust Debugging Smoke Test', () => {
60000
);

// Issue #255 reproducer: on non-Windows a single continue from a breakpoint
// must run the program to completion. On Windows CodeLLDB has been observed
// re-stopping at the just-hit breakpoint on continue (MSVC/PDB confirmed;
// GNU/DWARF repro status unknown), so the Windows leg probes: it accepts
// either outcome, leaves a loud breadcrumb, and drives to completion with
// the documented step_over workaround when the re-stop occurs. No product
// workaround exists by design — a same-line re-stop inside a loop is
// indistinguishable from a legitimate re-hit.
it(
'continues from a breakpoint to completion (issue #255 reproducer)',
async (ctx) => {
const { sourcePath, binaryPath } = await prepareRustExample('hello_world');
// Shared driver for the continue-to-completion tests below: sets one
// breakpoint, launches, and returns once the user frame at `line` is live.
async function launchAndReachBreakpoint(
ctx: Parameters<Parameters<typeof it>[1]>[0],
line: number,
name: string
): Promise<{
getSession: () => Promise<{ state?: string; exitCode?: number } | undefined>;
topUserFrameLine: () => Promise<number | undefined>;
wait: (ms: number) => Promise<unknown>;
reached: boolean;
}> {
const { sourcePath, binaryPath } = await prepareRustExample('hello_world');

const createResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'create_debug_session',
arguments: { language: 'rust', name }
}));
expect(createResponse.success).toBe(true);
sessionId = createResponse.sessionId as string;

const breakpointResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'set_breakpoint',
arguments: { sessionId, file: sourcePath, line }
}));
expect(breakpointResponse.success).toBe(true);

const startResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'start_debugging',
arguments: {
sessionId,
scriptPath: binaryPath,
dapLaunchArgs: { stopOnEntry: false },
adapterLaunchConfig: { sourceLanguages: ['rust'] }
}
}));
skipIfSpawnBlocked(ctx, startResponse, 'Rust');
expect(startResponse.success).toBe(true);

const createResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'create_debug_session',
arguments: { language: 'rust', name: 'rust-continue-reproducer' }
}));
expect(createResponse.success).toBe(true);
sessionId = createResponse.sessionId as string;
const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

const breakpointResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'set_breakpoint',
arguments: { sessionId, file: sourcePath, line: 26 }
async function getSession(): Promise<{ state?: string; exitCode?: number } | undefined> {
const res = parseSdkToolResult(await mcpClient!.callTool({
name: 'list_debug_sessions',
arguments: {}
}));
expect(breakpointResponse.success).toBe(true);
const sessions = (res.sessions ?? []) as Array<{ id: string; state?: string; exitCode?: number }>;
return sessions.find(s => s.id === sessionId);
}

const startResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'start_debugging',
arguments: {
sessionId,
scriptPath: binaryPath,
dapLaunchArgs: { stopOnEntry: false },
adapterLaunchConfig: { sourceLanguages: ['rust'] }
async function topUserFrameLine(): Promise<number | undefined> {
const stack = parseSdkToolResult(await mcpClient!.callTool({
name: 'get_stack_trace',
arguments: { sessionId }
})) as { stackFrames?: Array<{ file?: string; line?: number }> };
const frame = stack.stackFrames?.find(
f => f.file?.replace(/\\/g, '/').includes('/examples/rust/hello_world/src/')
);
return frame?.line;
}

// The first stop may be a launch-time system stop (platform-dependent);
// issue bounded continues until the user frame at `line` is live.
let reached = false;
for (let attempt = 0; attempt < 10 && !reached; attempt++) {
const snap = await getSession();
if (snap?.state === 'paused') {
if ((await topUserFrameLine()) === line) {
reached = true;
break;
}
}));
skipIfSpawnBlocked(ctx, startResponse, 'Rust');
expect(startResponse.success).toBe(true);
await callToolSafely(mcpClient!, 'continue_execution', { sessionId });
}
await wait(500);
}

const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
return { getSession, topUserFrameLine, wait, reached };
}

async function getSession(): Promise<{ state?: string; exitCode?: number; lastStop?: { reason?: string } } | undefined> {
const res = parseSdkToolResult(await mcpClient!.callTool({
name: 'list_debug_sessions',
arguments: {}
}));
const sessions = (res.sessions ?? []) as Array<{ id: string; state?: string; exitCode?: number; lastStop?: { reason?: string } }>;
return sessions.find(s => s.id === sessionId);
}
// The suite never continued past a user breakpoint to completion — the gap
// that let issue #255 survive it. Line 42 (`let sum = a + b;`) is a plain
// statement that compiles to a single breakpoint location, so exactly one
// continue must finish the program on every platform.
it(
'continues from a single-location breakpoint to completion in one call',
async (ctx) => {
const { getSession, wait, reached } = await launchAndReachBreakpoint(ctx, 42, 'rust-continue-single-location');
expect(reached, 'session should pause at the line-42 breakpoint').toBe(true);

async function topUserFrameLine(): Promise<number | undefined> {
const stack = parseSdkToolResult(await mcpClient!.callTool({
name: 'get_stack_trace',
arguments: { sessionId }
})) as { stackFrames?: Array<{ file?: string; line?: number }> };
const frame = stack.stackFrames?.find(
f => f.file?.replace(/\\/g, '/').includes('/examples/rust/hello_world/src/')
);
return frame?.line;
}
await callToolSafely(mcpClient!, 'continue_execution', { sessionId });
const stopped = await pollStopped(getSession, wait, 20000);
expect(stopped, 'a single continue from a single-location breakpoint should run to completion').toBeDefined();
expect(stopped!.exitCode).toBe(0);
},
90000
);

// Reach the breakpoint. The first stop may be a launch-time system stop
// (platform-dependent); issue bounded continues until the user frame at
// line 26 is live.
let atBreakpoint = false;
for (let attempt = 0; attempt < 10 && !atBreakpoint; attempt++) {
const snap = await getSession();
if (snap?.state === 'paused') {
if ((await topUserFrameLine()) === 26) {
atBreakpoint = true;
break;
}
await callToolSafely(mcpClient!, 'continue_execution', { sessionId });
}
await wait(500);
}
expect(atBreakpoint, 'session should pause at the line-26 breakpoint').toBe(true);
// Issue #255 was reported as "continue re-stops at the same breakpoint and
// never advances". Root cause (confirmed with a raw DAP client driving
// CodeLLDB directly, so mcp-debugger is not in the loop): line 26 is a
// `format!` macro whose expansion inlines several call sites onto that one
// source line, and LLDB plants a breakpoint location at each. Every stop is
// a genuine, distinct hit of the same breakpoint id at a different program
// counter, so continue advances location-by-location before leaving the
// line. Not platform-specific and not a defect — this test pins the
// behavior so a future change in resolution is visible.
it(
'drains every location of a multi-location (macro) breakpoint line, then completes',
async (ctx) => {
const { getSession, topUserFrameLine, wait, reached } =
await launchAndReachBreakpoint(ctx, 26, 'rust-continue-multi-location');
expect(reached, 'session should pause at the line-26 breakpoint').toBe(true);

if (process.platform !== 'win32') {
// Strict reproducer: exactly one continue must reach completion.
const MAX_CONTINUES = 8;
let continues = 0;
let stopped: { state?: string; exitCode?: number } | undefined;

while (continues < MAX_CONTINUES && !stopped) {
continues++;
await callToolSafely(mcpClient!, 'continue_execution', { sessionId });
const stopped = await pollStopped(getSession, wait, 20000);
expect(stopped, 'a single continue from the breakpoint should run to completion').toBeDefined();
expect(stopped!.exitCode).toBe(0);
return;
}
stopped = await pollStopped(getSession, wait, 5000);
if (stopped) break;

// Windows probe: one plain continue, then observe.
await callToolSafely(mcpClient!, 'continue_execution', { sessionId });
await wait(1500);
let snap = await getSession();
if (snap?.state !== 'paused') {
const stopped = await pollStopped(getSession, wait, 20000);
expect(stopped, 'continue should terminate when it does not re-stop').toBeDefined();
expect(stopped!.exitCode).toBe(0);
console.warn(
'[issue #255] Plain continue advanced to completion on win32 — either upstream fixed the ' +
're-stop or this build (GNU/DWARF) does not reproduce it. Consider tightening this test ' +
'and removing the step_over workarounds.'
);
return;
// Still paused: every intermediate stop belongs to the same macro line.
const snap = await getSession();
expect(snap?.state, 'session should be paused between macro-line locations').toBe('paused');
expect(
await topUserFrameLine(),
'intermediate stops should stay on the macro line until its locations are drained'
).toBe(26);
}

const lineAfterContinue = await topUserFrameLine();
console.warn(
`[issue #255] Reproduced on win32: continue re-stopped at line ${lineAfterContinue} ` +
'(breakpoint line 26). Driving to completion with the step_over workaround.'
);
// Documented workaround: step_over once, then continue (bounded).
let completed = false;
for (let i = 0; i < 10 && !completed; i++) {
await callToolSafely(mcpClient!, 'step_over', { sessionId });
await wait(500);
await callToolSafely(mcpClient!, 'continue_execution', { sessionId });
const stopped = await pollStopped(getSession, wait, 5000);
if (stopped) {
expect(stopped.exitCode).toBe(0);
completed = true;
}
}
expect(completed, 'step_over + continue workaround should reach completion').toBe(true);
expect(stopped, `program should complete within ${MAX_CONTINUES} continues`).toBeDefined();
expect(stopped!.exitCode).toBe(0);
expect(continues, 'a macro line should need more than one continue to leave').toBeGreaterThan(1);
},
90000
);
Expand Down
Loading