11import { inject , injectable , named } from 'inversify' ;
22import * as path from 'path' ;
3- import { DebugConfiguration , l10n , Uri , WorkspaceFolder , DebugSession , DebugSessionOptions } from 'vscode' ;
3+ import { DebugConfiguration , l10n , Uri , WorkspaceFolder , DebugSession , DebugSessionOptions , Disposable } from 'vscode' ;
44import { IApplicationShell , IDebugService } from '../../common/application/types' ;
55import { EXTENSION_ROOT_DIR } from '../../common/constants' ;
66import * as internalScripts from '../../common/process/internal/scripts' ;
@@ -17,6 +17,14 @@ import { getWorkspaceFolder, getWorkspaceFolders } from '../../common/vscodeApis
1717import { showErrorMessage } from '../../common/vscodeApis/windowApis' ;
1818import { createDeferred } from '../../common/utils/async' ;
1919import { addPathToPythonpath } from './helpers' ;
20+ import * as envExtApi from '../../envExt/api.internal' ;
21+
22+ /**
23+ * Key used to mark debug configurations with a unique session identifier.
24+ * This allows us to track which debug session belongs to which launchDebugger() call
25+ * when multiple debug sessions are launched in parallel.
26+ */
27+ const TEST_SESSION_MARKER_KEY = '__vscodeTestSessionMarker' ;
2028
2129@injectable ( )
2230export class DebugLauncher implements ITestDebugLauncher {
@@ -31,25 +39,46 @@ export class DebugLauncher implements ITestDebugLauncher {
3139 this . configService = this . serviceContainer . get < IConfigurationService > ( IConfigurationService ) ;
3240 }
3341
42+ /**
43+ * Launches a debug session for test execution.
44+ * Handles cancellation, multi-session support via unique markers, and cleanup.
45+ */
3446 public async launchDebugger (
3547 options : LaunchOptions ,
3648 callback ?: ( ) => void ,
3749 sessionOptions ?: DebugSessionOptions ,
3850 ) : Promise < void > {
3951 const deferred = createDeferred < void > ( ) ;
4052 let hasCallbackBeenCalled = false ;
53+
54+ // Collect disposables for cleanup when debugging completes
55+ const disposables : Disposable [ ] = [ ] ;
56+
57+ // Ensure callback is only invoked once, even if multiple termination paths fire
58+ const callCallbackOnce = ( ) => {
59+ if ( ! hasCallbackBeenCalled ) {
60+ hasCallbackBeenCalled = true ;
61+ callback ?.( ) ;
62+ }
63+ } ;
64+
65+ // Early exit if already cancelled before we start
4166 if ( options . token && options . token . isCancellationRequested ) {
42- hasCallbackBeenCalled = true ;
43- return undefined ;
67+ callCallbackOnce ( ) ;
4468 deferred . resolve ( ) ;
45- callback ?. ( ) ;
69+ return deferred . promise ;
4670 }
4771
48- options . token ?. onCancellationRequested ( ( ) => {
49- deferred . resolve ( ) ;
50- callback ?.( ) ;
51- hasCallbackBeenCalled = true ;
52- } ) ;
72+ // Listen for cancellation from the test run (e.g., user clicks stop in Test Explorer)
73+ // This allows the caller to clean up resources even if the debug session is still running
74+ if ( options . token ) {
75+ disposables . push (
76+ options . token . onCancellationRequested ( ( ) => {
77+ deferred . resolve ( ) ;
78+ callCallbackOnce ( ) ;
79+ } ) ,
80+ ) ;
81+ }
5382
5483 const workspaceFolder = DebugLauncher . resolveWorkspaceFolder ( options . cwd ) ;
5584 const launchArgs = await this . getLaunchArgs (
@@ -59,23 +88,49 @@ export class DebugLauncher implements ITestDebugLauncher {
5988 ) ;
6089 const debugManager = this . serviceContainer . get < IDebugService > ( IDebugService ) ;
6190
62- let activatedDebugSession : DebugSession | undefined ;
63- debugManager . startDebugging ( workspaceFolder , launchArgs , sessionOptions ) . then ( ( ) => {
64- // Save the debug session after it is started so we can check if it is the one that was terminated.
65- activatedDebugSession = debugManager . activeDebugSession ;
66- } ) ;
67- debugManager . onDidTerminateDebugSession ( ( session ) => {
68- traceVerbose ( `Debug session terminated. sessionId: ${ session . id } ` ) ;
69- // Only resolve no callback has been made and the session is the one that was started.
70- if (
71- ! hasCallbackBeenCalled &&
72- activatedDebugSession !== undefined &&
73- session . id === activatedDebugSession ?. id
74- ) {
75- deferred . resolve ( ) ;
76- callback ?.( ) ;
77- }
91+ // Unique marker to identify this session among concurrent debug sessions
92+ const sessionMarker = `test-${ Date . now ( ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
93+ launchArgs [ TEST_SESSION_MARKER_KEY ] = sessionMarker ;
94+
95+ let ourSession : DebugSession | undefined ;
96+
97+ // Capture our specific debug session when it starts by matching the marker.
98+ // This fires for ALL debug sessions, so we filter to only our marker.
99+ disposables . push (
100+ debugManager . onDidStartDebugSession ( ( session ) => {
101+ if ( session . configuration [ TEST_SESSION_MARKER_KEY ] === sessionMarker ) {
102+ ourSession = session ;
103+ traceVerbose ( `[test-debug] Debug session started: ${ session . name } (${ session . id } )` ) ;
104+ }
105+ } ) ,
106+ ) ;
107+
108+ // Handle debug session termination (user stops debugging, or tests complete).
109+ // Only react to OUR session terminating - other parallel sessions should
110+ // continue running independently.
111+ disposables . push (
112+ debugManager . onDidTerminateDebugSession ( ( session ) => {
113+ if ( ourSession && session . id === ourSession . id ) {
114+ traceVerbose ( `[test-debug] Debug session terminated: ${ session . name } (${ session . id } )` ) ;
115+ deferred . resolve ( ) ;
116+ callCallbackOnce ( ) ;
117+ }
118+ } ) ,
119+ ) ;
120+
121+ // Start the debug session
122+ const started = await debugManager . startDebugging ( workspaceFolder , launchArgs , sessionOptions ) ;
123+ if ( ! started ) {
124+ traceError ( 'Failed to start debug session' ) ;
125+ deferred . resolve ( ) ;
126+ callCallbackOnce ( ) ;
127+ }
128+
129+ // Clean up event subscriptions when debugging completes (success, failure, or cancellation)
130+ deferred . promise . finally ( ( ) => {
131+ disposables . forEach ( ( d ) => d . dispose ( ) ) ;
78132 } ) ;
133+
79134 return deferred . promise ;
80135 }
81136
@@ -108,6 +163,12 @@ export class DebugLauncher implements ITestDebugLauncher {
108163 subProcess : true ,
109164 } ;
110165 }
166+
167+ // Use project name in debug session name if provided
168+ if ( options . project ) {
169+ debugConfig . name = `Debug Tests: ${ options . project . name } ` ;
170+ }
171+
111172 if ( ! debugConfig . rules ) {
112173 debugConfig . rules = [ ] ;
113174 }
@@ -257,6 +318,23 @@ export class DebugLauncher implements ITestDebugLauncher {
257318 // run via F5 style debugging.
258319 launchArgs . purpose = [ ] ;
259320
321+ // For project-based execution, get the Python path from the project's environment.
322+ // Fallback: if env API unavailable or fails, LaunchConfigurationResolver already set
323+ // launchArgs.python from the active interpreter, so debugging still works.
324+ if ( options . project && envExtApi . useEnvExtension ( ) ) {
325+ try {
326+ const pythonEnv = await envExtApi . getEnvironment ( options . project . uri ) ;
327+ if ( pythonEnv ?. execInfo ?. run ?. executable ) {
328+ launchArgs . python = pythonEnv . execInfo . run . executable ;
329+ traceVerbose (
330+ `[test-by-project] Debug session using Python path from project: ${ launchArgs . python } ` ,
331+ ) ;
332+ }
333+ } catch ( error ) {
334+ traceVerbose ( `[test-by-project] Could not get environment for project, using default: ${ error } ` ) ;
335+ }
336+ }
337+
260338 return launchArgs ;
261339 }
262340
0 commit comments