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 @@ -4,6 +4,11 @@ All notable changes to DebugMCP will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed
- Startup failures now report the configured pre-launch task and exit code when available, preserve configuration and test-dispatch errors, and direct agents to launch/task diagnostics instead of assuming a missing language extension. Readiness waits are cancelled when startup fails.

## [2.3.4] - 2026-09-03

### Added
Expand Down
19 changes: 19 additions & 0 deletions docs/architecture/debuggingExecutor.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ VS Code's debug API is powerful but requires careful handling. `DebuggingExecuto

## Key Concepts

### Startup Failure Diagnostics

`src/utils/debugStartup.ts` observes task lifecycle events before dispatching a
launch. It correlates newly started executions with the selected configuration's
`preLaunchTask` and labeled `dependsOn` tasks in the same workspace. Nonzero exit
codes produce an error naming the task and configuration, even if VS Code is
still waiting for the user to dismiss a task-failure dialog. Reporting the failure
does not dismiss that dialog or cancel VS Code's pending launch request.

Thrown configuration/adapter errors are preserved. When VS Code declines startup
without details, the response directs the caller to launch/task configuration
and diagnostic output instead of assuming a missing language extension. Task
events expose an exit code, not terminal text; the response makes that limitation
explicit rather than inventing the underlying command error.
Task-identifier objects and dynamically supplied task configuration are not
resolved by this observer; startup still proceeds through VS Code normally.

### VS Code Debug Commands

Stepping and control operations use VS Code's command system:
Expand Down Expand Up @@ -79,6 +96,8 @@ A session is considered "ready" when:
2. Location info is available (file name and line number)

This handles cases where the debugger is still initializing (common with Python).
`waitForDebugSessionReady()` accepts cancellation so a failed startup does not
leave its readiness timeout and event subscriptions behind.

### State Retrieval

Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/debuggingHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,10 @@ Recursive expansion is bounded to 100 child fields total per response, shared ac
## Error Handling

All operations wrap errors with context about what operation failed, enabling AI agents to understand and potentially recover from failures.
Startup preserves task/configuration errors from the executor instead of
replacing them with an extension-installation hint. Readiness listeners are
started only after configuration resolution and cancelled when the startup
operation finishes or fails. Test-dispatch failures propagate as errors rather
than being interpreted as successful test completion.
Expression evaluation also distinguishes an adapter error from a successful
command whose result/output was not captured.
21 changes: 18 additions & 3 deletions src/debuggingExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as vscode from 'vscode';
import { DebugState, StackFrame } from './debugState';
import { logger } from './utils/logger';
import { withTimeout } from './utils/withTimeout';
import { getDebugStartupContext, startDebuggingWithDiagnostics } from './utils/debugStartup';

/**
* Outcome of dispatching `testing.debugAtCursor`.
Expand Down Expand Up @@ -43,7 +44,7 @@ export interface IDebuggingExecutor {
clearAllBreakpoints(): void;
hasActiveSession(): Promise<boolean>;
getActiveSession(): vscode.DebugSession | undefined;
waitForDebugSessionReady(timeoutMs: number): Promise<'stopped' | 'terminated' | 'timeout' | 'no-session' | 'attached'>;
waitForDebugSessionReady(timeoutMs: number, signal?: AbortSignal): Promise<'stopped' | 'terminated' | 'timeout' | 'no-session' | 'attached'>;
}

/**
Expand Down Expand Up @@ -83,7 +84,10 @@ export class DebuggingExecutor implements IDebuggingExecutor {
): Promise<boolean> {
try {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(workingDirectory));
return await vscode.debug.startDebugging(workspaceFolder, config);
return await startDebuggingWithDiagnostics(
() => vscode.debug.startDebugging(workspaceFolder, config),
getDebugStartupContext(config, workspaceFolder)
);
} catch (error) {
throw new Error(`Failed to start debugging: ${error}`);
}
Expand Down Expand Up @@ -137,6 +141,7 @@ export class DebuggingExecutor implements IDebuggingExecutor {
.then(() => undefined)
.catch(err => {
logger.error(`testing.debugAtCursor failed: ${err}`);
throw err;
});
return { started: true, runComplete };
}
Expand Down Expand Up @@ -751,8 +756,12 @@ export class DebuggingExecutor implements IDebuggingExecutor {
* start *and* terminate inside a polling interval.
*/
public async waitForDebugSessionReady(
timeoutMs: number
timeoutMs: number,
signal?: AbortSignal
): Promise<'stopped' | 'terminated' | 'timeout' | 'no-session' | 'attached'> {
if (signal?.aborted) {
return 'no-session';
}
// Helper: a session is only truly "stopped and actionable" when we have
// a DebugStackFrame (frameId present). A bare DebugThread means a thread
// is selected but the adapter hasn't published a frame yet — calling
Expand Down Expand Up @@ -794,6 +803,12 @@ export class DebuggingExecutor implements IDebuggingExecutor {
settle(trackedSession ? 'timeout' : 'no-session');
}, timeoutMs);

