diff --git a/CHANGELOG.md b/CHANGELOG.md index cc55e09..3f6b438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index d2aaea0..a819b1d 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -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: @@ -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 diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index 4c737b5..0863c42 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -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. diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index 5ec5333..c1eb067 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -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`. @@ -43,7 +44,7 @@ export interface IDebuggingExecutor { clearAllBreakpoints(): void; hasActiveSession(): Promise; 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'>; } /** @@ -83,7 +84,10 @@ export class DebuggingExecutor implements IDebuggingExecutor { ): Promise { 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}`); } @@ -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 }; } @@ -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 @@ -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}`); diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index 12fdc69..1f3a526 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -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 ?? ''} config=${configurationName ?? ''}`); - // 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; let started: boolean; let configDescription: string; let testRunComplete: Promise | 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`. @@ -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'; @@ -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(); } } diff --git a/src/test/debugStartup.test.ts b/src/test/debugStartup.test.ts new file mode 100644 index 0000000..2758f5f --- /dev/null +++ b/src/test/debugStartup.test.ts @@ -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; + let ended: vscode.EventEmitter; + let listeners: number; + + setup(() => { + started = new vscode.EventEmitter(); + ended = new vscode.EventEmitter(); + listeners = 0; + }); + + teardown(() => { + started.dispose(); + ended.dispose(); + }); + + function trackedEvent(event: vscode.Event): vscode.Event { + return listener => { + listeners++; + const subscription = event(listener); + return new vscode.Disposable(() => { + listeners--; + subscription.dispose(); + }); + }; + } + + function launch(start: () => Thenable, overrides: Partial = {}) { + 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(() => { /* 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'); + }); +}); diff --git a/src/test/startDebuggingMatrix.test.ts b/src/test/startDebuggingMatrix.test.ts index 00b2798..4d162d0 100644 --- a/src/test/startDebuggingMatrix.test.ts +++ b/src/test/startDebuggingMatrix.test.ts @@ -124,6 +124,45 @@ const LANGUAGES: LangCase[] = [ suite('handleStartDebugging regression matrix', () => { + test('task failure reaches the caller and cancels the readiness listener', async () => { + const { executor, configManager } = makeMocks({ + startResult: new Error("Pre-launch task 'Copy Item' failed with exit code 1. Check tasks.json.") + }); + let readinessSignal: AbortSignal | undefined; + executor.waitForDebugSessionReady = (_timeout, signal) => { + readinessSignal = signal; + return new Promise(resolve => signal?.addEventListener('abort', () => resolve('no-session'), { once: true })); + }; + await assert.rejects(new DebuggingHandler(executor, configManager, 30).handleStartDebugging({ + fileFullPath: '/repo/app.js', workingDirectory: '/repo' + }), /Copy Item.*exit code 1.*tasks\.json/); + assert.strictEqual(readinessSignal?.aborted, true); + }); + + test('configuration errors do not start a readiness listener', async () => { + const { executor, configManager } = makeMocks({ debugConfig: new Error('Invalid launch.json') }); + executor.waitForDebugSessionReady = async () => { + assert.fail('readiness must start after config resolution'); + }; + await assert.rejects(new DebuggingHandler(executor, configManager, 30).handleStartDebugging({ + fileFullPath: '/repo/app.js', workingDirectory: '/repo' + }), /Invalid launch\.json/); + }); + + test('test dispatch completion errors are not reported as successful termination', async () => { + const ready = deferred(); + const completion = deferred(); + const { executor, configManager } = makeMocks({ + readyState: ready, testDispatch: { started: true, runComplete: completion.promise } + }); + const result = new DebuggingHandler(executor, configManager, 30).handleStartDebugging({ + fileFullPath: '/repo/app.js', workingDirectory: '/repo', testName: 'fails' + }); + completion.reject(new Error('Test runner configuration failed')); + await assert.rejects(result, /Test runner configuration failed/); + ready.resolve('no-session'); + }); + // ------------------------------------------------------------------------- // Launch path (no testName) — uses executor.startDebugging + readyPromise. // ------------------------------------------------------------------------- diff --git a/src/utils/debugStartup.ts b/src/utils/debugStartup.ts new file mode 100644 index 0000000..175c9da --- /dev/null +++ b/src/utils/debugStartup.ts @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. + +import * as vscode from 'vscode'; + +export interface IDebugStartupContext { + configurationName?: string; + preLaunchTasks: readonly string[]; + workspaceFolder?: vscode.WorkspaceFolder; +} + +interface IConfiguredTask { + label?: string; + dependsOn?: string | vscode.TaskDefinition | (string | vscode.TaskDefinition)[]; +} + +export function getDebugStartupContext( + config: string | vscode.DebugConfiguration, + workspaceFolder?: vscode.WorkspaceFolder +): IDebugStartupContext { + const launch = vscode.workspace.getConfiguration('launch', workspaceFolder?.uri); + const configuration = typeof config === 'string' + ? [...launch.get('configurations', []), + ...launch.get('compounds', [])].find(entry => entry.name === config) + : config; + const tasks = vscode.workspace.getConfiguration('tasks', workspaceFolder?.uri) + .get('tasks', []); + const preLaunchTasks = new Set(); + const addTask = (name: string) => { + if (preLaunchTasks.has(name)) { + return; + } + preLaunchTasks.add(name); + const dependencies = tasks.find(task => task.label === name)?.dependsOn; + for (const dependency of Array.isArray(dependencies) ? dependencies : [dependencies]) { + if (typeof dependency === 'string') { + addTask(dependency); + } + } + }; + if (typeof configuration?.preLaunchTask === 'string') { + addTask(configuration.preLaunchTask); + } + return { + configurationName: typeof config === 'string' ? config : config.name, + preLaunchTasks: [...preLaunchTasks], + workspaceFolder + }; +} + +export async function startDebuggingWithDiagnostics( + start: () => Thenable, + context: IDebugStartupContext, + taskEvents: Pick = vscode.tasks +): Promise { + const executions = new Set(); + const subscriptions: vscode.Disposable[] = []; + const failure = new Promise(resolve => { + subscriptions.push(taskEvents.onDidStartTask(({ execution }) => { + const task = execution.task; + const matchesName = context.preLaunchTasks.some(name => + name === task.name || name === `${task.source}: ${task.name}`); + const scope = task.scope; + const matchesWorkspace = typeof scope === 'object' + ? scope.uri.toString() === context.workspaceFolder?.uri.toString() + : scope === vscode.TaskScope.Workspace || !context.workspaceFolder; + if (matchesName && matchesWorkspace) { + executions.add(execution); + } + })); + subscriptions.push(taskEvents.onDidEndTaskProcess(({ execution, exitCode }) => { + if (!executions.delete(execution) || exitCode === undefined || exitCode === 0) { + return; + } + resolve(new Error( + `Pre-launch task '${execution.task.name}' failed with exit code ${exitCode}` + + (context.configurationName ? ` while starting '${context.configurationName}'` : '') + + '. Check its terminal output and the preLaunchTask/dependsOn configuration in launch.json and tasks.json. ' + + 'VS Code does not include the task terminal output in this event.' + )); + })); + }); + + try { + // A failed task can leave startDebugging pending on VS Code's "Debug Anyway" + // dialog. Report the known failure without waiting for user interaction. + const result = await Promise.race([Promise.resolve().then(start), failure]); + if (result instanceof Error) { + throw result; + } + if (!result) { + throw new Error( + 'Failed to start debug session' + + (context.configurationName ? ` for configuration '${context.configurationName}'` : '') + + '. 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 for the underlying error.' + ); + } + return true; + } finally { + subscriptions.forEach(subscription => subscription.dispose()); + } +}