From c6a91e2faff466097d7bdc44d0415891835fdd28 Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Mon, 15 Jun 2026 14:48:25 +0200 Subject: [PATCH 1/8] Track unknown running state of new threads When a thread first arrives via a `thread-created` notification, we do not know yet whether it is running or not, that information only comes with a `running`/`stopped` notification shortly afterward. Previously, the "unknown" state was treated the same as "stopped". However, we need to distinguish between "stopped" and "unknown" because when the initial state of the first thread arrives and is stopped, we need to tell that to the client using a `stopped` DAP event (the initial state of a thread in DAP is running), even though the overall running state has not changed (it transitioned from not-running-because-there-are-no-threads to not-running-because-there-are-stopped-threads). The situation where this matters does not currently occur because stopped threads are later continued anyway, but it will starting from the following commit. --- src/gdb/GDBDebugSessionBase.ts | 43 ++++++++++++++++++++++++---------- src/gdb/common.ts | 4 ++-- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/gdb/GDBDebugSessionBase.ts b/src/gdb/GDBDebugSessionBase.ts index 36807eac..9503b2fe 100644 --- a/src/gdb/GDBDebugSessionBase.ts +++ b/src/gdb/GDBDebugSessionBase.ts @@ -154,7 +154,9 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { * (GDBTargetDebugSession) and never for type="gdb" (GDBDebugSession). */ protected isRemote = false; - // isRunning === true means there are no threads stopped. + // isRunning === true means there are threads and none are stopped. + // When uncertain because we have not received the status of the newest + // threads yet, this is the last certain value. protected isRunning = false; protected supportsRunInTerminalRequest = false; @@ -1725,7 +1727,14 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { name += ` (${thread.details})`; } - const running = thread.state === 'running'; + // 'thread-created' notifications don't include a thread state, it comes + // later via a 'stopped' or 'running' notification. + const running = + thread.state === 'running' + ? true + : thread.state === 'stopped' + ? false + : undefined; return new ThreadWithStatus(parseInt(thread.id, 10), name, running); } @@ -3148,15 +3157,19 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { return requestsToResolve.length > 0; } + private updateIsRunning() { + const newIsRunning = this.threads.some((t) => t.running === false) // have stopped + ? false + : this.threads.some((t) => t.running === undefined) // have unknown + ? undefined + : this.threads.some((t) => t.running === true); // have running + if (newIsRunning !== undefined) { + this.isRunning = newIsRunning; + } + // else leave this.isRunning at its previous known value + } + protected handleGDBAsync(resultClass: string, resultData: any) { - const updateIsRunning = () => { - this.isRunning = this.threads.length ? true : false; - for (const thread of this.threads) { - if (!thread.running) { - this.isRunning = false; - } - } - }; switch (resultClass) { case 'running': if (this.gdb.isNonStopMode()) { @@ -3172,13 +3185,14 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { thread.running = true; } } - updateIsRunning(); + this.updateIsRunning(); if (this.isInitialized) { this.handleGDBResume(resultData); } break; case 'stopped': { let suppressHandleGDBStopped = false; + let newThreadsConfirmed = false; if (this.gdb.isNonStopMode()) { const id = parseInt(resultData['thread-id'], 10); for (const thread of this.threads) { @@ -3202,6 +3216,9 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { } } else { for (const thread of this.threads) { + if (thread.running === undefined) { + newThreadsConfirmed = true; + } thread.running = false; thread.lastRunToken = undefined; } @@ -3227,10 +3244,11 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { } const wasRunning = this.isRunning; - updateIsRunning(); + this.updateIsRunning(); if ( !suppressHandleGDBStopped && (this.gdb.isNonStopMode() || + newThreadsConfirmed || (wasRunning && !this.isRunning)) ) { if (this.isInitialized) { @@ -3267,6 +3285,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { switch (notifyClass) { case 'thread-created': this.threads.push(this.convertThread(notifyData)); + this.updateIsRunning(); this.missingThreadNames = true; break; case 'thread-exited': { diff --git a/src/gdb/common.ts b/src/gdb/common.ts index c6d5d734..adcf8337 100644 --- a/src/gdb/common.ts +++ b/src/gdb/common.ts @@ -12,9 +12,9 @@ import { DebugProtocol } from '@vscode/debugprotocol'; export class ThreadWithStatus implements DebugProtocol.Thread { id: number; name: string; - running: boolean; + running?: boolean; lastRunToken: string | undefined; - constructor(id: number, name: string, running: boolean) { + constructor(id: number, name: string, running?: boolean) { this.id = id; this.name = name; this.running = running; From 1c1c91bf213b6057efc3b99d915793b04f59c7c5 Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Mon, 23 Feb 2026 15:15:48 +0100 Subject: [PATCH 2/8] Optionally keep stopped threads stopped on attaching Previously, when attaching to an inferior that already has some stopped threads, these threads were resumed by an indiscriminate continue command in the final `continueIfNeeded` call at the end of the configuration phase. This is often undesired and also inconsistent: continueIfNeeded is supposed to be a counterpart to pauseIfNeeded, and pausing was not needed in this case because there was a paused thread already, so there should not be a matching continue either. Fix this by inserting the appropriate `if (this.waitPausedNeeded)`. Because existing clients, and that in fact includes many of the tests, may expect the previous behavior, gate it behind an option in the launch/attach arguments: The new, optional property "run" can have values "always" or "preserve" (and potentially more in the future). "always" is the default and corresponds to the previous behavoir: always let all threads run. "preserve" is the new behavior: leave the running state of threads unchanged. Furthermore, `stopped` notifications from GDB for such threads that arrive early on and are suppressed from going to the client must not be simply discarded, but must be stored and delivered deferredly when the suppression ends, otherwise the client does not know about the stopped threads and displays all threads as running. `running` notifications can still be discarded because running is the default state of a thread, but one that comes after a `stopped` notification for the same thread can cancel the latter. This commit makes some tests in the "remote gdb-non-stop" scenario fail with "AssertionError [ERR_ASSERTION]: '0' == 'breakpoint'". That is not due to a problem with the code under test, but due to a problem with `DebugClient.hitBreakpoint()` from @vscode/debugadapter-testsupport. `hitBreakpoint` assumes that the very first `stopped` event comes from its breakpoint. That is not a reasonable assumption to make, and in fact is necessarily false in the situation that this commit seeks to enable, keeping stopped threads stopped. The only reason why it held so far is that we erroneously discarded early stop notifications before. However, the problematic behavior of `hitBreakpoint` will be sidestepped as a side effect of the following commit, therefore it is not fixed here. --- src/gdb/GDBDebugSessionBase.ts | 47 +++++++++++++++++++++++++++++++--- src/types/session.ts | 6 +++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/gdb/GDBDebugSessionBase.ts b/src/gdb/GDBDebugSessionBase.ts index 9503b2fe..f948c72b 100644 --- a/src/gdb/GDBDebugSessionBase.ts +++ b/src/gdb/GDBDebugSessionBase.ts @@ -42,6 +42,7 @@ import { ObjectVariableReference, MemoryRequestArguments, CDTDisassembleArguments, + RequestArgRun, } from '../types/session'; import { IGDBBackend, IGDBBackendFactory } from '../types/gdb'; import { getInstructions } from '../util/disassembly'; @@ -177,6 +178,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { protected updateThreadInfo: 'missing' | 'when-requested' | 'never' = 'missing'; + protected runAfterConfiguration: RequestArgRun = RequestArgRun.ALWAYS; /** * State variables for pauseIfNeeded/continueIfNeeded logic, mostly used for @@ -211,6 +213,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { // and configurationDone response) we are protected configuringState: ConfiguringState = ConfiguringState.INITIAL; protected isInitialized = false; + protected deferredStopEvents: any[] = []; /** * customResetCommands from launch.json @@ -350,6 +353,16 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { "Valid values are: 'missing', 'when-requested', or 'never'." ); } + if ( + args.run !== undefined && + args.run !== RequestArgRun.ALWAYS && + args.run !== RequestArgRun.PRESERVE + ) { + throw new Error( + `Invalid value for 'run': '${args.run}'. ` + + "Valid values are: 'always', 'preserve'." + ); + } } /** @@ -363,6 +376,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { this.steppingResponseTimeout = args.steppingResponseTimeout ?? DEFAULT_STEPPING_RESPONSE_TIMEOUT; this.updateThreadInfo = args.updateThreadInfo ?? 'missing'; + this.runAfterConfiguration = args.run ?? RequestArgRun.ALWAYS; } /** @@ -595,6 +609,10 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { } this.sendEvent(new InitializedEvent()); this.isInitialized = true; + for (const resultData of this.deferredStopEvents) { + this.handleGDBStopped(resultData); + } + this.deferredStopEvents.splice(0); } protected async attachRequest( @@ -766,10 +784,15 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { if (this.pauseCount === 0) { if (this.configuringState === ConfiguringState.FINISHING) { this.configuringState = ConfiguringState.DONE; - if (this.isAttach) { - await mi.sendExecContinue(this.gdb); - } else { - await mi.sendExecRun(this.gdb); + if ( + this.runAfterConfiguration == RequestArgRun.ALWAYS || + this.waitPausedNeeded + ) { + if (this.isAttach) { + await mi.sendExecContinue(this.gdb); + } else { + await mi.sendExecRun(this.gdb); + } } } else if (this.waitPausedNeeded) { if (this.gdb.isNonStopMode()) { @@ -3188,6 +3211,20 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { this.updateIsRunning(); if (this.isInitialized) { this.handleGDBResume(resultData); + } else { + // while we are deferring events, cancel previous stop events for the same thread + if (this.deferredStopEvents.length > 0) { + if (resultData['thread-id'] === 'all') { + this.deferredStopEvents.splice(0); + } else if (resultData['thread-id'] !== undefined) { + this.deferredStopEvents = + this.deferredStopEvents.filter( + (stopResultData) => + stopResultData['thread-id'] !== + resultData['thread-id'] + ); + } + } } break; case 'stopped': { @@ -3253,6 +3290,8 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { ) { if (this.isInitialized) { this.handleGDBStopped(resultData); + } else { + this.deferredStopEvents.push(resultData); } } break; diff --git a/src/types/session.ts b/src/types/session.ts index a106e744..caa1a916 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -10,6 +10,11 @@ import { Response } from '@vscode/debugadapter'; import { DebugProtocol } from '@vscode/debugprotocol'; +export const enum RequestArgRun { + ALWAYS = 'always', + PRESERVE = 'preserve', +} + export interface RequestArguments extends DebugProtocol.LaunchRequestArguments { gdb?: string; gdbArguments?: string[]; @@ -30,6 +35,7 @@ export interface RequestArguments extends DebugProtocol.LaunchRequestArguments { customResetCommands?: string[]; steppingResponseTimeout?: number; updateThreadInfo?: 'missing' | 'when-requested' | 'never'; + run?: RequestArgRun; } export interface LaunchRequestArguments extends RequestArguments { From de70e8d8c0e9341d301ffc179c9ca3016dbd184b Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Fri, 24 Apr 2026 15:15:32 +0200 Subject: [PATCH 3/8] Defer continued/stopped events to the end of the configuration phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When attaching, the initial `running` notifications from GDB about existing threads are suppressed from going to the client as `continued` events, these are unnecessary because running is the default state of a thread, and would result in a barrage of `threads` requests from the client. Previously, this suppression was upheld until the beginning of the configuration phase, marked by the `initialized` event. In some scenarios involving GDB’s `extended-remote` target, this is too early. Extend the suppression until the end of the configuration phase, shortly before the `configurationDone` response. This leaves the `isInitialized` variable unused, but since it is `protected` and may already be used by third-party subclasses, I am leaving it in. Moving the setting of configuringState = DONE after sendExecContinue is needed to fix the test failures from the previous commit, because it allows the `continued` event caused by this to cancel the problematic `stopped` event in deferredStopEvents. This does not disturb the working of the configuringState state machine. --- src/gdb/GDBDebugSessionBase.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/gdb/GDBDebugSessionBase.ts b/src/gdb/GDBDebugSessionBase.ts index f948c72b..094f216b 100644 --- a/src/gdb/GDBDebugSessionBase.ts +++ b/src/gdb/GDBDebugSessionBase.ts @@ -212,7 +212,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { // keeps track of where in the configuration phase (between initialize event // and configurationDone response) we are protected configuringState: ConfiguringState = ConfiguringState.INITIAL; - protected isInitialized = false; + protected isInitialized = false; // unused here but kept for compatibility protected deferredStopEvents: any[] = []; /** @@ -609,10 +609,6 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { } this.sendEvent(new InitializedEvent()); this.isInitialized = true; - for (const resultData of this.deferredStopEvents) { - this.handleGDBStopped(resultData); - } - this.deferredStopEvents.splice(0); } protected async attachRequest( @@ -778,12 +774,19 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { this.waitPaused = undefined; } + protected setConfiguringStateDone(): void { + this.configuringState = ConfiguringState.DONE; + for (const resultData of this.deferredStopEvents) { + this.handleGDBStopped(resultData); + } + this.deferredStopEvents.splice(0); + } + protected async continueIfNeeded(): Promise { if (this.pauseCount > 0) { this.pauseCount--; if (this.pauseCount === 0) { if (this.configuringState === ConfiguringState.FINISHING) { - this.configuringState = ConfiguringState.DONE; if ( this.runAfterConfiguration == RequestArgRun.ALWAYS || this.waitPausedNeeded @@ -794,6 +797,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { await mi.sendExecRun(this.gdb); } } + this.setConfiguringStateDone(); } else if (this.waitPausedNeeded) { if (this.gdb.isNonStopMode()) { await mi.sendExecContinue( @@ -1731,7 +1735,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { this.configuringState = ConfiguringState.FINISHING; await this.continueIfNeeded(); } else { - this.configuringState = ConfiguringState.DONE; + this.setConfiguringStateDone(); } this.sendResponse(response); } catch (err) { @@ -3209,7 +3213,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { } } this.updateIsRunning(); - if (this.isInitialized) { + if (this.configuringState == ConfiguringState.DONE) { this.handleGDBResume(resultData); } else { // while we are deferring events, cancel previous stop events for the same thread @@ -3288,7 +3292,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { newThreadsConfirmed || (wasRunning && !this.isRunning)) ) { - if (this.isInitialized) { + if (this.configuringState == ConfiguringState.DONE) { this.handleGDBStopped(resultData); } else { this.deferredStopEvents.push(resultData); From 44a9ebc7938e15c1b38783d0b084057ba3769d12 Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Fri, 5 Jun 2026 10:14:51 +0200 Subject: [PATCH 4/8] After launch with run=preserve, first send 'run' instead of 'continue' A 'launch' request with the new option "run": "preserve" leaves GDB in the state where it has not started the program yet. To start the program, GDB needs a 'run' command. So far, there has been no way for the user to explicitly send that command through DAP, other than typing it in the debug console, it was only sent automatically in the "run": "always" (default) case. Therefore, in the "preserve" case, let the first DAP 'continue' request send 'run' instead of 'continue' to GDB (the latter would not be accepted in this state anyway). --- src/gdb/GDBDebugSessionBase.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/gdb/GDBDebugSessionBase.ts b/src/gdb/GDBDebugSessionBase.ts index 094f216b..6ba95584 100644 --- a/src/gdb/GDBDebugSessionBase.ts +++ b/src/gdb/GDBDebugSessionBase.ts @@ -214,6 +214,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { protected configuringState: ConfiguringState = ConfiguringState.INITIAL; protected isInitialized = false; // unused here but kept for compatibility protected deferredStopEvents: any[] = []; + protected firstContinueIsRun = false; /** * customResetCommands from launch.json @@ -796,6 +797,11 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { } else { await mi.sendExecRun(this.gdb); } + } else if (!this.isAttach) { + // We would have sent a 'run' rather than a 'continue', + // but since the user didn't want us to, let them do it + // manually using a continue request. + this.firstContinueIsRun = true; } this.setConfiguringStateDone(); } else if (this.waitPausedNeeded) { @@ -2050,7 +2056,12 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { } try { - await mi.sendExecContinue(this.gdb, args.threadId); + if (this.firstContinueIsRun) { + this.firstContinueIsRun = false; + await mi.sendExecRun(this.gdb); + } else { + await mi.sendExecContinue(this.gdb, args.threadId); + } let isAllThreadsContinued; if (this.gdb.isNonStopMode()) { isAllThreadsContinued = args.threadId ? false : true; From 9e60469536e9ee0a40181775534643457c471f74 Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Thu, 4 Jun 2026 15:59:32 +0200 Subject: [PATCH 5/8] Accept GDB commands without a frame ID also in "gdb", not just "gdbtarget" debug type #414 allowed sending GDB commands by evaluating expressions starting with ">" even when there is no stack frame context (many commands don't need one, and for those that do, GDB can report that itself, no need for the adapter to preempt it). However, it only did that for GDBTargetDebugSession (remote, debug type "gdbtarget"), not for GDBDebugSessionBase (local, debug type "gdb"), for no apparent reason. In the latter case, such requests still fail with "Evaluation of expression without frameId is not supported". This is an annoyance and gets in the way of a test I am about to add, so change it as well and treat both cases the same. This makes the `alwaysAllowCliCommand` argument to `doEvaluateRequest` redundant (always `true`) inside this project, but because the method is protected and may already be used by third-party subclasses, I am not removing it. --- src/gdb/GDBDebugSessionBase.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gdb/GDBDebugSessionBase.ts b/src/gdb/GDBDebugSessionBase.ts index 6ba95584..6fa247ea 100644 --- a/src/gdb/GDBDebugSessionBase.ts +++ b/src/gdb/GDBDebugSessionBase.ts @@ -2502,7 +2502,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments ): Promise { - return this.doEvaluateRequest(response, args, false); + return this.doEvaluateRequest(response, args, true); } private extractExpressionFormat( @@ -2546,7 +2546,7 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { protected async doEvaluateRequest( response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments, - alwaysAllowCliCommand: boolean // if true, allows evaluation of expression without a frameId + alwaysAllowCliCommand: boolean // if true, allows evaluation of expression without a frameId (kept for compatibility) ): Promise { response.body = { result: 'Error: could not evaluate expression', From 384ae3b73633f20d32ac5d9e1b91ec2bad5837c3 Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Thu, 4 Jun 2026 15:59:32 +0200 Subject: [PATCH 6/8] Return result from evaluated MI command When sending a GDB MI command (one that starts with '-') by evaluating it as an expression prefixed by '>', return the MI response as the result of the evaluation, instead of discarding it and returning a rather useless '\r'. GDB CLI commands (those not starting with '-') still return '\r' as they do not have a return value (their output usually comes on stdout). The evaluation result must be a string, but the MI response has already been parsed into a JS object at that point, so serialize that as JSON. That is different from the original response string sent by GDB, but probably more convenient for programmatic consumers anyway. This is useful for getting information from GDB in tests (such as those in the following commit), but may also occasionally be useful when working interactively in the debug console. I do not expect existing callers to be disturbed by the change, as they have probably ignored the constant '\r' anyway. --- src/gdb/GDBDebugSessionBase.ts | 13 +++++++++---- src/integration-tests/evaluate.spec.ts | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/gdb/GDBDebugSessionBase.ts b/src/gdb/GDBDebugSessionBase.ts index 6fa247ea..64b64a24 100644 --- a/src/gdb/GDBDebugSessionBase.ts +++ b/src/gdb/GDBDebugSessionBase.ts @@ -2385,15 +2385,16 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { /** * Send command to backend. * @param expression Command to be executed + * @returns Promise for: - MI response as JS object if MI command, - `undefined` if CLI command */ protected async sendCommandToGdb( gdb: IGDBBackend, expression: string, frameRef: FrameReference | undefined - ): Promise { + ): Promise { if (expression.startsWith('-')) { // GDB/MI command - await gdb.sendCommand(expression); + return await gdb.sendCommand(expression); } else { // GDB CLI command await mi.sendInterpreterExecConsole(gdb, { @@ -2444,9 +2445,13 @@ export abstract class GDBDebugSessionBase extends LoggingDebugSession { frameRef: FrameReference | undefined ): Promise { const trimmedExpression = expression.trim(); - await this.sendCommandToGdb(this.gdb, trimmedExpression, frameRef); + const result = await this.sendCommandToGdb( + this.gdb, + trimmedExpression, + frameRef + ); response.body = { - result: '\r', + result: result ? JSON.stringify(result) : '\r', variablesReference: 0, }; await this.sendCommandToOtherGdbs( diff --git a/src/integration-tests/evaluate.spec.ts b/src/integration-tests/evaluate.spec.ts index 097d6ade..cb20324f 100644 --- a/src/integration-tests/evaluate.spec.ts +++ b/src/integration-tests/evaluate.spec.ts @@ -180,7 +180,7 @@ describe('evaluate request', function () { frameId: scope.frame.id, }); - expect(res2.body.result).eq('\r'); + expect(res2.body.result).matches(/^\{.*\}$/); }); it('should reject entering an invalid MI command', async function () { From 8dfb6ab1f17c8a0ff7fbeacc25e26d16752747c2 Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Thu, 4 Jun 2026 15:59:32 +0200 Subject: [PATCH 7/8] Add tests for "run": "preserve" --- src/integration-tests/attach.spec.ts | 68 +++++++++++++++++++ src/integration-tests/attachRemote.spec.ts | 67 +++++++++++++++++++ src/integration-tests/launch.spec.ts | 78 ++++++++++++++++++++++ 3 files changed, 213 insertions(+) diff --git a/src/integration-tests/attach.spec.ts b/src/integration-tests/attach.spec.ts index 2cc353bb..68a3eb2c 100644 --- a/src/integration-tests/attach.spec.ts +++ b/src/integration-tests/attach.spec.ts @@ -17,6 +17,7 @@ import { fillDefaults, gdbAsync, gdbNonStop, + gdbVersionAtLeast, isRemoteTest, standardBeforeEach, testProgramsDir, @@ -56,6 +57,73 @@ describe('attach', function () { expect(await dc.evaluate('argv[1]')).to.contain('running-from-spawn'); }); + function makeRunArgTest(runArg: string) { + return async function (this: Mocha.Context) { + if (isRemoteTest) { + // attachRemote.spec.ts is the test for when isRemoteTest + this.skip(); + } + const isAsync = + gdbAsync && + (os.platform() !== 'win32' || + (await gdbVersionAtLeast('13.0'))); + if (!isAsync && runArg === 'always') { + // in sync mode when all threads are running we can't ask '-thread-info' + this.skip(); + } + + const eventCounter = { stopped: 0, continued: 0 }; + dc.on('stopped', () => { + eventCounter.stopped++; + }); + dc.on('continued', () => { + eventCounter.continued++; + }); + + const attachArgs = fillDefaults(this.test, { + program: program, + processId: `${inferior.pid}`, + run: runArg, + } as AttachRequestArguments); + + await Promise.all([ + dc + .waitForEvent('initialized') + .then(() => dc.configurationDoneRequest()), + dc.initializeRequest().then(() => dc.attachRequest(attachArgs)), + ]); + + const threadInfo = JSON.parse( + ( + await dc.evaluateRequest({ + expression: '>-thread-info', + context: 'repl', + }) + ).body.result + ); + const threadStates = threadInfo.threads.map((t: any) => t.state); + if (runArg === 'always') { + expect(threadStates).to.contain('running'); + expect(threadStates).not.to.contain('stopped'); + } else if (runArg === 'preserve') { + expect(threadStates).to.contain('stopped'); + } else { + expect(runArg).to.be.oneOf(['always', 'preserve']); + } + + expect(eventCounter).to.deep.equal({ + stopped: runArg === 'always' ? 0 : 1, + continued: 0, + }); + }; + } + + it('can attach and continue stopped threads', makeRunArgTest('always')); + it( + 'can attach without continuing stopped threads', + makeRunArgTest('preserve') + ); + it('can attach and hit a breakpoint with no program specified', async function () { if (isRemoteTest) { // attachRemote.spec.ts is the test for when isRemoteTest diff --git a/src/integration-tests/attachRemote.spec.ts b/src/integration-tests/attachRemote.spec.ts index 0d26f76c..c38888bb 100644 --- a/src/integration-tests/attachRemote.spec.ts +++ b/src/integration-tests/attachRemote.spec.ts @@ -23,6 +23,7 @@ import { gdbAsync, fillDefaults, gdbNonStop, + isRemoteTest, } from './utils'; import { expect } from 'chai'; import { DebugProtocol } from '@vscode/debugprotocol'; @@ -81,6 +82,72 @@ describe('attach remote', function () { expect(await dc.evaluate('argv[1]')).to.contain('running-from-spawn'); }); + function makeRunArgTest(runArg: string) { + return async function (this: Mocha.Context) { + if (!isRemoteTest) { + // attach.spec.ts is the test for when !isRemoteTest + this.skip(); + } + if ((!gdbAsync || !gdbNonStop) && runArg === 'always') { + // in sync mode when all threads are running we can't ask '-thread-info' + this.skip(); + } + + const eventCounter = { stopped: 0, continued: 0 }; + dc.on('stopped', () => { + eventCounter.stopped++; + }); + dc.on('continued', () => { + eventCounter.continued++; + }); + + const attachArgs = fillDefaults(this.test, { + program: program, + target: { + type: 'remote', + parameters: [`localhost:${port}`], + } as TargetAttachArguments, + run: runArg, + } as TargetAttachRequestArguments); + + await Promise.all([ + dc + .waitForEvent('initialized') + .then(() => dc.configurationDoneRequest()), + dc.initializeRequest().then(() => dc.attachRequest(attachArgs)), + ]); + + const threadInfo = JSON.parse( + ( + await dc.evaluateRequest({ + expression: '>-thread-info', + context: 'repl', + }) + ).body.result + ); + const threadStates = threadInfo.threads.map((t: any) => t.state); + if (runArg === 'always') { + expect(threadStates).to.contain('running'); + expect(threadStates).not.to.contain('stopped'); + } else if (runArg === 'preserve') { + expect(threadStates).to.contain('stopped'); + } else { + expect(runArg).to.be.oneOf(['always', 'preserve']); + } + + expect(eventCounter).to.deep.equal({ + stopped: runArg === 'always' ? 0 : 1, + continued: 0, + }); + }; + } + + it('can attach and continue stopped threads', makeRunArgTest('always')); + it( + 'can attach without continuing stopped threads', + makeRunArgTest('preserve') + ); + it('can attach remote and hit a breakpoint without a program', async function () { if (os.platform() === 'win32') { // win32 host does support this use case diff --git a/src/integration-tests/launch.spec.ts b/src/integration-tests/launch.spec.ts index 9f90afa8..517d21c2 100644 --- a/src/integration-tests/launch.spec.ts +++ b/src/integration-tests/launch.spec.ts @@ -18,7 +18,9 @@ import { import { CdtDebugClient } from './debugClient'; import { fillDefaults, + gdbAsync, gdbNonStop, + gdbVersionAtLeast, isRemoteTest, standardBeforeEach, testProgramsDir, @@ -33,6 +35,7 @@ describe('launch', function () { const unicodeProgram = path.join(testProgramsDir, 'bug275-测试'); // the name of this file is short enough to work around https://sourceware.org/bugzilla/show_bug.cgi?id=30618 const unicodeSrc = path.join(testProgramsDir, 'bug275-测试.c'); + const loopForeverProgram = path.join(testProgramsDir, 'loopforever'); beforeEach(async function () { dc = await standardBeforeEach(); @@ -54,6 +57,81 @@ describe('launch', function () { ); }); + function makeRunArgTest(runArg: string) { + return async function (this: Mocha.Context) { + // This tests both local and remote cases and does not need to be + // duplicated in launchRemote.spec.ts (beforeEach and afterEach are + // similar enough here and there). + const isAsync = + gdbAsync && + (os.platform() !== 'win32' || + isRemoteTest || + (await gdbVersionAtLeast('13.0'))); + if ( + (!isAsync || (isRemoteTest && !gdbNonStop)) && + runArg === 'always' + ) { + // in sync mode when all threads are running we can't ask '-thread-info' + // (remote needs non-stop to be really async) + this.skip(); + } + + const eventCounter = { stopped: 0, continued: 0 }; + dc.on('stopped', () => { + eventCounter.stopped++; + }); + dc.on('continued', () => { + eventCounter.continued++; + }); + + const launchArgs = fillDefaults(this.test, { + program: loopForeverProgram, + run: runArg, + } as LaunchRequestArguments); + + await Promise.all([ + dc + .waitForEvent('initialized') + .then(() => dc.configurationDoneRequest()), + dc.initializeRequest().then(() => dc.launchRequest(launchArgs)), + ]); + + const threadInfo = JSON.parse( + ( + await dc.evaluateRequest({ + expression: '>-thread-info', + context: 'repl', + }) + ).body.result + ); + const threadStates = threadInfo.threads.map((t: any) => t.state); + if (runArg === 'always') { + expect(threadStates).to.contain('running'); + expect(threadStates).not.to.contain('stopped'); + } else if (runArg === 'preserve') { + if (isRemoteTest) { + // GDBTargetDebugSession interprets "launch" as "launch + // gdbserver", not "launch the program", so this case actually + // behaves like an attach, not like a launch. + expect(threadStates).to.contain('stopped'); + } else { + // GDBDebugSessionBase implements "launch" as expected. + expect(threadStates).to.be.an('array').that.is.empty; + } + } else { + expect(runArg).to.be.oneOf(['always', 'preserve']); + } + + expect(eventCounter).to.deep.equal({ + stopped: isRemoteTest ? (runArg === 'always' ? 0 : 1) : 0, + continued: 0, + }); + }; + } + + it('can launch and run', makeRunArgTest('always')); + it('can launch without running', makeRunArgTest('preserve')); + it('receives an error when no port is provided nor a suitable regex', async function () { if (!isRemoteTest) { this.skip(); From 59194fe8e9813efe097071fe41d6ba94d0a25c42 Mon Sep 17 00:00:00 2001 From: Christian Walther Date: Mon, 8 Jun 2026 16:20:58 +0200 Subject: [PATCH 8/8] Extend tests for first sending 'run' instead of 'continue' for run=preserve. --- src/integration-tests/attach.spec.ts | 21 +++++++++++++++++++++ src/integration-tests/attachRemote.spec.ts | 21 +++++++++++++++++++++ src/integration-tests/launch.spec.ts | 22 ++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/src/integration-tests/attach.spec.ts b/src/integration-tests/attach.spec.ts index 68a3eb2c..8c85778a 100644 --- a/src/integration-tests/attach.spec.ts +++ b/src/integration-tests/attach.spec.ts @@ -115,6 +115,27 @@ describe('attach', function () { stopped: runArg === 'always' ? 0 : 1, continued: 0, }); + + // check that continue sends the right GDB commands + // (always -exec-continue for attach; + // first -exec-run, then -exec-continue for launch) + if (runArg === 'preserve') { + await dc.setBreakpointsRequest({ + source: { path: src }, + breakpoints: [{ line: 25 }], + }); + await dc.continue({ threadId: -1 }, 'breakpoint', { line: 25 }); + await dc.setBreakpointsRequest({ + source: { path: src }, + breakpoints: [{ line: 26 }], + }); + await dc.continue({ threadId: -1 }, 'breakpoint', { line: 26 }); + + expect(eventCounter).to.deep.equal({ + stopped: 3, + continued: 2, + }); + } }; } diff --git a/src/integration-tests/attachRemote.spec.ts b/src/integration-tests/attachRemote.spec.ts index c38888bb..b788b080 100644 --- a/src/integration-tests/attachRemote.spec.ts +++ b/src/integration-tests/attachRemote.spec.ts @@ -139,6 +139,27 @@ describe('attach remote', function () { stopped: runArg === 'always' ? 0 : 1, continued: 0, }); + + // check that continue sends the right GDB commands + // (always -exec-continue for attach; + // first -exec-run, then -exec-continue for launch) + if (runArg === 'preserve') { + await dc.setBreakpointsRequest({ + source: { path: src }, + breakpoints: [{ line: 25 }], + }); + await dc.continue({ threadId: -1 }, 'breakpoint', { line: 25 }); + await dc.setBreakpointsRequest({ + source: { path: src }, + breakpoints: [{ line: 26 }], + }); + await dc.continue({ threadId: -1 }, 'breakpoint', { line: 26 }); + + expect(eventCounter).to.deep.equal({ + stopped: 3, + continued: 2, + }); + } }; } diff --git a/src/integration-tests/launch.spec.ts b/src/integration-tests/launch.spec.ts index 517d21c2..4c6de449 100644 --- a/src/integration-tests/launch.spec.ts +++ b/src/integration-tests/launch.spec.ts @@ -36,6 +36,7 @@ describe('launch', function () { // the name of this file is short enough to work around https://sourceware.org/bugzilla/show_bug.cgi?id=30618 const unicodeSrc = path.join(testProgramsDir, 'bug275-测试.c'); const loopForeverProgram = path.join(testProgramsDir, 'loopforever'); + const loopForeverSrc = path.join(testProgramsDir, 'loopforever.c'); beforeEach(async function () { dc = await standardBeforeEach(); @@ -126,6 +127,27 @@ describe('launch', function () { stopped: isRemoteTest ? (runArg === 'always' ? 0 : 1) : 0, continued: 0, }); + + // check that continue sends the right GDB commands + // (always -exec-continue for attach; + // first -exec-run, then -exec-continue for launch) + if (runArg === 'preserve') { + await dc.setBreakpointsRequest({ + source: { path: loopForeverSrc }, + breakpoints: [{ line: 25 }], + }); + await dc.continue({ threadId: -1 }, 'breakpoint', { line: 25 }); + await dc.setBreakpointsRequest({ + source: { path: loopForeverSrc }, + breakpoints: [{ line: 26 }], + }); + await dc.continue({ threadId: -1 }, 'breakpoint', { line: 26 }); + + expect(eventCounter).to.deep.equal({ + stopped: (isRemoteTest ? 1 : 0) + 2, + continued: 2, + }); + } }; }