diff --git a/CHANGELOG.md b/CHANGELOG.md index cc55e09..951c4ad 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 +- Recognize paused frame/thread contexts without source locations in status and state-change detection. + ## [2.3.4] - 2026-09-03 ### Added @@ -117,4 +122,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Initial release - Core debugging capabilities via MCP protocol - VS Code Debug Adapter Protocol integration -- Automatic MCP server startup on extension activation \ No newline at end of file +- Automatic MCP server startup on extension activation diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index 4c737b5..2bf4cea 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -105,3 +105,7 @@ Recursive expansion is bounded to 100 child fields total per response, shared ac All operations wrap errors with context about what operation failed, enabling AI agents to understand and potentially recover from failures. Expression evaluation also distinguishes an adapter error from a successful command whose result/output was not captured. + +## Source-less frames + +Paused status and state transitions use an active frame/thread context, independently of source location. This supports rdbg, native/disassembly frames, and sourceReference-only adapters. A still-paused source-less frame is not a resume; frame/thread changes are meaningful even when neither frame has file/line metadata. diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index 12fdc69..b633911 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -295,11 +295,15 @@ export class DebuggingHandler implements IDebuggingHandler { const startedAt = Date.now(); let state = await this.executor.getCurrentDebugState(this.numNextLines); - if (waitSeconds > 0 && state.sessionActive && !state.hasLocationInfo()) { + if (waitSeconds > 0 && state.sessionActive && !state.hasValidContext()) { state = await this.waitForPause(waitSeconds * 1000); } - const paused = state.sessionActive && state.hasLocationInfo(); + // A stopped DAP frame is actionable even when the adapter cannot + // map it to a local source file. rdbg, disassembly-only frames, and + // sourceReference-backed adapters may all provide frame/thread IDs + // while leaving file/line empty. + const paused = state.hasValidContext(); logger.info( `debug status: ${paused ? 'paused' : 'running'} at ${describeLocation(state)} ` + `after ${Date.now() - startedAt}ms (waited up to ${waitSeconds}s)` @@ -375,7 +379,7 @@ export class DebuggingHandler implements IDebuggingHandler { ); void this.executor.getCurrentDebugState(this.numNextLines).then(currentState => { - if (!currentState.sessionActive || currentState.hasLocationInfo()) { + if (!currentState.sessionActive || currentState.hasValidContext()) { settle('fast path'); } }); @@ -1067,7 +1071,7 @@ export class DebuggingHandler implements IDebuggingHandler { // time we subscribed (e.g. a trivial single-line step), or the // program may already be running again after a continue. void this.executor.getCurrentDebugState(this.numNextLines).then(currentState => { - const resumed = settleOnResume && currentState.sessionActive && !currentState.hasLocationInfo(); + const resumed = settleOnResume && currentState.sessionActive && !currentState.hasValidContext(); if (this.hasStateChanged(beforeState, currentState) || !currentState.sessionActive || resumed) { settle('fast path'); } @@ -1091,7 +1095,7 @@ export class DebuggingHandler implements IDebuggingHandler { * Determine if the debugger state has meaningfully changed */ private hasStateChanged(beforeState: DebugState, afterState: DebugState): boolean { - if (beforeState.hasLocationInfo() && !afterState.hasLocationInfo() && afterState.sessionActive) { + if (beforeState.hasValidContext() && !afterState.hasValidContext() && afterState.sessionActive) { return false; } @@ -1099,35 +1103,47 @@ export class DebuggingHandler implements IDebuggingHandler { if (beforeState.sessionActive !== afterState.sessionActive) { return true; } - + // If session is no longer active, that's a change if (!afterState.sessionActive) { return true; } - - // If either state lacks location info, compare what we can - if (!beforeState.hasLocationInfo() || !afterState.hasLocationInfo()) { - // If one has location info and the other doesn't, that's a change - return beforeState.hasLocationInfo() !== afterState.hasLocationInfo(); + + // A frame can be stopped and actionable without source information. + // Detect context arrival and frame changes independently from location. + if (beforeState.hasValidContext() !== afterState.hasValidContext()) { + return true; } - - // Compare file paths - if we moved to a different file, that's a change - if (beforeState.fileFullPath !== afterState.fileFullPath) { + + if (beforeState.threadId !== afterState.threadId) { return true; } - - // Compare line numbers - if we moved to a different line, that's a change - if (beforeState.currentLine !== afterState.currentLine) { + + if (beforeState.frameId !== afterState.frameId) { return true; } - - // Compare frame names - if we moved to a different function/method, that's a change + if (beforeState.frameName !== afterState.frameName) { return true; } - - // Compare frame IDs - internal frame change - if (beforeState.frameId !== afterState.frameId) { + + if (beforeState.hasLocationInfo() !== afterState.hasLocationInfo()) { + return true; + } + + // If neither state has a source location, context comparisons above are + // all that are available. + if (!beforeState.hasLocationInfo()) { + return false; + } + + // Compare file paths - if we moved to a different file, that's a change + if (beforeState.fileFullPath !== afterState.fileFullPath) { + return true; + } + + // Compare line numbers - if we moved to a different line, that's a change + if (beforeState.currentLine !== afterState.currentLine) { return true; } diff --git a/src/test/debuggingHandler.test.ts b/src/test/debuggingHandler.test.ts index 39ded7e..364255a 100644 --- a/src/test/debuggingHandler.test.ts +++ b/src/test/debuggingHandler.test.ts @@ -1,5 +1,6 @@ // Copyright (c) Microsoft Corporation. +import * as vscode from 'vscode'; import * as assert from 'assert'; import * as fs from 'fs'; import * as os from 'os'; @@ -109,6 +110,23 @@ suite('DebuggingHandler State Change Detection', () => { assert.strictEqual(hasStateChanged, true); }); + + test('hasStateChanged should detect frame changes without source locations', () => { + const handler = new DebuggingHandler({} as any, {} as any, 30); + + const beforeState = new DebugState(); + beforeState.sessionActive = true; + beforeState.updateContext(1, 1); + beforeState.updateFrameName('first'); + + const afterState = beforeState.clone(); + afterState.updateContext(2, 1); + afterState.updateFrameName('second'); + + const hasStateChanged = (handler as any).hasStateChanged(beforeState, afterState); + + assert.strictEqual(hasStateChanged, true); + }); }); /** @@ -236,6 +254,14 @@ suite('DebuggingHandler continue on a never-stopping process', () => { return s; } + function pausedStateWithoutLocation(): DebugState { + const s = new DebugState(); + s.sessionActive = true; + s.updateContext(1, 1); + s.updateFrameName('money'); + return s; + } + function makeExecutor(getState: (call: number) => DebugState): IDebuggingExecutor { let call = 0; return { @@ -275,6 +301,17 @@ suite('DebuggingHandler continue on a never-stopping process', () => { assert.ok(elapsed < 2000, 'continue should resolve as soon as the program resumes, took ' + elapsed + 'ms'); }); + test('continue does not mistake a source-less stopped frame for resumed execution', async () => { + const executor = makeExecutor(() => pausedStateWithoutLocation()); + const handler = new DebuggingHandler(executor, {} as any, 0.3); + + const started = Date.now(); + await handler.handleContinue(); + const elapsed = Date.now() - started; + + assert.ok(elapsed >= 200, 'a still-paused frame should wait for resume, only took ' + elapsed + 'ms'); + }); + test('step still waits for the next frame rather than settling on the resume', async () => { // Same state sequence, but stepping must NOT treat "running" as arrival, // otherwise a step would return a frameless state mid-step. @@ -306,6 +343,14 @@ suite('DebuggingHandler get_debug_status', () => { return s; } + function pausedStateWithoutLocation(): DebugState { + const s = new DebugState(); + s.sessionActive = true; + s.updateContext(1, 1); + s.updateFrameName('money'); + return s; + } + function makeExecutor(state: DebugState, hasSession = true): IDebuggingExecutor { return { startDebugging: async () => true, @@ -341,6 +386,59 @@ suite('DebuggingHandler get_debug_status', () => { assert.strictEqual(result.state.fileName, 'file.js'); }); + test('reports an rdbg frame as paused when source location is unavailable', async () => { + const handler = new DebuggingHandler(makeExecutor(pausedStateWithoutLocation()), {} as any, 30); + + const started = Date.now(); + const result = JSON.parse(await handler.handleGetDebugStatus({ waitForPauseSeconds: 30 })); + const elapsed = Date.now() - started; + + assert.strictEqual(result.status, 'paused'); + assert.strictEqual(result.paused, true); + assert.strictEqual(result.state.frameId, 1); + assert.strictEqual(result.state.threadId, 1); + assert.strictEqual(result.state.fileName, null); + assert.strictEqual(result.state.currentLine, null); + assert.ok(elapsed < 1000, 'an existing rdbg frame must not wait, took ' + elapsed + 'ms'); + }); + + test('waiting settles when a source-less stopped frame becomes available', async () => { + let call = 0; + const executor = makeExecutor(runningState()); + executor.getCurrentDebugState = async () => (call++ === 0 ? runningState() : pausedStateWithoutLocation()); + const handler = new DebuggingHandler(executor, {} as any, 30); + + const started = Date.now(); + const result = JSON.parse(await handler.handleGetDebugStatus({ waitForPauseSeconds: 30 })); + const elapsed = Date.now() - started; + + assert.strictEqual(result.status, 'paused'); + assert.strictEqual(result.state.frameName, 'money'); + assert.ok(elapsed < 1000, 'the source-less stopped frame should settle the wait, took ' + elapsed + 'ms'); + }); + + for (const adapter of [ 'ruby_lsp', 'cppdbg', 'pwa-node' ]) { + test(`${adapter}: source-less frame and thread transitions preserve paused status`, async () => { + const before = pausedStateWithoutLocation(); + const executor = makeExecutor(before); + executor.getActiveSession = () => ({ type: adapter }) as vscode.DebugSession; + const handler = new DebuggingHandler(executor, {} as any, 1); + const output = JSON.parse(await handler.handleGetDebugStatus({ waitForPauseSeconds: 1 })); + assert.strictEqual(output.status, 'paused'); + const changed = (after: DebugState) => (handler as any).hasStateChanged(before, after); + assert.strictEqual(changed(before.clone()), false); + const nextThread = before.clone(); + nextThread.updateContext(1, 2); + assert.strictEqual(changed(nextThread), true); + const nextFrame = before.clone(); + nextFrame.updateContext(2, 1); + assert.strictEqual(changed(nextFrame), true); + assert.strictEqual(changed(runningState()), false); + assert.strictEqual(changed(new DebugState()), true); + assert.strictEqual(changed(pausedState()), true); + }); + } + test('reports running without waiting and without throwing', async () => { const handler = new DebuggingHandler(makeExecutor(runningState()), {} as any, 30);