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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Claude Code auto-registration** - Claude Code is now offered in the agent selection popup and configured via `~/.claude.json`'s user-scope `mcpServers` field. Claude Desktop connects via its Custom Connector UI instead of a static config file; the README's manual configuration section covers both.

## [2.4.2] - 2026-09-27

### Fixed
- Bound `restart_debugging` by the configured operation timeout, preserving restart failures and reporting unacknowledged completion instead of leaving the worker call pending. This safeguards against Cortex-Debug v1.12.1's missing successful restart response without mistaking stopped events for confirmed success (#160).

## [2.4.1] - 2026-09-17

### Fixed
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture/debuggingHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,19 @@ state without issuing another pause or waiting for a location change. For a
running session, it dispatches pause and waits for a stopped state or session
termination, bounded by the operation timeout.

### Restart Completion

`handleRestart()` waits for the executor's restart acknowledgement, bounded by
the configured operation timeout. It returns immediately after acknowledgement,
without an arbitrary settling delay. A continued/stopped event, even at a new
location, does not prove that all restart commands succeeded.

If completion is not acknowledged, the handler reports an error explaining that
the target may already have restarted and the underlying request was not
cancelled. It does not automatically retry or stop the session. This protects
both hosts from an unresponsive restart; notably, Cortex-Debug v1.12.1 omits its
successful DAP restart response. Actual command failures still propagate.

### Root Cause Analysis