if (signal) {
const abort = () => settle('no-session');
signal.addEventListener('abort', abort, { once: true });
subscriptions.push(new vscode.Disposable(() => signal.removeEventListener('abort', abort)));
}

subscriptions.push(
vscode.debug.onDidStartDebugSession(session => {
logger.info(`onDidStartDebugSession: ${session.name}`);
Expand Down
20 changes: 12 additions & 8 deletions src/debuggingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,21 +83,19 @@ export class DebuggingHandler implements IDebuggingHandler {
const hasExplicitConfig = !!configurationName &&
configurationName.trim() !== '' &&
configurationName !== DebugConfigurationManager.getAutoLaunchConfigName();
const readinessAbort = new AbortController();

try {
logger.info(`handleStartDebugging: file=${fileFullPath} test=${testName ?? '<none>'} config=${configurationName ?? '<auto>'}`);

// Start listening BEFORE we trigger the debug session, otherwise
// `onDidStartDebugSession` / `onDidChangeActiveStackItem` can fire
// during the trigger call (testing.debugAtCursor / vscode.debug.startDebugging
// can resolve only after the session is already up) and we'd miss them.
const readyPromise = this.executor.waitForDebugSessionReady(this.timeoutInSeconds * 1000);

let readyPromise: ReturnType<IDebuggingExecutor['waitForDebugSessionReady']>;
let started: boolean;
let configDescription: string;
let testRunComplete: Promise<void> | undefined;

if (testName && !hasExplicitConfig) {
readyPromise = this.executor.waitForDebugSessionReady(
this.timeoutInSeconds * 1000, readinessAbort.signal);
// Route through VS Code's Testing API. This works for any language
// whose extension registers a TestController and correctly handles
// child-process attach for runners like `dotnet test`.
Expand All @@ -111,6 +109,10 @@ export class DebuggingHandler implements IDebuggingHandler {
fileFullPath,
configurationName
);
// Subscribe before launch so fast debug events are not lost,
// but only after configuration resolution has succeeded.
readyPromise = this.executor.waitForDebugSessionReady(
this.timeoutInSeconds * 1000, readinessAbort.signal);
started = await this.executor.startDebugging(workingDirectory, debugConfig);
const configName = typeof debugConfig === 'string' ? debugConfig : debugConfig.name;
configDescription = configName ? `configuration '${configName}'` : 'default configuration';
Expand Down Expand Up @@ -141,15 +143,17 @@ export class DebuggingHandler implements IDebuggingHandler {
case 'terminated':
return `Debug session for ${fileFullPath} ran to completion without stopping (no breakpoint hit). Using ${configDescription}${testInfo}. Final state: ${currentState.toString()}`;
case 'no-session':
throw new Error('Debug session failed to start within the timeout period. Make sure the appropriate language extension is installed and any required build step succeeded.');
throw new Error('No debug session started within the timeout period. Check launch.json, any preLaunchTask in tasks.json, and the task terminal or Debug Console for startup errors.');
case 'timeout':
return `Debug session is running but did not stop or terminate within the timeout for: ${fileFullPath} using ${configDescription}${testInfo}. Current state: ${currentState.toString()}`;
}
} else {
throw new Error('Failed to start debug session. Make sure the appropriate language extension is installed.');
throw new Error('Failed to start debug session: VS Code declined or cancelled startup without providing an error detail. Check launch.json, any preLaunchTask in tasks.json, and the task terminal or Debug Console.');
}
} catch (error) {
throw new Error(`Error starting debug session: ${error}`);
} finally {
readinessAbort.abort();
}
}

Expand Down
196 changes: 196 additions & 0 deletions src/test/debugStartup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Copyright (c) Microsoft Corporation.

import * as assert from 'assert';
import * as vscode from 'vscode';
import { DebuggingExecutor } from '../debuggingExecutor';
import {
getDebugStartupContext,
IDebugStartupContext,
startDebuggingWithDiagnostics
} from '../utils/debugStartup';

suite('Debug startup diagnostics', () => {
const folder: vscode.WorkspaceFolder = {
uri: vscode.Uri.file('/debugmcp-startup-tests'),
name: 'startup-tests',
index: 0
};
let started: vscode.EventEmitter<vscode.TaskStartEvent>;
let ended: vscode.EventEmitter<vscode.TaskProcessEndEvent>;
let listeners: number;

setup(() => {
started = new vscode.EventEmitter<vscode.TaskStartEvent>();
ended = new vscode.EventEmitter<vscode.TaskProcessEndEvent>();
listeners = 0;
});

teardown(() => {
started.dispose();
ended.dispose();
});

function trackedEvent<T>(event: vscode.Event<T>): vscode.Event<T> {
return listener => {
listeners++;
const subscription = event(listener);
return new vscode.Disposable(() => {
listeners--;
subscription.dispose();
});
};
}

function launch(start: () => Thenable<boolean>, overrides: Partial<IDebugStartupContext> = {}) {
return startDebuggingWithDiagnostics(start, {
configurationName: 'Launch app',
preLaunchTasks: ['Copy Item'],
workspaceFolder: folder,
...overrides
}, {
onDidStartTask: trackedEvent(started.event),
onDidEndTaskProcess: trackedEvent(ended.event)
});
}

function execution(name = 'Copy Item', scope: vscode.WorkspaceFolder | vscode.TaskScope = folder): vscode.TaskExecution {
return {
task: new vscode.Task({ type: 'shell' }, scope, name, 'test', new vscode.ShellExecution('exit 1')),
terminate: () => { /* no process is started */ }
};
}

test('reports a failed pre-launch task even while VS Code waits on a dialog', async () => {
const result = launch(() => new Promise<boolean>(() => { /* pending prompt */ }));
const task = execution();
started.fire({ execution: task });
ended.fire({ execution: task, exitCode: 1 });
await assert.rejects(result, error => {
assert.ok(error instanceof Error);
assert.match(error.message, /Pre-launch task 'Copy Item' failed with exit code 1/);
assert.match(error.message, /Launch app.*terminal output.*launch\.json and tasks\.json/);
assert.doesNotMatch(error.message, /extension.*installed/);
return true;
});
assert.strictEqual(listeners, 0);
});

test('reports task failure rather than a simultaneous false startup result', async () => {
await assert.rejects(launch(async () => {
const task = execution();
started.fire({ execution: task });
ended.fire({ execution: task, exitCode: 2 });
return false;
}), /task 'Copy Item' failed with exit code 2/);
assert.strictEqual(listeners, 0);
});

test('preserves errors thrown by VS Code configuration or adapter startup', async () => {
const failure = new Error('launch.json: program could not be resolved');
await assert.rejects(launch(async () => { throw failure; }), error => error === failure);
assert.strictEqual(listeners, 0);
});

test('uses actionable neutral guidance when VS Code returns false without diagnostics', async () => {
await assert.rejects(launch(async () => false), error => {
assert.ok(error instanceof Error);
assert.match(error.message, /declined or cancelled.*launch\.json.*tasks\.json/);
assert.doesNotMatch(error.message, /extension.*installed/);
return true;
});
assert.strictEqual(listeners, 0);
});

test('ignores failed tasks in other workspaces, unrelated tasks and pre-existing executions', async () => {
await launch(async () => {
for (const task of [
execution('unrelated'),
execution('Copy Item', { ...folder, uri: vscode.Uri.file('/other-workspace') })
]) {
started.fire({ execution: task });
ended.fire({ execution: task, exitCode: 1 });
}
ended.fire({ execution: execution(), exitCode: 1 });
return true;
});
assert.strictEqual(listeners, 0);
});

test('does not treat successful tasks or missing exit codes as build failures', async () => {
await launch(async () => {
for (const exitCode of [0, undefined]) {
const task = execution();
started.fire({ execution: task });
ended.fire({ execution: task, exitCode });
}
return true;
});
assert.strictEqual(listeners, 0);
});

test('matches task labels that include the task source', async () => {
await assert.rejects(launch(async () => {
const task = execution('build');
started.fire({ execution: task });
ended.fire({ execution: task, exitCode: 1 });
return false;
}, { preLaunchTasks: ['test: build'] }), /task 'build' failed/);
});

test('reports a failing dependency of a compound pre-launch task', async () => {
await assert.rejects(launch(async () => {
const task = execution('build');
started.fire({ execution: task });
ended.fire({ execution: task, exitCode: 3 });
return false;
}, { preLaunchTasks: ['prepare', 'build'] }), /task 'build' failed with exit code 3/);
});

test('accepts workspace-level tasks without attributing folder tasks to another workspace', async () => {
await assert.rejects(launch(async () => {
const task = execution('Copy Item', vscode.TaskScope.Workspace);
started.fire({ execution: task });
ended.fire({ execution: task, exitCode: 1 });
return false;
}), /task 'Copy Item' failed/);
});

test('collects named launch tasks and recursive dependencies without looping', () => {
const original = vscode.workspace.getConfiguration;
vscode.workspace.getConfiguration = section => ({
get: (key: string, fallback: unknown) => {
if (section === 'launch' && key === 'configurations') {
return [{ name: 'Launch app', preLaunchTask: 'prepare' }];
}
if (section === 'tasks' && key === 'tasks') {
return [
{ label: 'prepare', dependsOn: ['build', 'Copy Item', { type: 'npm', script: 'build' }] },
{ label: 'build', dependsOn: 'prepare' },
{ label: 'Copy Item', dependsOn: { type: 'npm', script: 'prepare' } }
];
}
return fallback;
}
} as vscode.WorkspaceConfiguration);
try {
assert.deepStrictEqual(getDebugStartupContext('Launch app', folder), {
configurationName: 'Launch app',
preLaunchTasks: ['prepare', 'build', 'Copy Item'],
workspaceFolder: folder
});
assert.deepStrictEqual(getDebugStartupContext({
name: 'Inline', type: 'node', request: 'launch', preLaunchTask: 'Copy Item'
}, folder).preLaunchTasks, ['Copy Item']);
} finally {
vscode.workspace.getConfiguration = original;
}
});

test('cancels readiness promptly instead of retaining the startup timeout', async () => {
const controller = new AbortController();
const ready = new DebuggingExecutor().waitForDebugSessionReady(60_000, controller.signal);
controller.abort();
assert.strictEqual(await ready, 'no-session');
assert.strictEqual(await new DebuggingExecutor().waitForDebugSessionReady(60_000, controller.signal), 'no-session');
});
});
Loading
Loading