Skip to content

Commit 802e6e9

Browse files
authored
Merge pull request #108 from microsoft/ozzafar/issue-18-add-breakpoint-line
Ozzafar/issue 18 add breakpoint line
2 parents e531260 + 19bfde5 commit 802e6e9

10 files changed

Lines changed: 186 additions & 80 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ DebugMCP is an MCP server that gives AI coding agents full control over the VS C
6060
| **continue_execution** | Continue until next breakpoint | None |
6161
| **pause_execution** | Interrupt a freely-running program and stop at its current location (no breakpoint needed) | None |
6262
| **restart_debugging** | Restart the current debug session | None |
63-
| **add_breakpoint** | Add a breakpoint at a specific line (optionally conditional) | `fileFullPath` (required)<br>`lineContent` (required)<br>`condition` (optional) |
63+
| **add_breakpoint** | Add a breakpoint at a specific line (optionally conditional) | `fileFullPath` (required)<br>`line` (required, 1-based)<br>`condition` (optional) |
6464
| **remove_breakpoint** | Remove a breakpoint from a specific line | `fileFullPath` (required)<br>`line` (required) |
6565
| **clear_all_breakpoints** | Remove all breakpoints at once | None |
6666
| **list_breakpoints** | List all active breakpoints | None |
@@ -371,7 +371,7 @@ Yes. DebugMCP supports `.cs` files and `.csproj` project files for C#/.NET debug
371371
- **Symptom**: Breakpoints are set but execution doesn't pause
372372
- **Solution**:
373373
- Ensure the correct file is being debugged
374-
- Check that the breakpoint line content matches exactly
374+
- Check that the breakpoint line number is correct
375375
- Verify the relevant language debugger extension is installed
376376

377377
#### Configuration Not Auto-Detected

skills/debug-live/SKILL.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,8 @@ If you can step through the code in a few tool calls, do that instead of specula
4646

4747
## Core workflow
4848

49-
1. **Set a starting breakpoint.** Use `add_breakpoint` with the file path and the exact
50-
line content you want to pause on (line content matching is more robust than line
51-
numbers — it survives small edits). Place it at the earliest point that's still
49+
1. **Set a starting breakpoint.** Use `add_breakpoint` with the file path and the 1-based
50+
line number you want to pause on. Place it at the earliest point that's still
5251
relevant to the suspected issue.
5352
2. **Optionally add strategic breakpoints.** Decision points, error-handling branches,
5453
data boundaries (where input enters, where output is produced).
@@ -168,8 +167,8 @@ Before ending the debug session, confirm you can answer:
168167

169168
- **Start broad, then narrow.** Begin at the entry point of the suspect function. As
170169
you isolate the issue, add tighter breakpoints around the problematic region.
171-
- **Match line content, not numbers.** `add_breakpoint` takes the exact line text so
172-
breakpoints survive small edits and refactors.
170+
- **Use line numbers.** `add_breakpoint` takes a 1-based `line`; re-check the line after
171+
edits since numbers shift when code changes.
173172
- **Don't overuse breakpoints.** A handful of well-placed pauses beats dozens of noisy
174173
ones. After each session, `clear_all_breakpoints` to start fresh.
175174
- **For test debugging,** pass `testName` to `start_debugging`. The server routes through
@@ -182,7 +181,7 @@ Before ending the debug session, confirm you can answer:
182181

183182
### Investigating a bug in `calculate.py`
184183
```text
185-
add_breakpoint fileFullPath=/repo/src/calculate.py lineContent="result = parse(raw)"
184+
add_breakpoint fileFullPath=/repo/src/calculate.py line=42
186185
start_debugging fileFullPath=/repo/src/calculate.py workingDirectory=/repo
187186
# session pauses on the breakpoint
188187
get_variables_values scope=local
@@ -194,7 +193,7 @@ clear_all_breakpoints
194193

195194
### Debugging a single xUnit test in C#
196195
```text
197-
add_breakpoint fileFullPath=C:\Repo\Calculator.Tests\CalculatorTests.cs lineContent="Assert.Equal(5, _calc.Add(2, 3));"
196+
add_breakpoint fileFullPath=C:\Repo\Calculator.Tests\CalculatorTests.cs line=18
198197
start_debugging fileFullPath=C:\Repo\Calculator.Tests\CalculatorTests.cs workingDirectory=C:\Repo testName=Add_ReturnsSum
199198
# pauses inside the test
200199
step_into

