Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/architecture/debuggingExecutor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions src/test/debugSessionTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
104 changes: 103 additions & 1 deletion src/utils/debugSessionTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,20 @@ interface ISessionExecutionState {
stopSequence?: number;
defaultStopSequence?: number;
threadStopSequences: Map<number, number>;
pendingResumes: Map<number, IResumeRequest>;
}

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<typeof vscode.debug,
'registerDebugAdapterTrackerFactory' | 'onDidTerminateDebugSession'>;

Expand Down Expand Up @@ -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
};
Expand All @@ -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);
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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<string, unknown>): 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;
}
}
Loading