When debugging stops, the handler prompts AI agents to consider whether they found the root cause or just a symptom, encouraging deeper investigation.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "debugmcpextension",
"displayName": "DebugMCP — Agentic Debugging for VS Code, Cursor & More",
"description": "Your AI agent debugs for you — right inside VS Code, Cursor & other VS Code-based editors. Let Copilot, Cline, Cursor, Codex & any MCP agent set breakpoints, step through code, and inspect variables live instead of guessing from logs.",
"version": "2.4.1",
"version": "2.4.2",
"publisher": "ozzafar",
"author": {
"name": "Oz Zafar",
Expand Down
21 changes: 21 additions & 0 deletions skills/debug-live/references/troubleshooting/cpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,27 @@
- **Segmentation fault:** Use backtrace to find the crashing line, check pointer operations
- **Optimized away variables:** Compile with `-O0` to disable optimizations

## Cortex-Debug restart hangs

Cortex-Debug v1.12.1 can reset the target without acknowledging the DAP `restart`
request. Its [successful restart handler](https://github.com/Marus/cortex-debug/blob/652d042da96d51b8f4b13d42d2e74df8efc1e28f/src/gdb.ts#L1543-L1612)
resolves an internal promise but never sends the response VS Code awaits.
DebugMCP bounds that wait using `debugmcp.timeoutInSeconds` and reports a timeout,
not a false success based on a continued/stopped event.

After a restart timeout, inspect `get_debug_status` and the Debug Console before
retrying: the device may already have reset, and timing out does not cancel the
request. Do not automatically reset it again or tear down an embedded session.
For diagnosis, record the installed Cortex-Debug and VS Code versions and enable
`"showDevDebugOutput": "vscode"` in the launch configuration for a subsequent
reproduction. Look for a `restart` request and its matching response, and sanitize
logs before sharing them.

Do not assume every Cortex-Debug version uses this path. Newer upstream code
[disables DAP restart](https://github.com/Marus/cortex-debug/commit/6236742f20884c5cb57b4e7f24394b3a942cd4f5)
in favor of VS Code's session teardown/relaunch. A timeout safeguards the client;
it does not repair a missing response in the adapter.

## Memory Debugging:
- **Valgrind:** `valgrind --leak-check=full ./program`
- **Address Sanitizer:** Compile with `-fsanitize=address -fno-omit-frame-pointer`
Expand Down
15 changes: 10 additions & 5 deletions src/debuggingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DebugConfigurationManager, IDebugConfigurationManager } from './utils/d
import { DebugState } from './debugState';
import { IDebuggingExecutor } from './debuggingExecutor';
import { logger } from './utils/logger';
import { withTimeout } from './utils/withTimeout';
import {
isSensitiveExpression,
isSensitiveName,
Expand Down Expand Up @@ -66,7 +67,6 @@ function describeLocation(state: DebugState): string {
*/
export class DebuggingHandler implements IDebuggingHandler {
private readonly numNextLines: number = 3;
private readonly executionDelay: number = 300; // ms to wait for debugger updates
private readonly timeoutInSeconds: number;

constructor(
Expand Down Expand Up @@ -389,10 +389,15 @@ export class DebuggingHandler implements IDebuggingHandler {
throw new Error('No active debug session to restart');
}

await this.executor.restart();

// Wait for debugger to restart
await new Promise(resolve => setTimeout(resolve, this.executionDelay));
await withTimeout(
this.executor.restart(),
this.timeoutInSeconds * 1000,
() => new Error(
`Restart timed out after ${this.timeoutInSeconds}s without completion acknowledgement. ` +
'The target may already have restarted; the request has not been cancelled. ' +
'Check get_debug_status and the Debug Console before retrying.'
)
);

return 'Debug session restarted successfully';
} catch (error) {
Expand Down
181 changes: 181 additions & 0 deletions src/test/restartDebugging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Copyright (c) Microsoft Corporation.

import * as assert from 'node:assert/strict';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { DebuggingHandler } from '../debuggingHandler';
import { CliDebuggingExecutor } from '../cli/cliDebuggingExecutor';
import { CliDebugConfiguration } from '../cli/cliConfigurationManager';
import { IDebugConfigurationManager } from '../utils/debugConfigurationManager';

suite('Restart completion (#160)', () => {
function handler(restart: () => Promise<void>, active = true, timeout = 0.05): DebuggingHandler {
const executor = new CliDebuggingExecutor();
executor.hasActiveSession = async () => active;
executor.restart = restart;
return new DebuggingHandler(executor, {} as IDebugConfigurationManager, timeout);
}

test('does not dispatch without an active session', async () => {
let called = false;
const target = handler(async () => { called = true; }, false);
await assert.rejects(() => target.handleRestart(), /No active debug session/);
assert.equal(called, false);
});

test('preserves a restart rejection rather than returning success', async () => {
const target = handler(async () => { throw new Error('GDB restart rejected'); });
await assert.rejects(() => target.handleRestart(), /GDB restart rejected/);
});

test('an unacknowledged restart is bounded by the operation timeout', async () => {
let release: () => void = () => {};
const target = handler(() => new Promise<void>(resolve => { release = resolve; }));
// Release the simulated adapter eventually so the unfixed test cannot hang.
const timer = setTimeout(() => release(), 400);
try {
await assert.rejects(() => target.handleRestart(), /restart.*timed out/i);
} finally {
clearTimeout(timer);
release();
}
});

test('successful restart returns without a fixed settling delay', async () => {
const target = handler(async () => {});
const start = Date.now();
assert.match(await target.handleRestart(), /restarted successfully/);
assert.ok(Date.now() - start < 200, 'a successful acknowledgement needs no blind sleep');
});

test('waits for a delayed acknowledgement before returning success', async () => {
let release: () => void = () => {};
const target = handler(() => new Promise<void>(resolve => { release = resolve; }), true, 1);
let settled = false;
const result = target.handleRestart().then(value => { settled = true; return value; });
try {
await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(settled, false);
} finally {
release();
}
assert.match(await result, /restarted successfully/);
});

test('a late rejection after timeout does not become an unhandled rejection', async () => {
let rejectRestart: (error: Error) => void = () => {};
const target = handler(() => new Promise<void>((_resolve, reject) => { rejectRestart = reject; }));
const timer = setTimeout(() => rejectRestart(new Error('late adapter error')), 400);
try {
await assert.rejects(() => target.handleRestart(), /restart.*timed out/i);
rejectRestart(new Error('late adapter error'));
await new Promise(resolve => setTimeout(resolve, 20));
} finally {
clearTimeout(timer);
rejectRestart(new Error('cleanup'));
}
});
});

suite('Restart acknowledgement over DAP (#160)', () => {
let directory: string;
let executor: CliDebuggingExecutor;
let config: CliDebugConfiguration;

setup(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'debugmcp-restart-'));
const adapterPath = path.join(directory, 'adapter.cjs');
await fs.writeFile(adapterPath, `
let buffer = Buffer.alloc(0);
let seq = 1;
let mode;
function send(message) {
const payload = Buffer.from(JSON.stringify({ seq: seq++, ...message }));
process.stdout.write('Content-Length: ' + payload.length + '\\r\\n\\r\\n');
process.stdout.write(payload);
}
function stopped() {
send({ type: 'event', event: 'stopped', body: {
reason: 'restart', threadId: 1, allThreadsStopped: true
} });
}
function handle(request) {
const response = { type: 'response', request_seq: request.seq, command: request.command, success: true };
switch (request.command) {
case 'initialize':
send({ ...response, body: { supportsConfigurationDoneRequest: true, supportsRestartRequest: true } });
break;
case 'launch':
mode = request.arguments.mode;
send(response);
send({ type: 'event', event: 'initialized' });
break;
case 'configurationDone':
send(response);
stopped();
break;
case 'restart':
send({ type: 'event', event: 'continued', body: { threadId: 1, allThreadsContinued: true } });
stopped();
if (mode === 'success') send(response);
if (mode === 'failure') send({ ...response, success: false, message: 'GDB restart rejected' });
break;
case 'stackTrace':
send({ ...response, body: { stackFrames: [{ id: 4096, name: 'main', line: 2, column: 1 }] } });
break;
default:
send(response);
}
}
process.stdin.on('data', chunk => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const headerEnd = buffer.indexOf('\\r\\n\\r\\n');
if (headerEnd < 0) return;
const length = Number(/Content-Length:\\s*(\\d+)/i.exec(buffer.subarray(0, headerEnd).toString('ascii'))[1]);
const start = headerEnd + 4;
if (buffer.length < start + length) return;
const request = JSON.parse(buffer.subarray(start, start + length).toString('utf8'));
buffer = buffer.subarray(start + length);
handle(request);
}
});
`, 'utf8');
executor = new CliDebuggingExecutor();
config = {
name: 'restart acknowledgement',
type: 'fake',
request: 'launch',
adapterName: 'fake',
adapter: { command: process.execPath, args: [adapterPath], type: 'fake', extensions: ['.fake'] }
};
});

teardown(async () => {
try {
await executor.dispose();
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
});

for (const mode of ['success', 'failure', 'missing']) {
test(`${mode} acknowledgement after a fresh stop`, async () => {
await executor.startDebugging(directory, { ...config, mode });
await executor.waitForDebugSessionReady(1_000);
const before = await executor.getCurrentDebugState();
assert.equal(before.isPaused(), true);
const target = new DebuggingHandler(executor, {} as IDebugConfigurationManager, 0.1);
if (mode === 'success') {
assert.match(await target.handleRestart(), /restarted successfully/);
} else {
await assert.rejects(() => target.handleRestart(),
mode === 'failure' ? /GDB restart rejected/ : /Restart timed out.*not been cancelled/);
}
const after = await executor.getCurrentDebugState();
assert.equal(after.isPaused(), true);
assert.notEqual(after.stopSequence, before.stopSequence);
});
}
});
Loading