skills/debug-live/references/troubleshooting/csharp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ DebugMCP provides enhanced support for debugging C# applications using the C# De
3333
3. Set appropriate working directories
3434

3535
### Breakpoint Management
36-
1. Use line content matching for reliable breakpoint placement
36+
1. Use the 1-based line number for reliable breakpoint placement
3737
2. Clear breakpoints when stopping debug sessions
3838
3. Verify breakpoints are hit in the expected files
3939

src/debugMCPServer.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
IDebuggingHandler
1212
} from '.';
1313
import { logger } from './utils/logger';
14+
import { withTimeout } from './utils/withTimeout';
1415
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
1516
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
1617
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
@@ -182,23 +183,15 @@ export class DebugMCPServer {
182183
toolName: string,
183184
run: () => Promise<string>
184185
): Promise<{ content: { type: 'text'; text: string }[] }> {
185-
let timer: ReturnType<typeof setTimeout> | undefined;
186-
const backstop = new Promise<never>((_, reject) => {
187-
timer = setTimeout(() => {
188-
reject(new Error(
189-
`Tool "${toolName}" did not complete within ${Math.round(this.toolBackstopMs / 1000)}s and was aborted. ` +
190-
'The debug adapter or target VS Code window may be unresponsive. Try stop_debugging and retry, or reload the window.'
191-
));
192-
}, this.toolBackstopMs);
193-
});
194-
try {
195-
const result = await Promise.race([run(), backstop]);
196-
return { content: [{ type: 'text' as const, text: result }] };
197-
} finally {
198-
if (timer) {
199-
clearTimeout(timer);
200-
}
201-
}
186+
const result = await withTimeout(
187+
run(),
188+
this.toolBackstopMs,
189+
() => new Error(
190+
`Tool "${toolName}" did not complete within ${Math.round(this.toolBackstopMs / 1000)}s and was aborted. ` +
191+
'The debug adapter or target VS Code window may be unresponsive. Try stop_debugging and retry, or reload the window.'
192+
)
193+
);
194+
return { content: [{ type: 'text' as const, text: result }] };
202195
}
203196

