From 02d8787cfc87af90f93c64b5c6f4e9273c88eaec Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:57:09 +0300 Subject: [PATCH 1/2] fix: track stopped debugger state and make pause idempotent Handle source-less and empty-stack stops, discard stale execution snapshots, and bump the extension to 2.4.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 5 + README.md | 2 +- docs/architecture/debugState.md | 14 +- docs/architecture/debuggingExecutor.md | 32 ++- docs/architecture/debuggingHandler.md | 16 ++ package-lock.json | 4 +- package.json | 2 +- src/cli/cliDebuggingExecutor.ts | 43 ++- src/debugState.ts | 13 + src/debuggingExecutor.ts | 47 +++- src/debuggingHandler.ts | 56 ++-- src/extension.ts | 6 +- src/test/cliExecutionState.test.ts | 72 +++++ src/test/debugSessionTracker.test.ts | 349 +++++++++++++++++++++++++ src/test/debugState.test.ts | 72 +++++ src/test/debugStatus.test.ts | 319 ++++++++++++++++++++++ src/utils/debugSessionTracker.ts | 148 +++++++++++ 17 files changed, 1147 insertions(+), 53 deletions(-) create mode 100644 src/test/cliExecutionState.test.ts create mode 100644 src/test/debugSessionTracker.test.ts create mode 100644 src/test/debugState.test.ts create mode 100644 src/test/debugStatus.test.ts create mode 100644 src/utils/debugSessionTracker.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b9b4d..a6765b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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.1] - 2026-09-17 + +### Fixed +- Track stopped/continued debugger events so `get_debug_status` recognizes paused targets even when source or stack frames are unavailable. `pause_execution` now returns immediately for an already-paused session instead of waiting for another step or the operation timeout (#157). + ## [2.3.5] - 2026-09-09 ### Fixed diff --git a/README.md b/README.md index bd752d2..a326474 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![VS Code](https://img.shields.io/badge/VS%20Code-1.104.0+-blue.svg)](https://code.visualstudio.com/) -[![Version](https://img.shields.io/badge/version-2.4.0-green.svg)](https://github.com/microsoft/DebugMCP) +[![Version](https://img.shields.io/badge/version-2.4.1-green.svg)](https://github.com/microsoft/DebugMCP) [![VS Marketplace](https://img.shields.io/badge/VS%20Marketplace-Install-blue.svg)](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension) diff --git a/docs/architecture/debugState.md b/docs/architecture/debugState.md index 5a0ff12..be34c0c 100644 --- a/docs/architecture/debugState.md +++ b/docs/architecture/debugState.md @@ -19,7 +19,8 @@ Debugging operations are asynchronous - the debugger takes time to execute and u | Property | Type | Description | |----------|------|-------------| -| `sessionActive` | `boolean` | Whether a debug session is running | +| `sessionActive` | `boolean` | Whether a debug session exists, running or stopped | +| `paused` | `boolean \| null` | Observed execution state, or `null` when unobserved | | `fileFullPath` | `string \| null` | Full path to current file | | `fileName` | `string \| null` | Just the filename | | `currentLine` | `number \| null` | 1-based line number | @@ -33,7 +34,8 @@ Debugging operations are asynchronous - the debugger takes time to execute and u | Method | Purpose | |--------|---------| -| `hasValidContext()` | Check if frame/thread IDs are set | +| `isPaused()` | Check observed stopped state, falling back to frame context only when unknown | +| `hasValidContext()` | Check for an active session with frame/thread IDs for inspection | | `hasLocationInfo()` | Check if file/line info is available | | `hasFrameName()` | Check if frame name is available | | `clone()` | Create a deep copy for comparison | @@ -60,3 +62,11 @@ Debugging operations are asynchronous - the debugger takes time to execute and u - **Immutable by convention**: Use `clone()` when you need a snapshot - **Incremental building**: State is built via multiple update calls during retrieval - **Null-safe**: All optional fields default to null, with helper methods to check validity +- **Independent execution state**: A stopped target need not provide a stack frame, + source path, or readable source. Explicit running state overrides stale frame IDs; + missing frames are never replaced by fabricated identifiers. +- **Compatibility fallback**: Sessions that predate DAP observation use available + frame/thread context to infer a pause. A selected thread alone is insufficient. +- **Snapshot lifecycle**: Cloning preserves observed execution state; resetting + returns it to unknown. JSON output includes the computed `isPaused()` boolean, + so an inactive session is never serialized as paused. diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index bda369e..563ce88 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -109,9 +109,13 @@ text that Cortex-Debug does not include in its `evaluate` response: A session is considered "ready" when: 1. `vscode.debug.activeDebugSession` exists -2. Location info is available (file name and line number) +2. An active stack frame is available (frame and thread IDs), even without source This handles cases where the debugger is still initializing (common with Python). +This startup readiness check is intentionally stronger than paused status: a +frameless stop can be reported by status without claiming frame inspection is +ready. Observed running state rejects stale UI frames. Attach sessions can also +be ready while the target remains running. `waitForDebugSessionReady()` accepts cancellation so a failed startup does not leave its readiness timeout and event subscriptions behind. @@ -119,9 +123,31 @@ leave its readiness timeout and event subscriptions behind. `getCurrentDebugState()` queries multiple VS Code APIs: - `vscode.debug.activeDebugSession` - Session existence +- The injected `DebugSessionTracker` - Observed DAP execution state - `vscode.debug.activeStackItem` - Frame/thread context -- `vscode.window.activeTextEditor` - Current file and line -- DAP `stackTrace` request - Frame name +- DAP `stackTrace` request - Frame name, call stack, and source location +- Source document lookup - Optional line contents and lookahead + +Frame/thread context is populated before source lookup. A missing or unreadable +source does not discard that context or mean that execution is running. Paused +status is also independent of frames: an observed stop with an empty stack still +reports paused, while an observed continue overrides stale UI frame information. +Unobserved sessions retain the frame-based fallback; a selected thread alone does +not establish a pause. + +`src/utils/debugSessionTracker.ts` is a read-only observer of standard DAP +`stopped`, `continued`, `exited`, and `terminated` events. It tracks sessions and +thread-specific versus all-thread transitions separately. A selected thread uses +its observed state; without a selected thread, any known stop establishes paused +status. Activation creates one tracker and owns its disposal; test-created +executors can omit it. Session termination and adapter shutdown clear tracked +execution state without retaining terminated session objects. + +After asynchronous stack/source lookups, execution transitions, a changed or +cleared frame, or an ended session invalidate the old frame snapshot. The latest +observed stopped/running state is retained even when frame information is +discarded. The CLI likewise reports its DAP-backed execution state independently +of its stack and rejects frame data spanning execution transitions. ## Key Code Locations diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index 3ef2b77..a89a479 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -63,6 +63,22 @@ A state change is considered meaningful when any of these change: - Current line number - Frame name (function/method) - Frame ID +- Thread ID + +### Paused State and Pause Requests + +Paused status uses the executor's observed stopped/running state, independently +of source and stack availability. If execution state has not been observed (for +example, a session that predates tracking), an active frame/thread context is the +fallback. A bare selected thread alone is not proof of a stop. +Native/disassembly frames, unavailable local files, and empty stacks can all +occur while stopped. Status waits and navigation use this same distinction; +losing source information alone is not a resume. + +`handlePause()` is idempotent for an already-paused session: it returns the current +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. ### Root Cause Analysis diff --git a/package-lock.json b/package-lock.json index 5d7e89d..e5065ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "debugmcpextension", - "version": "2.4.0", + "version": "2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "debugmcpextension", - "version": "2.4.0", + "version": "2.4.1", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index 4743edf..d7f2123 100644 --- a/package.json +++ b/package.json @@ -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.0", + "version": "2.4.1", "publisher": "ozzafar", "author": { "name": "Oz Zafar", diff --git a/src/cli/cliDebuggingExecutor.ts b/src/cli/cliDebuggingExecutor.ts index cfdac5b..39a1e4e 100644 --- a/src/cli/cliDebuggingExecutor.ts +++ b/src/cli/cliDebuggingExecutor.ts @@ -27,6 +27,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { private frameId?: number; private capabilities: Record = {}; private initialized = false; + private stateRevision = 0; public async startDebugging( workingDirectory: string, @@ -212,30 +213,31 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { } public async getCurrentDebugState(numNextLines = 3): Promise { - const result = new DebugState(); - result.sessionActive = this.state !== 'none' && this.state !== 'terminated'; - result.updateConfigurationName(this.session?.name ?? null); - result.updateBreakpoints(this.breakpoints.map(item => { - const suffix = item.condition ? ` [when: ${item.condition}]` : ''; - return `${path.basename(item.fileFullPath)}:${item.line}${suffix}`; - })); - + const result = this.createStateSnapshot(); if (!result.sessionActive || this.state !== 'stopped' || this.threadId === undefined) { return result; } - const response = await this.requireClient().request('stackTrace', { - threadId: this.threadId, + const client = this.requireClient(); + const threadId = this.threadId; + const revision = this.stateRevision; + const isCurrent = () => this.client === client && this.state === 'stopped' && + this.threadId === threadId && this.stateRevision === revision; + const response = await client.request('stackTrace', { + threadId, startFrame: 0, levels: 50 }); + if (!isCurrent()) { + return this.createStateSnapshot(); + } const frames = Array.isArray(response?.stackFrames) ? response.stackFrames : []; if (frames.length === 0) { + this.frameId = undefined; return result; } const current = frames[0]; - this.frameId = current.id; - result.updateContext(current.id, this.threadId); + result.updateContext(current.id, threadId); result.updateFrameName(current.name ?? null); result.updateStackTrace(frames.map((frame: any): StackFrame => ({ name: frame.name ?? 'unknown', @@ -247,6 +249,22 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { if (typeof current.source?.path === 'string' && typeof current.line === 'number') { await this.populateSource(result, current.source.path, current.line, numNextLines); } + if (!isCurrent()) { + return this.createStateSnapshot(); + } + this.frameId = current.id; + return result; + } + + private createStateSnapshot(): DebugState { + const result = new DebugState(); + result.sessionActive = this.state !== 'none' && this.state !== 'terminated'; + result.paused = this.state === 'stopped'; + result.updateConfigurationName(this.session?.name ?? null); + result.updateBreakpoints(this.breakpoints.map(item => { + const suffix = item.condition ? ` [when: ${item.condition}]` : ''; + return `${path.basename(item.fileFullPath)}:${item.line}${suffix}`; + })); return result; } @@ -391,6 +409,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { } private emitState(): void { + this.stateRevision++; this.events.emit('state'); } diff --git a/src/debugState.ts b/src/debugState.ts index 6fe8c3a..fb6c7bb 100644 --- a/src/debugState.ts +++ b/src/debugState.ts @@ -15,6 +15,7 @@ export interface StackFrame { */ export class DebugState { public sessionActive: boolean; + public paused: boolean | null; public fileFullPath: string | null; public fileName: string | null; public currentLine: number | null; @@ -29,6 +30,7 @@ export class DebugState { constructor() { this.sessionActive = false; + this.paused = null; this.fileFullPath = null; this.fileName = null; this.currentLine = null; @@ -47,6 +49,7 @@ export class DebugState { */ public reset(): void { this.sessionActive = false; + this.paused = null; this.fileFullPath = null; this.fileName = null; this.currentLine = null; @@ -69,6 +72,13 @@ export class DebugState { this.threadId !== null; } + /** + * Prefer observed execution state; frames are a fallback for unobserved sessions. + */ + public isPaused(): boolean { + return this.sessionActive && (this.paused ?? this.hasValidContext()); + } + /** * Check if location information is available */ @@ -140,6 +150,7 @@ export class DebugState { public clone(): DebugState { const cloned = new DebugState(); cloned.sessionActive = this.sessionActive; + cloned.paused = this.paused; cloned.fileFullPath = this.fileFullPath; cloned.fileName = this.fileName; cloned.currentLine = this.currentLine; @@ -160,6 +171,7 @@ export class DebugState { public toString(): string { const stateObject: { sessionActive: boolean; + paused: boolean; configurationName?: string | null; stackTrace?: string[]; breakpoints?: string[]; @@ -173,6 +185,7 @@ export class DebugState { frameName?: string | null; } = { sessionActive: this.sessionActive, + paused: this.isPaused(), }; if (this.sessionActive) { diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index 3849ff4..acaa5e9 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -6,6 +6,7 @@ import { DebugBreakpoint, DebugConfiguration, DebugSessionInfo } from './debugTy import { logger } from './utils/logger'; import { withTimeout } from './utils/withTimeout'; import { getDebugStartupContext, startDebuggingWithDiagnostics } from './utils/debugStartup'; +import { DebugSessionTracker } from './utils/debugSessionTracker'; /** * Outcome of dispatching `testing.debugAtCursor`. @@ -63,6 +64,8 @@ export class DebuggingExecutor implements IDebuggingExecutor { // Kept small relative to the router/tool backstops so it fails fast. private static readonly DAP_REQUEST_TIMEOUT_MS = 30_000; + constructor(private readonly sessionTracker?: DebugSessionTracker) {} + /** * Issue a DAP request with an upper time bound, rejecting if the adapter * doesn't respond in time. @@ -394,12 +397,15 @@ export class DebuggingExecutor implements IDebuggingExecutor { try { const activeSession = vscode.debug.activeDebugSession; - if (activeSession) { + if (activeSession && !this.sessionTracker?.hasSessionEnded(activeSession)) { state.sessionActive = true; state.updateConfigurationName(activeSession.configuration.name ?? null); - const activeStackItem = vscode.debug.activeStackItem; - if (activeStackItem && 'frameId' in activeStackItem) { + const selectedItem = vscode.debug.activeStackItem; + const activeStackItem = selectedItem?.session.id === activeSession.id ? selectedItem : undefined; + const revision = this.sessionTracker?.getRevision(activeSession.id); + state.paused = this.sessionTracker?.getPausedState(activeSession.id, activeStackItem?.threadId) ?? null; + if (state.paused !== false && activeStackItem && 'frameId' in activeStackItem) { state.updateContext(activeStackItem.frameId, activeStackItem.threadId); // Pull the current location from the debug adapter's top stack @@ -415,9 +421,29 @@ export class DebuggingExecutor implements IDebuggingExecutor { await this.populateLocationFromFrame(state, topFrame.path, topFrame.line, numNextLines); } } + + // Stack/source requests can finish after the target has resumed + // or exited. Do not return the now-invalid stopped context. + const currentSession = vscode.debug.activeDebugSession; + const selectedCurrentItem = vscode.debug.activeStackItem; + const currentItem = selectedCurrentItem?.session.id === currentSession?.id ? selectedCurrentItem : undefined; + const sessionActive = currentSession !== undefined && !this.sessionTracker?.hasSessionEnded(currentSession); + const paused = sessionActive && currentSession + ? this.sessionTracker?.getPausedState(currentSession.id, currentItem?.threadId) ?? null + : null; + if (!sessionActive || currentSession?.id !== activeSession.id || paused === false || + this.sessionTracker?.getRevision(activeSession.id) !== revision || + !currentItem || !('frameId' in currentItem) || + !activeStackItem || !('frameId' in activeStackItem) || + currentItem.frameId !== activeStackItem.frameId || currentItem.threadId !== activeStackItem.threadId) { + state.reset(); + state.sessionActive = sessionActive; + state.updateConfigurationName(sessionActive ? currentSession?.configuration.name ?? null : null); + } + state.paused = paused; } } catch (error) { - console.log('Unable to get debug state:', error); + logger.error('Unable to get debug state:', error); } // Populate breakpoints as compact "fileName:line" strings @@ -786,6 +812,12 @@ export class DebuggingExecutor implements IDebuggingExecutor { public getActiveFrameId(): number | undefined { const item = vscode.debug.activeStackItem; + const session = vscode.debug.activeDebugSession; + if (this.sessionTracker && (!session || this.sessionTracker.hasSessionEnded(session) || + item?.session.id !== session.id || + this.sessionTracker.getPausedState(session.id, item?.threadId) === false)) { + return undefined; + } return item && 'frameId' in item ? item.frameId : undefined; } @@ -818,10 +850,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { // a DebugStackFrame (frameId present). A bare DebugThread means a thread // is selected but the adapter hasn't published a frame yet — calling // stackTrace/variables at that point can stall or return empty. - const isStoppedWithFrame = () => { - const item = vscode.debug.activeStackItem; - return !!item && 'frameId' in item; - }; + const isStoppedWithFrame = () => this.getActiveFrameId() !== undefined; if (isStoppedWithFrame()) { return 'stopped'; @@ -888,7 +917,7 @@ export class DebuggingExecutor implements IDebuggingExecutor { // Only resolve when we have a stack frame. A bare // DebugThread can fire while the program is still // running, before the adapter publishes frame info. - if (stackItem && 'frameId' in stackItem) { + if (isStoppedWithFrame()) { settle('stopped'); } }) diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index bcb41ce..f91dc32 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -49,9 +49,15 @@ function describeLocation(state: DebugState): string { if (!state.sessionActive) { return ''; } - if (!state.hasLocationInfo()) { + if (!state.isPaused()) { return ''; } + if (!state.hasValidContext()) { + return ''; + } + if (!state.hasLocationInfo()) { + return ''; + } return `${state.fileName}:${state.currentLine}`; } @@ -214,7 +220,7 @@ export class DebuggingHandler implements IDebuggingHandler { private async navigate( operation: string, run: () => Promise, - settleOnResume = false + completion: 'state-change' | 'resume' | 'pause' = 'state-change' ): Promise { try { if (!(await this.executor.hasActiveSession())) { @@ -225,13 +231,17 @@ export class DebuggingHandler implements IDebuggingHandler { const beforeState = await this.executor.getCurrentDebugState(this.numNextLines); logger.info(`${operation}: from ${describeLocation(beforeState)}`); + // Pausing an already-stopped target produces no new stop or location. + if (completion === 'pause' && beforeState.isPaused()) { + return beforeState.toString(); + } + const startedAt = Date.now(); await run(); - // Wait for the debugger to leave its current stop. For a step that - // means the next frame; for a continue, "running again" is itself - // the terminal state (see waitForStateChange). - const afterState = await this.waitForStateChange(beforeState, settleOnResume); + const afterState = completion === 'pause' + ? await this.waitForPause(this.timeoutInSeconds * 1000) + : await this.waitForStateChange(beforeState, completion === 'resume'); const elapsedMs = Date.now() - startedAt; logger.info( @@ -271,7 +281,7 @@ export class DebuggingHandler implements IDebuggingHandler { * Continue execution */ public async handleContinue(): Promise { - return this.navigate('continue', () => this.executor.continue(), true); + return this.navigate('continue', () => this.executor.continue(), 'resume'); } /** @@ -304,11 +314,11 @@ 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.isPaused()) { state = await this.waitForPause(waitSeconds * 1000); } - const paused = state.sessionActive && state.hasLocationInfo(); + const paused = state.isPaused(); logger.info( `debug status: ${paused ? 'paused' : 'running'} at ${describeLocation(state)} ` + `after ${Date.now() - startedAt}ms (waited up to ${waitSeconds}s)` @@ -353,7 +363,7 @@ export class DebuggingHandler implements IDebuggingHandler { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const state = await this.executor.getCurrentDebugState(this.numNextLines); - if (!state.sessionActive || state.hasLocationInfo()) { + if (!state.sessionActive || state.isPaused()) { return state; } await new Promise(resolve => setTimeout(resolve, Math.min(100, deadline - Date.now()))); @@ -367,7 +377,7 @@ export class DebuggingHandler implements IDebuggingHandler { * (e.g. a busy loop or an embedded/bare-metal target that is running freely). */ public async handlePause(): Promise { - return this.navigate('pause', () => this.executor.pause()); + return this.navigate('pause', () => this.executor.pause(), 'pause'); } /** @@ -984,11 +994,9 @@ export class DebuggingHandler implements IDebuggingHandler { * completes in tens of milliseconds. There is no early-wakeup — a state * change 10ms into the sleep is ignored for the rest of the second. * - * This version subscribes to the same events the start path already uses - * (`onDidChangeActiveStackItem` for a new stopped frame, plus session - * termination) so it reacts the instant the step lands. A fast-path check - * covers the case where the step already completed before we got here, and - * a timeout bounds the no-event/never-stops case. + * Short bounded polls work with both VS Code and standalone executors. An + * immediate check covers steps that already completed; the timeout bounds + * operations that never reach another stop. * * `settleOnResume` (continue only) additionally treats "running again, no * stack frame" as a terminal state. `hasStateChanged` deliberately reports @@ -1003,7 +1011,7 @@ export class DebuggingHandler implements IDebuggingHandler { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const currentState = await this.executor.getCurrentDebugState(this.numNextLines); - const resumed = settleOnResume && currentState.sessionActive && !currentState.hasLocationInfo(); + const resumed = settleOnResume && currentState.sessionActive && !currentState.isPaused(); if (this.hasStateChanged(beforeState, currentState) || !currentState.sessionActive || resumed) { return currentState; } @@ -1026,7 +1034,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.isPaused() && !afterState.isPaused() && afterState.sessionActive) { return false; } @@ -1040,10 +1048,14 @@ export class DebuggingHandler implements IDebuggingHandler { 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(); + if (beforeState.isPaused() !== afterState.isPaused() || + beforeState.hasValidContext() !== afterState.hasValidContext()) { + return true; + } + + // Frame/thread changes remain meaningful without readable source. + if (beforeState.threadId !== afterState.threadId) { + return true; } // Compare file paths - if we moved to a different file, that's a change diff --git a/src/extension.ts b/src/extension.ts index 3c7ea6a..468b3dd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,6 +9,7 @@ import { ControlServer } from './controlServer'; import { RoutingDebuggingHandler } from './routingDebuggingHandler'; import { WorkspaceRegistry } from './utils/workspaceRegistry'; import { AgentConfigurationManager } from './utils/agentConfigurationManager'; +import { DebugSessionTracker } from './utils/debugSessionTracker'; import { logger, LogLevel } from './utils/logger'; let mcpServer: DebugMCPServer | null = null; @@ -31,6 +32,9 @@ export async function activate(context: vscode.ExtensionContext) { logger.logSystemInfo(`VS Code ${vscode.version}`); logger.logEnvironment(); + const debugSessionTracker = new DebugSessionTracker(); + context.subscriptions.push(debugSessionTracker); + const config = vscode.workspace.getConfiguration('debugmcp'); const timeoutInSeconds = config.get('timeoutInSeconds', 180); const serverPort = config.get('serverPort', 3001); @@ -68,7 +72,7 @@ export async function activate(context: vscode.ExtensionContext) { // advertises its workspace folders. The window that wins the public port // becomes the router and proxies each session to the control server of // the window owning the requested workspace. - const executor = new DebuggingExecutor(); + const executor = new DebuggingExecutor(debugSessionTracker); const configManager = new ConfigurationManager(); const localHandler = new DebuggingHandler(executor, configManager, timeoutInSeconds); const controlToken = randomUUID(); diff --git a/src/test/cliExecutionState.test.ts b/src/test/cliExecutionState.test.ts new file mode 100644 index 0000000..5bf72d7 --- /dev/null +++ b/src/test/cliExecutionState.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert/strict'; +import { CliDebuggingExecutor } from '../cli/cliDebuggingExecutor'; + +suite('CLI explicit execution snapshots (#157)', () => { + test('reports a stopped session with no selected thread as paused', async () => { + const executor = new CliDebuggingExecutor(); + executor['state'] = 'stopped'; + const state = await executor.getCurrentDebugState(); + assert.equal(state.isPaused(), true); + assert.equal(state.frameId, null); + assert.equal(state.threadId, null); + }); + + test('an empty stack preserves paused status and clears a previously cached frame', async () => { + const executor = new CliDebuggingExecutor(); + executor['state'] = 'stopped'; + executor['threadId'] = 0; + executor['frameId'] = 9; + Object.defineProperty(executor, 'client', { + value: { + request: async (command: string, args: { threadId: number }) => { + assert.equal(command, 'stackTrace'); + assert.equal(args.threadId, 0); + return { stackFrames: [], totalFrames: 0 }; + } + } + }); + const state = await executor.getCurrentDebugState(); + assert.equal(state.paused, true); + assert.equal(state.frameId, null); + assert.equal(executor.getActiveFrameId(), undefined); + }); + + test('running state never supplies a stale stopped frame', async () => { + const executor = new CliDebuggingExecutor(); + executor['state'] = 'running'; + executor['frameId'] = 0; + const state = await executor.getCurrentDebugState(); + assert.equal(state.sessionActive, true); + assert.equal(state.paused, false); + assert.equal(state.frameId, null); + assert.equal(executor.getActiveFrameId(), undefined); + }); + + for (const transition of ['continued', 'terminated', 'restopped']) { + test(`${transition} during stack lookup discards stale response frames`, async () => { + const executor = new CliDebuggingExecutor(); + executor['state'] = 'stopped'; + executor['threadId'] = 0; + Object.defineProperty(executor, 'client', { + value: { + request: async () => { + executor['state'] = transition === 'terminated' ? 'terminated' : 'running'; + executor['emitState'](); + if (transition === 'restopped') { + executor['state'] = 'stopped'; + executor['emitState'](); + } + return { stackFrames: [{ id: 0, name: 'main' }] }; + } + } + }); + const state = await executor.getCurrentDebugState(); + assert.equal(state.sessionActive, transition !== 'terminated'); + assert.equal(state.paused, transition === 'restopped'); + assert.equal(state.frameId, null); + assert.equal(executor.getActiveFrameId(), undefined); + }); + } +}); diff --git a/src/test/debugSessionTracker.test.ts b/src/test/debugSessionTracker.test.ts new file mode 100644 index 0000000..59211a1 --- /dev/null +++ b/src/test/debugSessionTracker.test.ts @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft Corporation. + +import * as vscode from 'vscode'; +import * as assert from 'node:assert/strict'; +import { DebuggingExecutor } from '../debuggingExecutor'; +import { DebugSessionTracker } from '../utils/debugSessionTracker'; + +function createSession(id = 'issue-157'): vscode.DebugSession { + return { + id, + name: id, + type: 'cortex-debug', + configuration: { name: id, type: 'cortex-debug', request: 'launch' }, + workspaceFolder: undefined, + customRequest: async () => ({ stackFrames: [], totalFrames: 0 }), + getDebugProtocolBreakpoint: async () => undefined + }; +} + +class TrackerFixture implements vscode.Disposable { + public readonly terminated = new vscode.EventEmitter(); + public readonly tracker: DebugSessionTracker; + public factory?: vscode.DebugAdapterTrackerFactory; + public registrations = 0; + + constructor() { + this.tracker = new DebugSessionTracker({ + registerDebugAdapterTrackerFactory: (type, factory) => { + assert.equal(type, '*'); + this.factory = factory; + this.registrations++; + return new vscode.Disposable(() => { this.registrations--; }); + }, + onDidTerminateDebugSession: listener => { + this.registrations++; + const subscription = this.terminated.event(listener); + return new vscode.Disposable(() => { + this.registrations--; + subscription.dispose(); + }); + } + }); + } + + public async attach(session: vscode.DebugSession): Promise { + const adapter = await this.factory?.createDebugAdapterTracker(session); + assert.ok(adapter); + return adapter; + } + + public dispose(): void { + this.tracker.dispose(); + this.terminated.dispose(); + } +} + +function send(adapter: vscode.DebugAdapterTracker, event: string, body?: unknown): void { + adapter.onDidSendMessage?.({ type: 'event', event, body }); +} + +suite('DAP execution tracking (#157)', () => { + let fixture: TrackerFixture; + let session: vscode.DebugSession; + let adapter: vscode.DebugAdapterTracker; + + setup(async () => { + fixture = new TrackerFixture(); + session = createSession(); + adapter = await fixture.attach(session); + }); + teardown(() => fixture.dispose()); + + test('unobserved and pre-existing sessions remain unknown', () => { + assert.equal(fixture.tracker.getPausedState(session.id), undefined); + assert.equal(fixture.tracker.getPausedState('pre-existing', 0), undefined); + }); + + test('a stopped thread with ID zero is recorded without requesting frames', () => { + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id, 0), true); + assert.equal(fixture.tracker.getPausedState(session.id), true); + assert.equal(fixture.tracker.getPausedState(session.id, 1), undefined); + }); + + test('a stop without a thread remains observable', () => { + send(adapter, 'continued', { threadId: 0 }); + send(adapter, 'stopped', { reason: 'pause' }); + assert.equal(fixture.tracker.getPausedState(session.id), true); + assert.equal(fixture.tracker.getPausedState(session.id, 0), true); + }); + + test('partial thread transitions do not resume other stopped threads', () => { + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + send(adapter, 'stopped', { reason: 'pause', threadId: 1, allThreadsStopped: false }); + send(adapter, 'continued', { threadId: 0, allThreadsContinued: false }); + assert.equal(fixture.tracker.getPausedState(session.id, 0), false); + assert.equal(fixture.tracker.getPausedState(session.id, 1), true); + assert.equal(fixture.tracker.getPausedState(session.id), true); + send(adapter, 'continued', { threadId: 1, allThreadsContinued: false }); + assert.equal(fixture.tracker.getPausedState(session.id), false); + }); + + test('partial continue overrides an all-thread stop for only that thread', () => { + send(adapter, 'stopped', { reason: 'pause', allThreadsStopped: true }); + send(adapter, 'continued', { threadId: 0, allThreadsContinued: false }); + assert.equal(fixture.tracker.getPausedState(session.id, 0), false); + assert.equal(fixture.tracker.getPausedState(session.id, 1), true); + assert.equal(fixture.tracker.getPausedState(session.id), true); + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id, 0), true); + }); + + for (const allThreadsContinued of [undefined, true]) { + test(`continued defaults clear every stopped thread (${allThreadsContinued})`, () => { + send(adapter, 'stopped', { reason: 'pause', allThreadsStopped: true }); + send(adapter, 'continued', { threadId: 0, allThreadsContinued }); + assert.equal(fixture.tracker.getPausedState(session.id, 0), false); + assert.equal(fixture.tracker.getPausedState(session.id, 1), false); + send(adapter, 'stopped', { reason: 'pause', threadId: 1 }); + assert.equal(fixture.tracker.getPausedState(session.id, 0), false); + assert.equal(fixture.tracker.getPausedState(session.id, 1), true); + }); + } + + test('execution state never leaks across sessions', async () => { + const other = createSession('other'); + const otherAdapter = await fixture.attach(other); + send(adapter, 'stopped', { reason: 'pause', allThreadsStopped: true }); + send(otherAdapter, 'continued', { threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id), true); + assert.equal(fixture.tracker.getPausedState(other.id), false); + fixture.terminated.fire(session); + assert.equal(fixture.tracker.getPausedState(session.id), undefined); + assert.equal(fixture.tracker.getPausedState(other.id), false); + }); + + for (const end of ['exited', 'terminated', 'adapter stop', 'adapter exit', 'session termination']) { + test(`${end} clears records and ignores late adapter events`, () => { + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + if (end === 'adapter stop') { + adapter.onWillStopSession?.(); + } else if (end === 'adapter exit') { + adapter.onExit?.(0, undefined); + } else if (end === 'session termination') { + fixture.terminated.fire(session); + } else { + send(adapter, end, {}); + } + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id), undefined); + assert.equal(fixture.tracker.getRevision(session.id), undefined); + assert.equal(fixture.tracker.hasSessionEnded(session), true); + }); + } + + test('fresh adapter registration does not inherit stopped state or old callbacks', async () => { + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + const replacement = await fixture.attach(session); + adapter.onWillStopSession?.(); + send(adapter, 'continued', { threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id), undefined); + send(replacement, 'stopped', { reason: 'pause', threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id), true); + }); + + test('ignores malformed messages and custom events', () => { + for (const message of [ + null, [], 'stopped', { type: 'response', event: 'stopped', body: {} }, + { type: 'event', event: 'custom-stop', body: {} }, + { type: 'event', event: 'stopped', body: null }, + { type: 'event', event: 'stopped', body: [] }, + { type: 'event', event: 'stopped', body: { threadId: '0' } }, + { type: 'event', event: 'stopped', body: { threadId: -1 } }, + { type: 'event', event: 'stopped', body: { allThreadsStopped: 'true' } }, + { type: 'event', event: 'continued', body: { allThreadsContinued: false } } + ]) { + adapter.onDidSendMessage?.(message); + } + assert.equal(fixture.tracker.getPausedState(session.id), undefined); + }); + + test('disposal unregisters listeners and clears all execution records', () => { + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + fixture.tracker.dispose(); + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + assert.equal(fixture.registrations, 0); + assert.equal(fixture.tracker.getPausedState(session.id), undefined); + assert.equal(fixture.factory?.createDebugAdapterTracker(session), undefined); + }); +}); + +suite('VS Code observed execution snapshots (#157)', () => { + let fixture: TrackerFixture; + let session: vscode.DebugSession; + let adapter: vscode.DebugAdapterTracker; + let executor: DebuggingExecutor; + let activeSession: vscode.DebugSession | undefined; + let item: vscode.DebugStackFrame | vscode.DebugThread | undefined; + let sessionDescriptor: PropertyDescriptor; + let itemDescriptor: PropertyDescriptor; + let requests: number; + let duringStack: (() => void) | undefined; + let sourcePath: string | undefined; + + setup(async () => { + fixture = new TrackerFixture(); + session = createSession(); + requests = 0; + duringStack = undefined; + sourcePath = undefined; + session.customRequest = async command => { + assert.equal(command, 'stackTrace'); + requests++; + duringStack?.(); + return sourcePath + ? { stackFrames: [{ id: 0, name: 'main', line: 1, source: { path: sourcePath } }] } + : { stackFrames: [], totalFrames: 0 }; + }; + adapter = await fixture.attach(session); + executor = new DebuggingExecutor(fixture.tracker); + activeSession = session; + item = undefined; + sessionDescriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeDebugSession')!; + itemDescriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem')!; + Object.defineProperty(vscode.debug, 'activeDebugSession', { configurable: true, get: () => activeSession }); + Object.defineProperty(vscode.debug, 'activeStackItem', { configurable: true, get: () => item }); + }); + + teardown(() => { + Object.defineProperty(vscode.debug, 'activeDebugSession', sessionDescriptor); + Object.defineProperty(vscode.debug, 'activeStackItem', itemDescriptor); + fixture.dispose(); + }); + + test('an observed frameless stop is paused without issuing stack requests', async () => { + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + const state = await executor.getCurrentDebugState(); + assert.equal(state.paused, true); + assert.equal(state.isPaused(), true); + assert.equal(state.frameId, null); + assert.equal(state.threadId, null); + assert.equal(requests, 0); + }); + + test('a selected thread is not paused until a stopped event is observed', async () => { + item = { session, threadId: 0 }; + assert.equal((await executor.getCurrentDebugState()).isPaused(), false); + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + assert.equal((await executor.getCurrentDebugState()).isPaused(), true); + assert.equal(requests, 0); + }); + + test('empty stack responses preserve an observed stop and real zero-valued context', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + const state = await executor.getCurrentDebugState(); + assert.equal(state.isPaused(), true); + assert.equal(state.frameId, 0); + assert.equal(state.threadId, 0); + assert.deepEqual(state.stackTrace, []); + }); + + test('continued state suppresses stale UI frames and their DAP requests', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'continued', { threadId: 0 }); + const state = await executor.getCurrentDebugState(); + assert.equal(state.paused, false); + assert.equal(state.hasValidContext(), false); + assert.equal(executor.getActiveFrameId(), undefined); + assert.equal(requests, 0); + }); + + test('clearing a frame does not erase an observed stopped state', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + duringStack = () => { item = undefined; }; + const state = await executor.getCurrentDebugState(); + assert.equal(state.isPaused(), true); + assert.equal(state.hasValidContext(), false); + }); + + test('a continued event during stack lookup overrides an unchanged UI frame', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + duringStack = () => send(adapter, 'continued', { threadId: 0 }); + const state = await executor.getCurrentDebugState(); + assert.equal(state.paused, false); + assert.equal(state.frameId, null); + }); + + test('a resume and new stop discard old frame data even when IDs are reused', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + duringStack = () => { + send(adapter, 'continued', { threadId: 0 }); + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + }; + const state = await executor.getCurrentDebugState(); + assert.equal(state.isPaused(), true); + assert.equal(state.frameId, null); + }); + + test('termination during lookup suppresses a lagging active UI session', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + duringStack = () => send(adapter, 'terminated'); + const state = await executor.getCurrentDebugState(); + assert.equal(state.sessionActive, false); + assert.equal(state.isPaused(), false); + assert.equal(state.frameId, null); + assert.equal((await executor.getCurrentDebugState()).sessionActive, false); + }); + + test('session changes cannot carry old frame data into another session', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + const other = createSession('other'); + const otherAdapter = await fixture.attach(other); + send(otherAdapter, 'continued', { threadId: 0 }); + duringStack = () => { activeSession = other; }; + const state = await executor.getCurrentDebugState(); + assert.equal(state.configurationName, 'other'); + assert.equal(state.paused, false); + assert.equal(state.frameId, null); + }); + + test('a continue during asynchronous source lookup discards location and frame data', async () => { + item = { session, threadId: 0, frameId: 0 }; + sourcePath = __filename; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + const descriptor = Object.getOwnPropertyDescriptor(vscode.workspace, 'openTextDocument')!; + const open = vscode.workspace.openTextDocument; + Object.defineProperty(vscode.workspace, 'openTextDocument', { + configurable: true, + value: async () => { + send(adapter, 'continued', { threadId: 0 }); + return open(__filename); + } + }); + try { + const state = await executor.getCurrentDebugState(); + assert.equal(state.paused, false); + assert.equal(state.frameId, null); + assert.equal(state.fileFullPath, null); + } finally { + Object.defineProperty(vscode.workspace, 'openTextDocument', descriptor); + } + }); +}); diff --git a/src/test/debugState.test.ts b/src/test/debugState.test.ts new file mode 100644 index 0000000..7f59390 --- /dev/null +++ b/src/test/debugState.test.ts @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. + +import * as assert from 'node:assert/strict'; +import { DebugState } from '../debugState'; + +suite('Explicit debug execution state (#157)', () => { + test('defaults to unknown and inactive, including serialized paused status', () => { + const state = new DebugState(); + assert.equal(state.paused, null); + assert.equal(state.isPaused(), false); + assert.equal(JSON.parse(state.toString()).paused, false); + }); + + test('an explicit stop needs neither source nor a frame', () => { + const state = new DebugState(); + state.sessionActive = true; + state.paused = true; + assert.equal(state.isPaused(), true); + assert.equal(state.hasValidContext(), false); + assert.equal(state.hasLocationInfo(), false); + assert.equal(state.frameId, null); + assert.equal(JSON.parse(state.toString()).paused, true); + }); + + test('unknown state falls back to valid context, including zero IDs', () => { + const state = new DebugState(); + state.sessionActive = true; + state.threadId = 0; + assert.equal(state.isPaused(), false); + state.frameId = 0; + assert.equal(state.isPaused(), true); + assert.equal(state.hasValidContext(), true); + assert.equal(state.paused, null); + }); + + test('explicit running overrides context without conflating the two predicates', () => { + const state = new DebugState(); + state.sessionActive = true; + state.updateContext(0, 0); + state.paused = false; + assert.equal(state.hasValidContext(), true); + assert.equal(state.isPaused(), false); + assert.equal(JSON.parse(state.toString()).paused, false); + }); + + test('inactive sessions never report paused', () => { + const state = new DebugState(); + state.paused = true; + state.updateContext(0, 0); + assert.equal(state.isPaused(), false); + assert.equal(state.hasValidContext(), false); + }); + + for (const paused of [null, false, true]) { + test(`clone preserves ${paused} and reset restores unknown`, () => { + const state = new DebugState(); + state.sessionActive = true; + state.paused = paused; + state.updateContext(0, 0); + const clone = state.clone(); + assert.equal(clone.paused, paused); + assert.equal(clone.isPaused(), state.isPaused()); + clone.reset(); + assert.equal(clone.paused, null); + assert.equal(clone.frameId, null); + assert.equal(clone.threadId, null); + assert.equal(clone.isPaused(), false); + assert.equal(state.sessionActive, true); + assert.equal(state.paused, paused); + }); + } +}); diff --git a/src/test/debugStatus.test.ts b/src/test/debugStatus.test.ts new file mode 100644 index 0000000..4bb9386 --- /dev/null +++ b/src/test/debugStatus.test.ts @@ -0,0 +1,319 @@ +// Copyright (c) Microsoft Corporation. + +import * as vscode from 'vscode'; +import * as assert from 'node:assert/strict'; +import * as path from 'node:path'; +import { DebugState } from '../debugState'; +import { DebuggingExecutor } from '../debuggingExecutor'; +import { DebuggingHandler } from '../debuggingHandler'; +import { DebugConfigurationManager } from '../utils/debugConfigurationManager'; + +function runningState(): DebugState { + const state = new DebugState(); + state.sessionActive = true; + return state; +} + +function pausedState(frameId = 0, threadId = 1, withSource = false): DebugState { + const state = runningState(); + state.updateContext(frameId, threadId); + state.updateFrameName('main'); + if (withSource) { + state.updateLocation(__filename, path.basename(__filename), 1, '', []); + } + return state; +} + +class SnapshotExecutor extends DebuggingExecutor { + public reads = 0; + public pauseCalls = 0; + public pauseError?: Error; + + constructor(private readonly states: DebugState[]) { + super(); + } + + public override async hasActiveSession(): Promise { + return this.states[0].sessionActive; + } + + public override getActiveSession() { + return this.states[0].sessionActive + ? { id: 'session', name: 'test', type: 'cortex-debug' } + : undefined; + } + + public override async getCurrentDebugState(): Promise { + return this.states[Math.min(this.reads++, this.states.length - 1)].clone(); + } + + public override async pause(): Promise { + this.pauseCalls++; + if (this.pauseError) { + throw this.pauseError; + } + } + + public override async stepOver(): Promise {} + public override async continue(): Promise {} +} + +function handler(executor: DebuggingExecutor, timeout = 0.3): DebuggingHandler { + return new DebuggingHandler(executor, new DebugConfigurationManager(), timeout); +} + +suite('Debug status and idempotent pause (#157)', () => { + test('an observed stopped event is paused even with no frames', async () => { + const state = runningState(); + state.paused = true; + const executor = new SnapshotExecutor([state]); + const result = JSON.parse(await handler(executor).handleGetDebugStatus({ waitForPauseSeconds: 60 })); + assert.equal(result.status, 'paused'); + assert.equal(result.state.frameId, null); + assert.equal(executor.reads, 1); + }); + + test('an observed running state overrides stale frame context', async () => { + const state = pausedState(); + state.paused = false; + const result = JSON.parse(await handler(new SnapshotExecutor([state])).handleGetDebugStatus()); + assert.equal(result.status, 'running'); + assert.equal(result.paused, false); + }); + + test('pause skips dispatch for a stopped target with an empty stack', async () => { + const state = runningState(); + state.paused = true; + const executor = new SnapshotExecutor([state]); + const result = JSON.parse(await handler(executor).handlePause()); + assert.equal(result.frameId, null); + assert.equal(executor.pauseCalls, 0); + assert.equal(executor.reads, 1); + }); + + test('pause still dispatches after a continued event with a stale UI frame', async () => { + const state = pausedState(); + state.paused = false; + const stopped = runningState(); + stopped.paused = true; + const executor = new SnapshotExecutor([state, stopped]); + await handler(executor).handlePause(); + assert.equal(executor.pauseCalls, 1); + assert.equal(executor.reads, 2); + }); + + test('a pause wait settles on an observed stopped event without a frame', async () => { + const stopped = runningState(); + stopped.paused = true; + const executor = new SnapshotExecutor([runningState(), stopped]); + const result = JSON.parse(await handler(executor).handleGetDebugStatus({ waitForPauseSeconds: 60 })); + assert.equal(result.status, 'paused'); + assert.equal(executor.reads, 2); + }); + + test('a source-less frame with ID zero is paused and does not consume the requested wait', async () => { + const executor = new SnapshotExecutor([pausedState()]); + const result = JSON.parse(await handler(executor).handleGetDebugStatus({ waitForPauseSeconds: 60 })); + assert.equal(result.status, 'paused'); + assert.equal(result.paused, true); + assert.equal(result.state.frameId, 0); + assert.equal(result.state.fileName, null); + assert.equal(executor.reads, 1); + }); + + test('a status wait ends when a source-less frame arrives', async () => { + const executor = new SnapshotExecutor([runningState(), pausedState()]); + const result = JSON.parse(await handler(executor).handleGetDebugStatus({ waitForPauseSeconds: 60 })); + assert.equal(result.status, 'paused'); + assert.equal(executor.reads, 2); + }); + + test('a thread without a frame does not imply paused', async () => { + const state = runningState(); + state.threadId = 1; + const result = JSON.parse(await handler(new SnapshotExecutor([state])).handleGetDebugStatus()); + assert.equal(result.status, 'running'); + assert.equal(result.paused, false); + }); + + test('source information without an execution context does not imply paused', async () => { + const state = runningState(); + state.updateLocation(__filename, path.basename(__filename), 1, '', []); + const result = JSON.parse(await handler(new SnapshotExecutor([state])).handleGetDebugStatus()); + assert.equal(result.status, 'running'); + assert.equal(result.paused, false); + }); + + for (const withSource of [false, true]) { + test(`pause is a no-op when already paused (readable source: ${withSource})`, async () => { + const executor = new SnapshotExecutor([pausedState(0, 1, withSource)]); + const result = JSON.parse(await handler(executor).handlePause()); + assert.equal(result.frameId, 0); + assert.equal(executor.pauseCalls, 0); + assert.equal(executor.reads, 1); + }); + } + + test('pause interrupts a running session and waits for a source-less stop', async () => { + const executor = new SnapshotExecutor([runningState(), runningState(), pausedState()]); + const result = JSON.parse(await handler(executor).handlePause()); + assert.equal(result.frameId, 0); + assert.equal(executor.pauseCalls, 1); + assert.equal(executor.reads, 3); + }); + + test('pause returns when the session terminates while waiting', async () => { + const executor = new SnapshotExecutor([runningState(), new DebugState()]); + const result = JSON.parse(await handler(executor).handlePause()); + assert.equal(result.sessionActive, false); + assert.equal(executor.pauseCalls, 1); + }); + + test('pause without an active session is an explicit error', async () => { + const executor = new SnapshotExecutor([new DebugState()]); + await assert.rejects(handler(executor).handlePause(), /Debug session is not ready/); + assert.equal(executor.pauseCalls, 0); + }); + + test('pause dispatch errors propagate', async () => { + const executor = new SnapshotExecutor([runningState()]); + executor.pauseError = new Error('adapter rejected pause'); + await assert.rejects(handler(executor).handlePause(), /adapter rejected pause/); + }); + + test('pause waiting remains bounded when the adapter never stops', async () => { + const executor = new SnapshotExecutor([runningState()]); + const started = Date.now(); + const result = JSON.parse(await handler(executor).handlePause()); + assert.equal(result.frameId, null); + assert.equal(executor.pauseCalls, 1); + assert.ok(Date.now() - started >= 200); + assert.ok(Date.now() - started < 2000); + }); + + test('continue does not treat an unchanged source-less stop as resumed', async () => { + const executor = new SnapshotExecutor([pausedState()]); + const started = Date.now(); + await handler(executor).handleContinue(); + assert.ok(Date.now() - started >= 200); + }); + + test('continue completes when a source-less stopped session resumes', async () => { + const executor = new SnapshotExecutor([pausedState(), runningState()]); + const result = JSON.parse(await handler(executor).handleContinue()); + assert.equal(result.frameId, null); + assert.equal(executor.reads, 2); + }); + + for (const [name, next] of [ + ['frame', pausedState(1)], + ['thread', pausedState(0, 2)], + ['source availability', pausedState(0, 1, true)] + ] as const) { + test(`stepping detects ${name} changes from a source-less stop`, async () => { + const executor = new SnapshotExecutor([pausedState(), next]); + const result = JSON.parse(await handler(executor).handleStepOver()); + assert.equal(result.frameId, next.frameId); + assert.equal(result.threadId, next.threadId); + assert.equal(executor.reads, 2); + }); + } + + test('stepping waits through the transient running state before another source-less stop', async () => { + const executor = new SnapshotExecutor([pausedState(), runningState(), pausedState(1)]); + const result = JSON.parse(await handler(executor).handleStepOver()); + assert.equal(result.frameId, 1); + assert.equal(executor.reads, 3); + }); +}); + +suite('VS Code executor source-independent status (#157)', () => { + let sessionDescriptor: PropertyDescriptor; + let stackDescriptor: PropertyDescriptor; + let session: vscode.DebugSession; + let stackItem: vscode.DebugStackFrame | vscode.DebugThread | undefined; + let source: { path?: string; name?: string; sourceReference?: number } | undefined; + let rejectStackTrace: boolean; + let duringStackTrace: (() => void) | undefined; + let active: boolean; + + setup(() => { + sessionDescriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeDebugSession')!; + stackDescriptor = Object.getOwnPropertyDescriptor(vscode.debug, 'activeStackItem')!; + source = undefined; + rejectStackTrace = false; + duringStackTrace = undefined; + active = true; + session = { + id: 'issue-157', + name: 'Cortex test', + type: 'cortex-debug', + configuration: { type: 'cortex-debug', request: 'launch', name: 'Cortex test' }, + workspaceFolder: undefined, + customRequest: async (command: string) => { + assert.equal(command, 'stackTrace'); + duringStackTrace?.(); + if (rejectStackTrace) { + throw new Error('stack unavailable'); + } + return { stackFrames: [{ id: 0, name: 'main', line: 1, column: 1, source }] }; + }, + getDebugProtocolBreakpoint: async () => undefined + }; + stackItem = { frameId: 0, threadId: 1, session }; + Object.defineProperty(vscode.debug, 'activeDebugSession', { configurable: true, get: () => active ? session : undefined }); + Object.defineProperty(vscode.debug, 'activeStackItem', { configurable: true, get: () => stackItem }); + }); + + teardown(() => { + Object.defineProperty(vscode.debug, 'activeDebugSession', sessionDescriptor); + Object.defineProperty(vscode.debug, 'activeStackItem', stackDescriptor); + }); + + for (const scenario of ['missing source', 'sourceReference only', 'unreadable source path', 'stack request failed']) { + test(`${scenario} preserves paused status from the real executor`, async () => { + if (scenario === 'sourceReference only') { + source = { name: 'generated.c', sourceReference: 1 }; + } else if (scenario === 'unreadable source path') { + source = { path: path.join(__dirname, 'nonexistent-issue157', 'firmware.c') }; + } else if (scenario === 'stack request failed') { + rejectStackTrace = true; + } + const result = JSON.parse(await handler(new DebuggingExecutor()).handleGetDebugStatus()); + assert.equal(result.status, 'paused'); + assert.equal(result.state.frameId, 0); + assert.equal(result.state.threadId, 1); + assert.equal(result.state.fileName, null); + }); + } + + test('readable source still supplies the actual line and frame', async () => { + source = { path: __filename }; + const result = JSON.parse(await handler(new DebuggingExecutor()).handleGetDebugStatus()); + assert.equal(result.status, 'paused'); + assert.equal(result.state.currentLine, 1); + assert.equal(result.state.frameId, 0); + }); + + test('a selected thread is not mistaken for a stopped frame', async () => { + stackItem = { threadId: 1, session }; + const result = JSON.parse(await handler(new DebuggingExecutor()).handleGetDebugStatus()); + assert.equal(result.status, 'running'); + }); + + test('resuming during a stack request does not return a stale paused context', async () => { + duringStackTrace = () => { stackItem = { threadId: 1, session }; }; + rejectStackTrace = true; + const state = await new DebuggingExecutor().getCurrentDebugState(); + assert.equal(state.sessionActive, true); + assert.equal(state.hasValidContext(), false); + assert.equal(state.threadId, null); + }); + + test('termination during a stack request clears the stopped snapshot', async () => { + duringStackTrace = () => { active = false; stackItem = undefined; }; + const result = JSON.parse(await handler(new DebuggingExecutor()).handleGetDebugStatus()); + assert.equal(result.status, 'no-session'); + assert.equal(result.paused, false); + }); +}); diff --git a/src/utils/debugSessionTracker.ts b/src/utils/debugSessionTracker.ts new file mode 100644 index 0000000..9a5d4c9 --- /dev/null +++ b/src/utils/debugSessionTracker.ts @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. + +import * as vscode from 'vscode'; +import { logger } from './logger'; + +interface ISessionExecutionState { + defaultPaused?: boolean; + threads: Map; + unscopedStop: boolean; + revision: number; +} + +type DebugTrackerApi = Pick; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Observes standard DAP execution events without requesting or selecting frames. + * Unobserved sessions remain unknown so existing sessions can use the UI fallback. + */ +export class DebugSessionTracker implements vscode.Disposable { + private readonly sessions = new Map(); + private readonly endedSessions = new WeakSet(); + private readonly subscriptions: vscode.Disposable[]; + private revision = 0; + private disposed = false; + + constructor(api: DebugTrackerApi = vscode.debug) { + this.subscriptions = [ + api.registerDebugAdapterTrackerFactory('*', { + createDebugAdapterTracker: session => { + if (this.disposed) { + return undefined; + } + const state: ISessionExecutionState = { + threads: new Map(), + unscopedStop: false, + revision: ++this.revision + }; + this.sessions.set(session.id, state); + this.endedSessions.delete(session); + const end = () => { + if (this.sessions.get(session.id) === state) { + this.endSession(session); + } + }; + return { + onDidSendMessage: (message: unknown) => { + if (this.sessions.get(session.id) === state) { + this.observe(session, state, message); + } + }, + onWillStopSession: end, + onExit: end + }; + } + }), + api.onDidTerminateDebugSession(session => this.endSession(session)) + ]; + } + + public getPausedState(sessionId: string, threadId?: number): boolean | undefined { + const state = this.sessions.get(sessionId); + if (!state) { + return undefined; + } + if (threadId !== undefined) { + return state.threads.get(threadId) ?? (state.unscopedStop ? true : state.defaultPaused); + } + if (state.defaultPaused === true || state.unscopedStop || + [...state.threads.values()].some(paused => paused)) { + return true; + } + return state.defaultPaused ?? (state.threads.size > 0 ? false : undefined); + } + + /** Detect an execution transition during an asynchronous snapshot lookup. */ + public getRevision(sessionId: string): number | undefined { + return this.sessions.get(sessionId)?.revision; + } + + public hasSessionEnded(session: vscode.DebugSession): boolean { + return this.endedSessions.has(session); + } + + public dispose(): void { + this.disposed = true; + this.subscriptions.forEach(subscription => subscription.dispose()); + this.sessions.clear(); + } + + private endSession(session: vscode.DebugSession): void { + this.sessions.delete(session.id); + // Do not retain terminated sessions, but reject a lagging active UI session. + this.endedSessions.add(session); + } + + private observe(session: vscode.DebugSession, state: ISessionExecutionState, message: unknown): void { + if (!isRecord(message) || message.type !== 'event') { + return; + } + if (message.event === 'exited' || message.event === 'terminated') { + if (message.body === undefined || isRecord(message.body)) { + this.endSession(session); + } + return; + } + if (message.event !== 'stopped' && message.event !== 'continued') { + return; + } + const body = message.body; + const flag = message.event === 'stopped' ? 'allThreadsStopped' : 'allThreadsContinued'; + if (!isRecord(body) || + (body.threadId !== undefined && + (typeof body.threadId !== 'number' || !Number.isInteger(body.threadId) || body.threadId < 0)) || + (body[flag] !== undefined && typeof body[flag] !== 'boolean')) { + logger.warn(`Ignoring malformed DAP ${message.event} event.`); + return; + } + const threadId = typeof body.threadId === 'number' ? body.threadId : undefined; + if (message.event === 'continued' && body.allThreadsContinued === false && threadId === undefined) { + logger.warn('Ignoring thread-specific DAP continued event without a thread ID.'); + return; + } + state.revision = ++this.revision; + if (message.event === 'stopped') { + if (body.allThreadsStopped === true) { + state.defaultPaused = true; + state.threads.clear(); + state.unscopedStop = false; + } else if (threadId !== undefined) { + state.threads.set(threadId, true); + } else { + state.unscopedStop = true; + } + } else if (body.allThreadsContinued !== false) { + // DAP defaults an omitted allThreadsContinued to all threads running. + state.defaultPaused = false; + state.threads.clear(); + state.unscopedStop = false; + } else if (threadId !== undefined) { + state.threads.set(threadId, false); + } + } +} From 959ac10bb55dc4814c6aef8ab0f178863e375cc6 Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:32:42 +0300 Subject: [PATCH 2/2] fix: complete steps on fresh debugger stops Recognize a new stopped event independently of frame IDs, source locations, or stack availability in both VS Code and CLI execution snapshots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e8c76249-e4b9-47a4-adee-0ef91ec3f978 --- CHANGELOG.md | 1 + docs/architecture/debugState.md | 6 +- docs/architecture/debuggingExecutor.md | 7 ++ docs/architecture/debuggingHandler.md | 12 ++- src/cli/cliDebuggingExecutor.ts | 3 + src/debugState.ts | 4 + src/debuggingExecutor.ts | 3 + src/debuggingHandler.ts | 11 ++- src/test/cliDebuggingExecutor.test.ts | 15 ++++ src/test/cliExecutionState.test.ts | 9 +++ src/test/debugSessionTracker.test.ts | 74 ++++++++++++++++++ src/test/debugState.test.ts | 6 ++ src/test/debuggingHandler.test.ts | 101 +++++++++++++++++++++++++ src/utils/debugSessionTracker.ts | 27 +++++++ 14 files changed, 273 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6765b1..37881aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### Fixed - Track stopped/continued debugger events so `get_debug_status` recognizes paused targets even when source or stack frames are unavailable. `pause_execution` now returns immediately for an already-paused session instead of waiting for another step or the operation timeout (#157). +- Complete step operations on a fresh stopped event even when frame IDs and source locations are unchanged or the stack is empty, avoiding hangs until a manual step or timeout in VS Code and the standalone CLI (#157). ## [2.3.5] - 2026-09-09 diff --git a/docs/architecture/debugState.md b/docs/architecture/debugState.md index be34c0c..f8c3b70 100644 --- a/docs/architecture/debugState.md +++ b/docs/architecture/debugState.md @@ -21,6 +21,7 @@ Debugging operations are asynchronous - the debugger takes time to execute and u |----------|------|-------------| | `sessionActive` | `boolean` | Whether a debug session exists, running or stopped | | `paused` | `boolean \| null` | Observed execution state, or `null` when unobserved | +| `stopSequence` | `number \| null` | Internal executor-local stopped-event marker, independent of frames and source | | `fileFullPath` | `string \| null` | Full path to current file | | `fileName` | `string \| null` | Just the filename | | `currentLine` | `number \| null` | 1-based line number | @@ -54,7 +55,7 @@ Debugging operations are asynchronous - the debugger takes time to execute and u 1. Capture before state: beforeState = executor.getCurrentDebugState() 2. Execute debug command 3. Poll for changes: compare beforeState with currentState -4. State changed when: file, line, frame, or session status differs +4. Step completed when a fresh stop is observed or the session ends; unobserved sessions fall back to location/context comparison ``` ## Design Notes @@ -70,3 +71,6 @@ Debugging operations are asynchronous - the debugger takes time to execute and u - **Snapshot lifecycle**: Cloning preserves observed execution state; resetting returns it to unknown. JSON output includes the computed `isPaused()` boolean, so an inactive session is never serialized as paused. +- **Stop identity**: A new stopped event advances `stopSequence` even if the + adapter reuses the frame ID and location or returns no frames. This internal + comparison marker is cloned/reset with the snapshot but omitted from tool JSON. diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index 563ce88..8ae5076 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -143,6 +143,13 @@ status. Activation creates one tracker and owns its disposal; test-created executors can omit it. Session termination and adapter shutdown clear tracked execution state without retaining terminated session objects. +Snapshots also carry an internal stopped-event sequence. Unlike the revision used +to reject stale asynchronous reads, it advances only on a stop, so an unchanged +source location or an empty stack cannot hide a completed step. Selected-thread +markers are unaffected by other threads' partial transitions; all-thread and +unscoped stops remain observable without a selected frame. The CLI supplies the +same marker from its stopped events. These markers are not added to tool output. + After asynchronous stack/source lookups, execution transitions, a changed or cleared frame, or an ended session invalidate the old frame snapshot. The latest observed stopped/running state is retained even when frame information is diff --git a/docs/architecture/debuggingHandler.md b/docs/architecture/debuggingHandler.md index a89a479..fdcafed 100644 --- a/docs/architecture/debuggingHandler.md +++ b/docs/architecture/debuggingHandler.md @@ -45,10 +45,10 @@ Debugging is inherently asynchronous - when you step over a line, the debugger t After executing a debug command (step over, continue, etc.), the handler: 1. Captures "before" state 2. Executes the command via executor -3. Polls for state changes using exponential backoff +3. Polls for state changes at short bounded intervals 4. Returns the "after" state when a meaningful change is detected -### Exponential Backoff +### Bounded Polling State-change polling uses short bounded intervals so either executor can expose new stopped/running state without the handler depending on host-specific event @@ -57,7 +57,13 @@ native VS Code or DAP events. ### Meaningful State Changes -A state change is considered meaningful when any of these change: +A fresh observed stopped-event sequence completes a step, even at the same source +line with reused frame IDs, or with no source/stack at all. Resuming or refreshing +the UI alone does not complete a step. Session termination also completes the +wait. The VS Code and CLI executors supply the same internal snapshot marker. + +When neither snapshot has an observed stopped-event sequence, the compatibility +fallback considers changes to: - Session active status - Current file path - Current line number diff --git a/src/cli/cliDebuggingExecutor.ts b/src/cli/cliDebuggingExecutor.ts index 39a1e4e..3c38ee7 100644 --- a/src/cli/cliDebuggingExecutor.ts +++ b/src/cli/cliDebuggingExecutor.ts @@ -28,6 +28,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { private capabilities: Record = {}; private initialized = false; private stateRevision = 0; + private stopSequence = 0; public async startDebugging( workingDirectory: string, @@ -260,6 +261,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { const result = new DebugState(); result.sessionActive = this.state !== 'none' && this.state !== 'terminated'; result.paused = this.state === 'stopped'; + result.stopSequence = result.paused && this.stopSequence > 0 ? this.stopSequence : null; result.updateConfigurationName(this.session?.name ?? null); result.updateBreakpoints(this.breakpoints.map(item => { const suffix = item.condition ? ` [when: ${item.condition}]` : ''; @@ -369,6 +371,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor { private registerClientEvents(client: DapClient): void { client.on('stopped', body => { + this.stopSequence++; this.threadId = typeof body.threadId === 'number' ? body.threadId : this.threadId; this.frameId = undefined; this.state = 'stopped'; diff --git a/src/debugState.ts b/src/debugState.ts index fb6c7bb..34c819f 100644 --- a/src/debugState.ts +++ b/src/debugState.ts @@ -16,6 +16,7 @@ export interface StackFrame { export class DebugState { public sessionActive: boolean; public paused: boolean | null; + public stopSequence: number | null; public fileFullPath: string | null; public fileName: string | null; public currentLine: number | null; @@ -31,6 +32,7 @@ export class DebugState { constructor() { this.sessionActive = false; this.paused = null; + this.stopSequence = null; this.fileFullPath = null; this.fileName = null; this.currentLine = null; @@ -50,6 +52,7 @@ export class DebugState { public reset(): void { this.sessionActive = false; this.paused = null; + this.stopSequence = null; this.fileFullPath = null; this.fileName = null; this.currentLine = null; @@ -151,6 +154,7 @@ export class DebugState { const cloned = new DebugState(); cloned.sessionActive = this.sessionActive; cloned.paused = this.paused; + cloned.stopSequence = this.stopSequence; cloned.fileFullPath = this.fileFullPath; cloned.fileName = this.fileName; cloned.currentLine = this.currentLine; diff --git a/src/debuggingExecutor.ts b/src/debuggingExecutor.ts index acaa5e9..46e8de9 100644 --- a/src/debuggingExecutor.ts +++ b/src/debuggingExecutor.ts @@ -441,6 +441,9 @@ export class DebuggingExecutor implements IDebuggingExecutor { state.updateConfigurationName(sessionActive ? currentSession?.configuration.name ?? null : null); } state.paused = paused; + state.stopSequence = sessionActive && currentSession + ? this.sessionTracker?.getStopSequence(currentSession.id, currentItem?.threadId) ?? null + : null; } } catch (error) { logger.error('Unable to get debug state:', error); diff --git a/src/debuggingHandler.ts b/src/debuggingHandler.ts index f91dc32..870f4e3 100644 --- a/src/debuggingHandler.ts +++ b/src/debuggingHandler.ts @@ -1001,7 +1001,7 @@ export class DebuggingHandler implements IDebuggingHandler { * `settleOnResume` (continue only) additionally treats "running again, no * stack frame" as a terminal state. `hasStateChanged` deliberately reports * paused -> running as "no change" so that a step isn't settled by the - * transient frameless moment mid-step; for a continue, though, that state + * transient running moment mid-step; for a continue, though, that state * is the successful outcome, and a process that keeps running (a server, an * event loop) never produces the next frame the step path waits for. */ @@ -1047,7 +1047,14 @@ export class DebuggingHandler implements IDebuggingHandler { if (!afterState.sessionActive) { return true; } - + + // A completed step can reuse every frame/location field, or have no stack. + // Once stops are observed, UI-only changes must not settle a pending step. + if (beforeState.stopSequence !== null || afterState.stopSequence !== null) { + return afterState.isPaused() && afterState.stopSequence !== null && + beforeState.stopSequence !== afterState.stopSequence; + } + if (beforeState.isPaused() !== afterState.isPaused() || beforeState.hasValidContext() !== afterState.hasValidContext()) { return true; diff --git a/src/test/cliDebuggingExecutor.test.ts b/src/test/cliDebuggingExecutor.test.ts index 9184209..997cbbe 100644 --- a/src/test/cliDebuggingExecutor.test.ts +++ b/src/test/cliDebuggingExecutor.test.ts @@ -6,6 +6,8 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { CliDebuggingExecutor } from '../cli/cliDebuggingExecutor'; import { CliDebugConfiguration } from '../cli/cliConfigurationManager'; +import { DebuggingHandler } from '../debuggingHandler'; +import { IDebugConfigurationManager } from '../utils/debugConfigurationManager'; suite('CLI debugging executor', () => { let directory: string; @@ -62,6 +64,10 @@ function handle(request) { currentLine = 3; send({ type: 'event', event: 'stopped', body: { reason: 'step', threadId: 7 } }); break; + case 'stepIn': + respond(request); + send({ type: 'event', event: 'stopped', body: { reason: 'step', threadId: 7 } }); + break; case 'continue': respond(request); send({ type: 'event', event: 'continued', body: { threadId: 7 } }); @@ -123,6 +129,15 @@ process.stdin.on('data', chunk => { const steppedState = await executor.getCurrentDebugState(); assert.strictEqual(steppedState.currentLine, 3); assert.strictEqual(executor.getActiveFrameId(), 11); + assert.notStrictEqual(steppedState.stopSequence, state.stopSequence); + + const handler = new DebuggingHandler(executor, {} as IDebugConfigurationManager, 3); + for (const operation of ['handleStepOver', 'handleStepInto'] as const) { + const started = Date.now(); + const repeatedStep = await handler[operation](); + assert.strictEqual(repeatedStep, steppedState.toString()); + assert.ok(Date.now() - started < 2_000, 'unchanged CLI frames must not cause a second wait'); + } await executor.continue(); const continuedState = await executor.getCurrentDebugState(); assert.strictEqual(continuedState.currentLine, 4); diff --git a/src/test/cliExecutionState.test.ts b/src/test/cliExecutionState.test.ts index 5bf72d7..4047405 100644 --- a/src/test/cliExecutionState.test.ts +++ b/src/test/cliExecutionState.test.ts @@ -7,8 +7,10 @@ suite('CLI explicit execution snapshots (#157)', () => { test('reports a stopped session with no selected thread as paused', async () => { const executor = new CliDebuggingExecutor(); executor['state'] = 'stopped'; + executor['stopSequence'] = 1; const state = await executor.getCurrentDebugState(); assert.equal(state.isPaused(), true); + assert.equal(state.stopSequence, 1); assert.equal(state.frameId, null); assert.equal(state.threadId, null); }); @@ -16,6 +18,7 @@ suite('CLI explicit execution snapshots (#157)', () => { test('an empty stack preserves paused status and clears a previously cached frame', async () => { const executor = new CliDebuggingExecutor(); executor['state'] = 'stopped'; + executor['stopSequence'] = 2; executor['threadId'] = 0; executor['frameId'] = 9; Object.defineProperty(executor, 'client', { @@ -29,6 +32,7 @@ suite('CLI explicit execution snapshots (#157)', () => { }); const state = await executor.getCurrentDebugState(); assert.equal(state.paused, true); + assert.equal(state.stopSequence, 2); assert.equal(state.frameId, null); assert.equal(executor.getActiveFrameId(), undefined); }); @@ -36,10 +40,12 @@ suite('CLI explicit execution snapshots (#157)', () => { test('running state never supplies a stale stopped frame', async () => { const executor = new CliDebuggingExecutor(); executor['state'] = 'running'; + executor['stopSequence'] = 1; executor['frameId'] = 0; const state = await executor.getCurrentDebugState(); assert.equal(state.sessionActive, true); assert.equal(state.paused, false); + assert.equal(state.stopSequence, null); assert.equal(state.frameId, null); assert.equal(executor.getActiveFrameId(), undefined); }); @@ -48,6 +54,7 @@ suite('CLI explicit execution snapshots (#157)', () => { test(`${transition} during stack lookup discards stale response frames`, async () => { const executor = new CliDebuggingExecutor(); executor['state'] = 'stopped'; + executor['stopSequence'] = 1; executor['threadId'] = 0; Object.defineProperty(executor, 'client', { value: { @@ -56,6 +63,7 @@ suite('CLI explicit execution snapshots (#157)', () => { executor['emitState'](); if (transition === 'restopped') { executor['state'] = 'stopped'; + executor['stopSequence']++; executor['emitState'](); } return { stackFrames: [{ id: 0, name: 'main' }] }; @@ -66,6 +74,7 @@ suite('CLI explicit execution snapshots (#157)', () => { assert.equal(state.sessionActive, transition !== 'terminated'); assert.equal(state.paused, transition === 'restopped'); assert.equal(state.frameId, null); + assert.equal(state.stopSequence, transition === 'restopped' ? 2 : null); assert.equal(executor.getActiveFrameId(), undefined); }); } diff --git a/src/test/debugSessionTracker.test.ts b/src/test/debugSessionTracker.test.ts index 59211a1..bfd41b3 100644 --- a/src/test/debugSessionTracker.test.ts +++ b/src/test/debugSessionTracker.test.ts @@ -73,6 +73,52 @@ suite('DAP execution tracking (#157)', () => { test('unobserved and pre-existing sessions remain unknown', () => { assert.equal(fixture.tracker.getPausedState(session.id), undefined); assert.equal(fixture.tracker.getPausedState('pre-existing', 0), undefined); + assert.equal(fixture.tracker.getStopSequence(session.id), undefined); + assert.equal(fixture.tracker.getStopSequence('pre-existing', 0), undefined); + }); + + for (const continued of [false, true]) { + test(`fresh stops advance the selected-thread marker (continued=${continued})`, () => { + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 0 }); + const first = fixture.tracker.getStopSequence(session.id, 0); + assert.notEqual(first, undefined); + assert.equal(fixture.tracker.getStopSequence(session.id, 0), first); + if (continued) { + send(adapter, 'continued', { threadId: 0 }); + assert.equal(fixture.tracker.getStopSequence(session.id, 0), undefined); + } + send(adapter, 'stopped', { reason: 'step', threadId: 0 }); + const second = fixture.tracker.getStopSequence(session.id, 0); + assert.notEqual(second, undefined); + assert.notEqual(second, first); + assert.equal(fixture.tracker.getStopSequence(session.id), second); + }); + } + + test('other threads cannot advance the selected stopped thread marker', () => { + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 0 }); + const first = fixture.tracker.getStopSequence(session.id, 0); + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 1 }); + assert.equal(fixture.tracker.getStopSequence(session.id, 0), first); + assert.notEqual(fixture.tracker.getStopSequence(session.id, 1), first); + send(adapter, 'continued', { threadId: 1, allThreadsContinued: false }); + assert.equal(fixture.tracker.getStopSequence(session.id, 0), first); + assert.equal(fixture.tracker.getStopSequence(session.id, 1), undefined); + }); + + test('all-thread and unscoped stops advance markers without needing a frame', () => { + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 0 }); + send(adapter, 'stopped', { reason: 'pause', allThreadsStopped: true }); + const all = fixture.tracker.getStopSequence(session.id); + assert.notEqual(all, undefined); + assert.equal(fixture.tracker.getStopSequence(session.id, 0), all); + assert.equal(fixture.tracker.getStopSequence(session.id, 1), all); + send(adapter, 'stopped', { reason: 'step', threadId: 0 }); + const selected = fixture.tracker.getStopSequence(session.id, 0); + send(adapter, 'stopped', { reason: 'step' }); + const unscoped = fixture.tracker.getStopSequence(session.id); + assert.notEqual(unscoped, selected); + assert.equal(fixture.tracker.getStopSequence(session.id, 0), unscoped); }); test('a stopped thread with ID zero is recorded without requesting frames', () => { @@ -149,18 +195,22 @@ suite('DAP execution tracking (#157)', () => { send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); assert.equal(fixture.tracker.getPausedState(session.id), undefined); assert.equal(fixture.tracker.getRevision(session.id), undefined); + assert.equal(fixture.tracker.getStopSequence(session.id), undefined); assert.equal(fixture.tracker.hasSessionEnded(session), true); }); } test('fresh adapter registration does not inherit stopped state or old callbacks', async () => { send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + const previous = fixture.tracker.getStopSequence(session.id); const replacement = await fixture.attach(session); adapter.onWillStopSession?.(); send(adapter, 'continued', { threadId: 0 }); assert.equal(fixture.tracker.getPausedState(session.id), undefined); + assert.equal(fixture.tracker.getStopSequence(session.id), undefined); send(replacement, 'stopped', { reason: 'pause', threadId: 0 }); assert.equal(fixture.tracker.getPausedState(session.id), true); + assert.notEqual(fixture.tracker.getStopSequence(session.id), previous); }); test('ignores malformed messages and custom events', () => { @@ -177,6 +227,7 @@ suite('DAP execution tracking (#157)', () => { adapter.onDidSendMessage?.(message); } assert.equal(fixture.tracker.getPausedState(session.id), undefined); + assert.equal(fixture.tracker.getStopSequence(session.id), undefined); }); test('disposal unregisters listeners and clears all execution records', () => { @@ -239,6 +290,7 @@ suite('VS Code observed execution snapshots (#157)', () => { assert.equal(state.isPaused(), true); assert.equal(state.frameId, null); assert.equal(state.threadId, null); + assert.equal(state.stopSequence, fixture.tracker.getStopSequence(session.id)); assert.equal(requests, 0); }); @@ -291,6 +343,7 @@ suite('VS Code observed execution snapshots (#157)', () => { test('a resume and new stop discard old frame data even when IDs are reused', async () => { item = { session, threadId: 0, frameId: 0 }; send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + const previous = (await executor.getCurrentDebugState()).stopSequence; duringStack = () => { send(adapter, 'continued', { threadId: 0 }); send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); @@ -298,6 +351,27 @@ suite('VS Code observed execution snapshots (#157)', () => { const state = await executor.getCurrentDebugState(); assert.equal(state.isPaused(), true); assert.equal(state.frameId, null); + assert.equal(state.stopSequence, fixture.tracker.getStopSequence(session.id, 0)); + assert.notEqual(state.stopSequence, previous); + }); + + test('successive source-less snapshots distinguish reused frame IDs by their stops', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 0 }); + const before = await executor.getCurrentDebugState(); + send(adapter, 'stopped', { reason: 'step', threadId: 0 }); + const after = await executor.getCurrentDebugState(); + assert.equal(after.toString(), before.toString()); + assert.notEqual(after.stopSequence, before.stopSequence); + }); + + test('an unrelated thread stop cannot change the focused snapshot stop marker', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 0 }); + const before = await executor.getCurrentDebugState(); + send(adapter, 'stopped', { reason: 'breakpoint', threadId: 1 }); + const after = await executor.getCurrentDebugState(); + assert.equal(after.stopSequence, before.stopSequence); }); test('termination during lookup suppresses a lagging active UI session', async () => { diff --git a/src/test/debugState.test.ts b/src/test/debugState.test.ts index 7f59390..10e72cf 100644 --- a/src/test/debugState.test.ts +++ b/src/test/debugState.test.ts @@ -7,6 +7,7 @@ suite('Explicit debug execution state (#157)', () => { test('defaults to unknown and inactive, including serialized paused status', () => { const state = new DebugState(); assert.equal(state.paused, null); + assert.equal(state.stopSequence, null); assert.equal(state.isPaused(), false); assert.equal(JSON.parse(state.toString()).paused, false); }); @@ -56,17 +57,22 @@ suite('Explicit debug execution state (#157)', () => { const state = new DebugState(); state.sessionActive = true; state.paused = paused; + state.stopSequence = 7; state.updateContext(0, 0); const clone = state.clone(); assert.equal(clone.paused, paused); + assert.equal(clone.stopSequence, 7); + assert.equal('stopSequence' in JSON.parse(clone.toString()), false); assert.equal(clone.isPaused(), state.isPaused()); clone.reset(); assert.equal(clone.paused, null); + assert.equal(clone.stopSequence, null); assert.equal(clone.frameId, null); assert.equal(clone.threadId, null); assert.equal(clone.isPaused(), false); assert.equal(state.sessionActive, true); assert.equal(state.paused, paused); + assert.equal(state.stopSequence, 7); }); } }); diff --git a/src/test/debuggingHandler.test.ts b/src/test/debuggingHandler.test.ts index 5bc8f8c..075e0f3 100644 --- a/src/test/debuggingHandler.test.ts +++ b/src/test/debuggingHandler.test.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import { DebugState } from '../debugState'; import { DebuggingHandler } from '../debuggingHandler'; import { IDebuggingExecutor } from '../debuggingExecutor'; +import { IDebugConfigurationManager } from '../utils/debugConfigurationManager'; /** * Test suite for DebuggingHandler state change detection @@ -220,6 +221,106 @@ suite('DebuggingHandler waitForStateChange (event-driven)', () => { assert.strictEqual(disposeCalls, 1); }); + + for (const operation of ['handleStepOver', 'handleStepInto', 'handleStepOut'] as const) { + for (const context of ['source', 'source-less', 'empty']) { + test(`${operation} completes on a fresh stop with unchanged ${context} context`, async () => { + const before = context === 'source' ? lineState(10) : new DebugState(); + before.sessionActive = true; + before.paused = true; + before.stopSequence = 1; + if (context === 'source-less') { + before.updateContext(0, 0); + } + const after = before.clone(); + after.stopSequence = 2; + let reads = 0; + const executor = makeExecutor(call => { + reads++; + return call === 0 ? before : after; + }); + const handler = new DebuggingHandler(executor, {} as IDebugConfigurationManager, 0.3); + const result = await handler[operation](); + assert.equal(result, after.toString()); + assert.equal(reads, 2, 'a fresh stop must not wait for a different frame or timeout'); + }); + } + } + + test('waits through running state and UI-only changes until the next observed stop', async () => { + const before = lineState(10); + before.paused = true; + before.stopSequence = 1; + const changedLocation = before.clone(); + changedLocation.currentLine = 11; + const clearedContext = before.clone(); + clearedContext.frameId = null; + const running = before.clone(); + running.paused = false; + const after = before.clone(); + after.stopSequence = 2; + const states = [before, changedLocation, clearedContext, running, after]; + let reads = 0; + const executor = makeExecutor(call => { + reads++; + return states[Math.min(call, states.length - 1)]; + }); + const handler = new DebuggingHandler(executor, {} as IDebugConfigurationManager, 1); + assert.equal(await handler.handleStepOver(), after.toString()); + assert.equal(reads, states.length); + }); + + test('an unobserved initial frame can complete on its first tracked stop', async () => { + const before = lineState(10); + const after = before.clone(); + after.paused = true; + after.stopSequence = 1; + let reads = 0; + const executor = makeExecutor(call => { + reads++; + return call === 0 ? before : after; + }); + const handler = new DebuggingHandler(executor, {} as IDebugConfigurationManager, 0.3); + assert.equal(await handler.handleStepOver(), after.toString()); + assert.equal(reads, 2); + }); + + test('a tracked step still completes on termination without another stop', async () => { + const before = lineState(10); + before.paused = true; + before.stopSequence = 1; + const after = new DebugState(); + const executor = makeExecutor(call => call === 0 ? before : after); + const handler = new DebuggingHandler(executor, {} as IDebugConfigurationManager, 0.3); + assert.equal(await handler.handleStepOver(), after.toString()); + }); + + test('an unchanged observed stop still waits for the bounded timeout', async () => { + const state = lineState(10); + state.paused = true; + state.stopSequence = 1; + const handler = new DebuggingHandler(makeExecutor(() => state), {} as IDebugConfigurationManager, 0.15); + const started = Date.now(); + assert.equal(await handler.handleStepOver(), state.toString()); + assert.ok(Date.now() - started >= 100); + }); + + test('continue from an observed stop still returns on resume', async () => { + const before = lineState(10); + before.paused = true; + before.stopSequence = 1; + const running = new DebugState(); + running.sessionActive = true; + running.paused = false; + let reads = 0; + const executor = makeExecutor(call => { + reads++; + return call === 0 ? before : running; + }); + const handler = new DebuggingHandler(executor, {} as IDebugConfigurationManager, 0.3); + assert.equal(await handler.handleContinue(), running.toString()); + assert.equal(reads, 2); + }); }); /** diff --git a/src/utils/debugSessionTracker.ts b/src/utils/debugSessionTracker.ts index 9a5d4c9..5e41dae 100644 --- a/src/utils/debugSessionTracker.ts +++ b/src/utils/debugSessionTracker.ts @@ -8,6 +8,9 @@ interface ISessionExecutionState { threads: Map; unscopedStop: boolean; revision: number; + stopSequence?: number; + defaultStopSequence?: number; + threadStopSequences: Map; } type DebugTrackerApi = Pick