diff --git a/skills/debugging/references/rust.md b/skills/debugging/references/rust.md index 98641283..1c405096 100644 --- a/skills/debugging/references/rust.md +++ b/skills/debugging/references/rust.md @@ -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 `` 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. @@ -72,5 +72,5 @@ Not supported. The Rust adapter implements launch mode only — `attach_to_proce | Variables `` / 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 | diff --git a/tests/e2e/mcp-server-breakpoint-management.test.ts b/tests/e2e/mcp-server-breakpoint-management.test.ts index 41a2e852..93fd52ab 100644 --- a/tests/e2e/mcp-server-breakpoint-management.test.ts +++ b/tests/e2e/mcp-server-breakpoint-management.test.ts @@ -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']); diff --git a/tests/e2e/mcp-server-smoke-restart.test.ts b/tests/e2e/mcp-server-smoke-restart.test.ts index ad3d3b6c..7986b045 100644 --- a/tests/e2e/mcp-server-smoke-restart.test.ts +++ b/tests/e2e/mcp-server-smoke-restart.test.ts @@ -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 { for (let i = 0; i < 25; i++) { if (language === 'rust' && process.platform === 'win32') { diff --git a/tests/e2e/mcp-server-smoke-rust.test.ts b/tests/e2e/mcp-server-smoke-rust.test.ts index 56786e43..ec3b2631 100644 --- a/tests/e2e/mcp-server-smoke-rust.test.ts +++ b/tests/e2e/mcp-server-smoke-rust.test.ts @@ -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 }>; @@ -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[1]>[0], + line: number, + name: string + ): Promise<{ + getSession: () => Promise<{ state?: string; exitCode?: number } | undefined>; + topUserFrameLine: () => Promise; + wait: (ms: number) => Promise; + 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 { + 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 { - 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 );