Skip to content
Open
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
7 changes: 6 additions & 1 deletion 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
- Recognize paused frame/thread contexts without source locations in status and state-change detection.

## [2.3.4] - 2026-09-03

### Added
Expand Down Expand Up @@ -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
- Automatic MCP server startup on extension activation
4 changes: 4 additions & 0 deletions docs/architecture/debuggingHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 38 additions & 22 deletions src/debuggingHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
Expand Down Expand Up @@ -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');
}
});
Expand Down Expand Up @@ -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');
}
Expand All @@ -1091,43 +1095,55 @@ 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;
}

// If session status changed, that's a meaningful change
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;
}

Expand Down
98 changes: 98 additions & 0 deletions src/test/debuggingHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Expand Down