204197
/**
@@ -271,10 +264,10 @@ export class DebugMCPServer {
271264
description: 'Set a breakpoint to pause execution at a critical line of code. Essential for debugging: pause before potential errors, examine state at decision points, or verify code paths. Breakpoints let you inspect variables and control flow at exact moments. Provide an optional condition to create a conditional breakpoint that only pauses when the expression evaluates to true (e.g. "i == 5" or "user.id === null").',
272265
inputSchema: {
273266
fileFullPath: z.string().describe('Full path to the file'),
274-
lineContent: z.string().describe('Line content'),
267+
line: z.number().int().describe('Line number (1-based) where the breakpoint should be set'),
275268
condition: z.string().optional().describe('Optional condition expression. When provided, execution only pauses if this expression evaluates to true at the breakpoint location.'),
276269
},
277-
}, async (args: { fileFullPath: string; lineContent: string; condition?: string }) =>
270+
}, async (args: { fileFullPath: string; line: number; condition?: string }) =>
278271
this.runTool('add_breakpoint', () => debuggingHandler.handleAddBreakpoint(args)));
279272

280273
// Remove breakpoint tool

src/debuggingExecutor.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import * as vscode from 'vscode';
44
import { DebugState, StackFrame } from './debugState';
55
import { logger } from './utils/logger';
6+
import { withTimeout } from './utils/withTimeout';
67

78
/**
89
* Outcome of dispatching `testing.debugAtCursor`.
@@ -62,22 +63,14 @@ export class DebuggingExecutor implements IDebuggingExecutor {
6263
command: string,
6364
args: unknown
6465
): Promise<any> {
65-
let timer: ReturnType<typeof setTimeout> | undefined;
66-
const timeout = new Promise<never>((_, reject) => {
67-
timer = setTimeout(() => {
68-
reject(new Error(
69-
`Debug adapter did not respond to '${command}' within ` +
70-
`${Math.round(DebuggingExecutor.DAP_REQUEST_TIMEOUT_MS / 1000)}s (it may be unresponsive).`
71-
));
72-
}, DebuggingExecutor.DAP_REQUEST_TIMEOUT_MS);
73-
});
74-
try {
75-
return await Promise.race([session.customRequest(command, args), timeout]);
76-
} finally {
77-
if (timer) {
78-
clearTimeout(timer);
79-
}
80-
}
66+
return withTimeout(
67+
Promise.resolve(session.customRequest(command, args)),
68+
DebuggingExecutor.DAP_REQUEST_TIMEOUT_MS,
69+
() => new Error(
70+
`Debug adapter did not respond to '${command}' within ` +
71+
`${Math.round(DebuggingExecutor.DAP_REQUEST_TIMEOUT_MS / 1000)}s (it may be unresponsive).`
72+
)
73+
);
8174
}
8275

8376
/**

src/debuggingHandler.ts

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface IDebuggingHandler {
1818
handleContinue(): Promise<string>;
1919
handlePause(): Promise<string>;
2020
handleRestart(): Promise<string>;
21-
handleAddBreakpoint(args: { fileFullPath: string; lineContent: string; condition?: string }): Promise<string>;
21+
handleAddBreakpoint(args: { fileFullPath: string; line: number; condition?: string }): Promise<string>;
2222
handleRemoveBreakpoint(args: { fileFullPath: string; line: number }): Promise<string>;
2323
handleClearAllBreakpoints(): Promise<string>;
2424
handleListBreakpoints(): Promise<string>;
@@ -300,40 +300,26 @@ export class DebuggingHandler implements IDebuggingHandler {
300300
* Add a breakpoint at specified location. An optional condition makes it a
301301
* conditional breakpoint that only pauses when the expression is true.
302302
*/
303-
public async handleAddBreakpoint(args: { fileFullPath: string; lineContent: string; condition?: string }): Promise<string> {
304-
const { fileFullPath, lineContent, condition } = args;
305-
303+
public async handleAddBreakpoint(args: { fileFullPath: string; line: number; condition?: string }): Promise<string> {
304+
const { fileFullPath, line, condition } = args;
305+
306306
try {
307-
// Find the line number containing the line content
308-
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(fileFullPath));
309-
const text = document.getText();
310-
const lines = text.split(/\r?\n/);
311-
const matchingLineNumbers: number[] = [];
312-
313-
for (let i = 0; i < lines.length; i++) {
314-
if (lines[i].includes(lineContent)) {
315-
matchingLineNumbers.push(i + 1); // Convert to 1-based line numbers
316-
}
307+
if (!Number.isInteger(line) || line < 1) {
308+
throw new Error(`Invalid line number: ${line}. Provide a 1-based line number.`);
317309
}
318-
319-
if (matchingLineNumbers.length === 0) {
320-
throw new Error(`Could not find any lines containing: ${lineContent}`);
310+
311+
// Validate the line exists so we fail clearly instead of setting an
312+
// unbound breakpoint past the end of the file.
313+
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(fileFullPath));
314+
if (line > document.lineCount) {
315+
throw new Error(`Line ${line} is out of range: ${fileFullPath} has ${document.lineCount} lines.`);
321316
}
322-
317+
323318
const uri = vscode.Uri.file(fileFullPath);
324-
325-
// Add breakpoints to all matching lines
326-
for (const lineNumber of matchingLineNumbers) {
327-
await this.executor.addBreakpoint(uri, lineNumber, condition);
328-
}
329-
319+
await this.executor.addBreakpoint(uri, line, condition);
320+
330321
const conditionInfo = condition ? ` (condition: ${condition})` : '';
331-
if (matchingLineNumbers.length === 1) {
332-
return `Breakpoint added at ${fileFullPath}:${matchingLineNumbers[0]}${conditionInfo}`;
333-
} else {
334-
const linesList = matchingLineNumbers.join(', ');
335-
return `Breakpoints added at ${matchingLineNumbers.length} locations in ${fileFullPath}: lines ${linesList}${conditionInfo}`;
336-
}
322+
return `Breakpoint added at ${fileFullPath}:${line}${conditionInfo}`;
337323
} catch (error) {
338324
throw new Error(`Error adding breakpoint: ${error}`);
339325
}

src/routingDebuggingHandler.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,16 @@ export class RoutingDebuggingHandler implements IDebuggingHandler {
1919

2020
// Bound the router->worker round-trip so a hung worker can't hang the
2121
// forward forever. Kept above the worker's own timeout to avoid preempting
22-
// slow-but-progressing operations.
22+
// slow-but-progressing operations. `forwardTimeoutMs` can be passed
23+
// explicitly (mainly for tests) to override the derived value.
2324
private readonly forwardTimeoutMs: number;
2425

2526
constructor(
2627
private readonly registry: WorkspaceRegistry,
27-
timeoutInSeconds: number = 180
28+
timeoutInSeconds: number = 180,
29+
forwardTimeoutMs?: number
2830
) {
29-
this.forwardTimeoutMs = timeoutInSeconds * 1000 + 15_000;
31+
this.forwardTimeoutMs = forwardTimeoutMs ?? timeoutInSeconds * 1000 + 15_000;
3032
}
3133

3234
/**
@@ -198,7 +200,7 @@ export class RoutingDebuggingHandler implements IDebuggingHandler {
198200
return this.forward('handleRestart', {});
199201
}
200202

201-
public handleAddBreakpoint(args: { fileFullPath: string; lineContent: string; condition?: string }): Promise<string> {
203+
public handleAddBreakpoint(args: { fileFullPath: string; line: number; condition?: string }): Promise<string> {
202204
return this.forward('handleAddBreakpoint', args, args.fileFullPath);
203205
}
204206

src/test/routing.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import * as assert from 'assert';
44
import * as fs from 'fs';
5+
import * as http from 'http';
56
import * as os from 'os';
67
import * as path from 'path';
78
import { ControlServer } from '../controlServer';
@@ -37,13 +38,15 @@ class RecordingHandler implements IDebuggingHandler {
3738
suite('Multi-window routing', () => {
3839
let dir: string;
3940
const servers: ControlServer[] = [];
41+
const deadServers: http.Server[] = [];
4042

4143
setup(() => {
4244
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'debugmcp-routing-test-'));
4345
});
4446

4547
teardown(async () => {
4648
await Promise.all(servers.splice(0).map(s => s.stop()));
49+
await Promise.all(deadServers.splice(0).map(s => new Promise<void>(resolve => s.close(() => resolve()))));
4750
fs.rmSync(dir, { recursive: true, force: true });
4851
});
4952

@@ -115,7 +118,7 @@ suite('Multi-window routing', () => {
115118
const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir));
116119
const result = await routing.handleAddBreakpoint({
117120
fileFullPath: path.join(repoA, 'src', 'y.py'),
118-
lineContent: 'return 1'
121+
line: 1
119122
});
120123

121124
assert.strictEqual(result, 'A:addBp');
@@ -143,6 +146,49 @@ suite('Multi-window routing', () => {
143146
await assert.rejects(() => routing.handleStepOver(), /no active debug target/i);
144147
});
145148

149+
/**
150+
* Register a window whose control server accepts connections but never
151+
* responds, to simulate a hung/unresponsive VS Code window.
152+
*/
153+
async function startDeadWindow(fileName: string, workspaceFolders: string[]): Promise<void> {
154+
const server = http.createServer(() => { /* accept, never respond */ });
155+
deadServers.push(server);
156+
const port: number = await new Promise(resolve => {
157+
server.listen(0, '127.0.0.1', () => resolve((server.address() as { port: number }).port));
158+
});
159+
fs.writeFileSync(
160+
path.join(dir, fileName),
161+
JSON.stringify({
162+
pid: process.pid,
163+
controlPort: port,
164+
controlToken: 'secret',
165+
workspaceFolders,
166+
name: fileName,
167+
updatedAt: Date.now()
168+
}),
169+
'utf8'
170+
);
171+
}
172+
173+
test('rejects with an actionable error when the target window is unresponsive', async () => {
174+
const repoA = path.join(dir, 'repoA');
175+
await startDeadWindow('a.json', [repoA]);
176+
177+
// 200ms forward timeout so the hung round-trip fails fast in the test.
178+
const routing = new RoutingDebuggingHandler(new WorkspaceRegistry(process.pid, dir), 180, 200);
179+
const start = Date.now();
180+
await assert.rejects(
181+
() => routing.handleStartDebugging({
182+
fileFullPath: path.join(repoA, 'm.py'),
183+
workingDirectory: repoA
184+
}),
185+
/did not respond within[\s\S]*unresponsive/i
186+
);
187+
const elapsed = Date.now() - start;
188+
assert.ok(elapsed >= 150, `should wait for the ~200ms forward timeout, only took ${elapsed}ms`);
189+
assert.ok(elapsed < 5000, `forward timeout should fire promptly, took ${elapsed}ms`);
190+
});
191+
146192
test('control server rejects requests with a wrong token', async () => {
147193
const repoA = path.join(dir, 'repoA');
148194
await startWindow('a.json', [repoA], new RecordingHandler('A'), 'right-token');
@@ -160,3 +206,4 @@ suite('Multi-window routing', () => {
160206
);
161207
});
162208
});
209+

0 commit comments

Comments
 (0)