From bbeb37d97977027b7841b1658e516d54f538d2da Mon Sep 17 00:00:00 2001 From: ozzafar <48795672+ozzafar@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:25:00 +0200 Subject: [PATCH] Track successful DAP resume responses Correlate resume requests with successful responses so adapters need not emit continued events. Respect thread-specific resumes and preserve stops arriving before acknowledgement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a882e001-7e0e-4256-8a20-8eaec9c947f4 --- docs/architecture/debuggingExecutor.md | 9 +- src/test/debugSessionTracker.test.ts | 111 +++++++++++++++++++++++++ src/utils/debugSessionTracker.ts | 104 ++++++++++++++++++++++- 3 files changed, 222 insertions(+), 2 deletions(-) diff --git a/docs/architecture/debuggingExecutor.md b/docs/architecture/debuggingExecutor.md index cb4adc7..438d652 100644 --- a/docs/architecture/debuggingExecutor.md +++ b/docs/architecture/debuggingExecutor.md @@ -140,7 +140,14 @@ Unobserved sessions retain the frame-based fallback; a selected thread alone doe 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 +`stopped`, `continued`, `exited`, and `terminated` events and correlated successful +resume responses. Adapters need not emit `continued` for client-requested +execution. The tracker records outbound resume requests and clears paused state +only on their successful matching responses, respecting thread-specific resumes. +Failed or unrelated responses do not resume execution. Stops newer than the +request remain observable even if they arrive before its response. Pending +request correlation is bounded and is discarded with the session. +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 diff --git a/src/test/debugSessionTracker.test.ts b/src/test/debugSessionTracker.test.ts index bfd41b3..4631088 100644 --- a/src/test/debugSessionTracker.test.ts +++ b/src/test/debugSessionTracker.test.ts @@ -58,6 +58,105 @@ function send(adapter: vscode.DebugAdapterTracker, event: string, body?: unknown adapter.onDidSendMessage?.({ type: 'event', event, body }); } +function resumeRequest(adapter: vscode.DebugAdapterTracker, command: string, args: unknown = { threadId: 0 }): void { + adapter.onWillReceiveMessage?.({ type: 'request', seq: 42, command, arguments: args }); +} + +function resumeResponse(adapter: vscode.DebugAdapterTracker, command: string, body?: unknown, success = true): void { + adapter.onDidSendMessage?.({ type: 'response', request_seq: 42, command, success, body }); +} + +suite('DAP successful resume responses (#177)', () => { + let fixture: TrackerFixture; + let session: vscode.DebugSession; + let adapter: vscode.DebugAdapterTracker; + + setup(async () => { + fixture = new TrackerFixture(); + session = createSession(); + adapter = await fixture.attach(session); + send(adapter, 'stopped', { threadId: 0, allThreadsStopped: true }); + }); + teardown(() => fixture.dispose()); + + for (const command of ['continue', 'next', 'stepIn', 'stepOut', 'stepBack', 'reverseContinue', 'goto', 'restartFrame']) { + test(`${command} success resumes without a continued event`, () => { + resumeRequest(adapter, command, command === 'restartFrame' ? { frameId: 7 } : { threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id), true); + resumeResponse(adapter, command); + assert.equal(fixture.tracker.getPausedState(session.id), false); + assert.equal(fixture.tracker.getStopSequence(session.id), undefined); + send(adapter, 'stopped', { threadId: 0 }); + assert.equal(fixture.tracker.getPausedState(session.id), true); + }); + + test(`${command} failure preserves the stopped state`, () => { + resumeRequest(adapter, command); + resumeResponse(adapter, command, undefined, false); + assert.equal(fixture.tracker.getPausedState(session.id), true); + }); + } + + test('partial continue and single-thread steps preserve other stopped threads', () => { + resumeRequest(adapter, 'continue'); + resumeResponse(adapter, 'continue', { allThreadsContinued: false }); + assert.equal(fixture.tracker.getPausedState(session.id, 0), false); + assert.equal(fixture.tracker.getPausedState(session.id, 1), true); + send(adapter, 'stopped', { threadId: 0 }); + resumeRequest(adapter, 'next', { threadId: 0, singleThread: true }); + resumeResponse(adapter, 'next'); + assert.equal(fixture.tracker.getPausedState(session.id, 0), false); + assert.equal(fixture.tracker.getPausedState(session.id, 1), true); + }); + + test('a stop arriving before a successful response remains paused', () => { + resumeRequest(adapter, 'next'); + send(adapter, 'stopped', { threadId: 0 }); + const stop = fixture.tracker.getStopSequence(session.id, 0); + resumeResponse(adapter, 'next'); + assert.equal(fixture.tracker.getPausedState(session.id, 0), true); + assert.equal(fixture.tracker.getStopSequence(session.id, 0), stop); + }); + + test('an unrelated newer stop survives while the requested thread resumes', () => { + resumeRequest(adapter, 'continue'); + send(adapter, 'stopped', { threadId: 1 }); + resumeResponse(adapter, 'continue'); + assert.equal(fixture.tracker.getPausedState(session.id, 0), false); + assert.equal(fixture.tracker.getPausedState(session.id, 1), true); + }); + + for (const stop of [{ allThreadsStopped: true }, {}]) { + test(`newer all-thread/unscoped stops survive late responses (${JSON.stringify(stop)})`, () => { + resumeRequest(adapter, 'continue'); + send(adapter, 'stopped', stop); + resumeResponse(adapter, 'continue'); + assert.equal(fixture.tracker.getPausedState(session.id, 0), true); + }); + } + + test('explicit continued events and duplicate responses are harmless', () => { + resumeRequest(adapter, 'continue'); + send(adapter, 'continued', { threadId: 0 }); + resumeResponse(adapter, 'continue'); + assert.equal(fixture.tracker.getPausedState(session.id), false); + send(adapter, 'stopped', { threadId: 0 }); + resumeResponse(adapter, 'continue'); + assert.equal(fixture.tracker.getPausedState(session.id, 0), true); + }); + + test('uncorrelated, mismatched, malformed, and non-resuming responses do not resume', () => { + resumeResponse(adapter, 'continue'); + resumeRequest(adapter, 'continue'); + resumeResponse(adapter, 'next'); + resumeRequest(adapter, 'continue'); + resumeResponse(adapter, 'continue', { allThreadsContinued: 'yes' }); + resumeRequest(adapter, 'scopes'); + resumeResponse(adapter, 'scopes'); + assert.equal(fixture.tracker.getPausedState(session.id), true); + }); +}); + suite('DAP execution tracking (#157)', () => { let fixture: TrackerFixture; let session: vscode.DebugSession; @@ -322,6 +421,18 @@ suite('VS Code observed execution snapshots (#157)', () => { assert.equal(requests, 0); }); + test('successful resume responses suppress stale frames without continued events (#177)', async () => { + item = { session, threadId: 0, frameId: 0 }; + send(adapter, 'stopped', { reason: 'pause', threadId: 0 }); + resumeRequest(adapter, 'continue'); + resumeResponse(adapter, 'continue'); + const state = await executor.getCurrentDebugState(); + assert.equal(state.isPaused(), 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 }); diff --git a/src/utils/debugSessionTracker.ts b/src/utils/debugSessionTracker.ts index 5e41dae..4b99e74 100644 --- a/src/utils/debugSessionTracker.ts +++ b/src/utils/debugSessionTracker.ts @@ -11,8 +11,20 @@ interface ISessionExecutionState { stopSequence?: number; defaultStopSequence?: number; threadStopSequences: Map; + pendingResumes: Map; } +interface IResumeRequest { + command: string; + threadId?: number; + singleThread: boolean; + revision: number; +} + +const RESUME_COMMANDS = new Set([ + 'continue', 'next', 'stepIn', 'stepOut', 'stepBack', 'reverseContinue', 'goto', 'restartFrame' +]); + type DebugTrackerApi = Pick; @@ -41,6 +53,7 @@ export class DebugSessionTracker implements vscode.Disposable { const state: ISessionExecutionState = { threads: new Map(), threadStopSequences: new Map(), + pendingResumes: new Map(), unscopedStop: false, revision: ++this.revision }; @@ -52,6 +65,11 @@ export class DebugSessionTracker implements vscode.Disposable { } }; return { + onWillReceiveMessage: (message: unknown) => { + if (this.sessions.get(session.id) === state) { + this.observeRequest(state, message); + } + }, onDidSendMessage: (message: unknown) => { if (this.sessions.get(session.id) === state) { this.observe(session, state, message); @@ -119,7 +137,14 @@ export class DebugSessionTracker implements vscode.Disposable { } private observe(session: vscode.DebugSession, state: ISessionExecutionState, message: unknown): void { - if (!isRecord(message) || message.type !== 'event') { + if (!isRecord(message)) { + return; + } + if (message.type === 'response') { + this.observeResponse(state, message); + return; + } + if (message.type !== 'event') { return; } if (message.event === 'exited' || message.event === 'terminated') { @@ -172,4 +197,81 @@ export class DebugSessionTracker implements vscode.Disposable { state.threads.set(threadId, false); } } + + private observeRequest(state: ISessionExecutionState, message: unknown): void { + if (!isRecord(message) || message.type !== 'request' || typeof message.command !== 'string' || + !RESUME_COMMANDS.has(message.command)) { + return; + } + const args = message.arguments; + if (typeof message.seq !== 'number' || !Number.isSafeInteger(message.seq) || + !isRecord(args) || + (args.threadId !== undefined && + (typeof args.threadId !== 'number' || !Number.isInteger(args.threadId) || args.threadId < 0)) || + (args.singleThread !== undefined && typeof args.singleThread !== 'boolean')) { + logger.warn('Ignoring malformed DAP resume request.'); + return; + } + if (state.pendingResumes.size >= 256) { + state.pendingResumes.delete(state.pendingResumes.keys().next().value!); + logger.warn('Discarding oldest pending DAP resume request at tracking limit.'); + } + state.pendingResumes.set(message.seq, { + command: message.command, + threadId: typeof args.threadId === 'number' ? args.threadId : undefined, + singleThread: args.singleThread === true, + revision: state.revision + }); + } + + private observeResponse(state: ISessionExecutionState, message: Record): void { + if (typeof message.request_seq !== 'number') { + return; + } + const request = state.pendingResumes.get(message.request_seq); + if (!request) { + return; + } + state.pendingResumes.delete(message.request_seq); + if (message.success !== true || message.command !== request.command) { + return; + } + const body = message.body; + if (body !== undefined && !isRecord(body)) { + logger.warn('Ignoring malformed DAP resume response.'); + return; + } + if (request.command === 'continue' && body?.allThreadsContinued !== undefined && + typeof body.allThreadsContinued !== 'boolean') { + logger.warn('Ignoring malformed DAP continue response.'); + return; + } + const allThreads = request.command === 'continue' + ? body?.allThreadsContinued !== false + : !request.singleThread; + // A stop may arrive before the response. Never overwrite a newer stop. + if ((state.defaultStopSequence ?? 0) > request.revision) { + return; + } + if (allThreads) { + state.defaultPaused = false; + state.unscopedStop = false; + state.defaultStopSequence = undefined; + for (const threadId of state.threads.keys()) { + if ((state.threadStopSequences.get(threadId) ?? 0) <= request.revision) { + state.threads.delete(threadId); + state.threadStopSequences.delete(threadId); + } + } + } else if (request.threadId !== undefined) { + if ((state.threadStopSequences.get(request.threadId) ?? 0) > request.revision) { + return; + } + state.threads.set(request.threadId, false); + } else { + logger.warn('Ignoring thread-specific DAP resume response without a request thread ID.'); + return; + } + state.revision = ++this.revision; + } }