Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
149 changes: 124 additions & 25 deletions src/client/testing/common/debugLauncher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { inject, injectable, named } from 'inversify';
import * as path from 'path';
import { DebugConfiguration, l10n, Uri, WorkspaceFolder, DebugSession, DebugSessionOptions } from 'vscode';
import { DebugConfiguration, l10n, Uri, WorkspaceFolder, DebugSession, DebugSessionOptions, Disposable } from 'vscode';
import { IApplicationShell, IDebugService } from '../../common/application/types';
import { EXTENSION_ROOT_DIR } from '../../common/constants';
import * as internalScripts from '../../common/process/internal/scripts';
Expand All @@ -17,6 +17,14 @@ import { getWorkspaceFolder, getWorkspaceFolders } from '../../common/vscodeApis
import { showErrorMessage } from '../../common/vscodeApis/windowApis';
import { createDeferred } from '../../common/utils/async';
import { addPathToPythonpath } from './helpers';
import * as envExtApi from '../../envExt/api.internal';

/**
* Key used to mark debug configurations with a unique session identifier.
* This allows us to track which debug session belongs to which launchDebugger() call
* when multiple debug sessions are launched in parallel.
*/
const TEST_SESSION_MARKER_KEY = '__vscodeTestSessionMarker';

@injectable()
export class DebugLauncher implements ITestDebugLauncher {
Expand All @@ -31,25 +39,63 @@ export class DebugLauncher implements ITestDebugLauncher {
this.configService = this.serviceContainer.get<IConfigurationService>(IConfigurationService);
}

/**
* Launches a debug session for test execution.
*
* **Cancellation handling:**
* Cancellation can occur from multiple sources, all properly handled:
* 1. **Pre-check**: If already cancelled before starting, returns immediately
* 2. **Token cancellation**: If the parent CancellationToken fires during debugging,
* the deferred resolves and the callback is invoked to clean up resources
* 3. **Session termination**: When the user stops debugging (via UI or completes),
* the onDidTerminateDebugSession event fires and we resolve
*
* **Multi-session support:**
* When debugging tests from multiple projects simultaneously, each launchDebugger()
* call needs to track its own debug session independently. We use a unique marker
* in the launch configuration to identify which session belongs to which call,
* avoiding race conditions with the global `activeDebugSession` property.
*
* @param options Launch configuration including test provider, args, and optional project info
* @param callback Called when the debug session ends (for cleanup like closing named pipes)
* @param sessionOptions VS Code debug session options (e.g., testRun association)
*/
public async launchDebugger(
options: LaunchOptions,
callback?: () => void,
sessionOptions?: DebugSessionOptions,
): Promise<void> {
const deferred = createDeferred<void>();
let hasCallbackBeenCalled = false;

// Collect disposables for cleanup when debugging completes
const disposables: Disposable[] = [];

// Ensure callback is only invoked once, even if multiple termination paths fire
const callCallbackOnce = () => {
if (!hasCallbackBeenCalled) {
hasCallbackBeenCalled = true;
callback?.();
}
};

// Early exit if already cancelled before we start
if (options.token && options.token.isCancellationRequested) {
hasCallbackBeenCalled = true;
return undefined;
callCallbackOnce();
deferred.resolve();
callback?.();
return deferred.promise;
}

options.token?.onCancellationRequested(() => {
deferred.resolve();
callback?.();
hasCallbackBeenCalled = true;
});
// Listen for cancellation from the test run (e.g., user clicks stop in Test Explorer)
// This allows the caller to clean up resources even if the debug session is still running
if (options.token) {
disposables.push(
options.token.onCancellationRequested(() => {
deferred.resolve();
callCallbackOnce();
}),
);
}

const workspaceFolder = DebugLauncher.resolveWorkspaceFolder(options.cwd);
const launchArgs = await this.getLaunchArgs(
Expand All @@ -59,23 +105,54 @@ export class DebugLauncher implements ITestDebugLauncher {
);
const debugManager = this.serviceContainer.get<IDebugService>(IDebugService);

let activatedDebugSession: DebugSession | undefined;
debugManager.startDebugging(workspaceFolder, launchArgs, sessionOptions).then(() => {
// Save the debug session after it is started so we can check if it is the one that was terminated.
activatedDebugSession = debugManager.activeDebugSession;
});
debugManager.onDidTerminateDebugSession((session) => {
traceVerbose(`Debug session terminated. sessionId: ${session.id}`);
// Only resolve no callback has been made and the session is the one that was started.
if (
!hasCallbackBeenCalled &&
activatedDebugSession !== undefined &&
session.id === activatedDebugSession?.id
) {
deferred.resolve();
callback?.();
}
// Generate a unique marker for this debug session.
// When multiple debug sessions start in parallel (e.g., debugging tests from
// multiple projects), we can't rely on debugManager.activeDebugSession because
// it's a global that could be overwritten by another concurrent session start.
// Instead, we embed a unique marker in our launch configuration and match it
// when the session starts to identify which session is ours.
const sessionMarker = `test-${Date.now()}-${Math.random().toString(36).substring(7)}`;
launchArgs[TEST_SESSION_MARKER_KEY] = sessionMarker;

let ourSession: DebugSession | undefined;

// Capture our specific debug session when it starts by matching the marker.
// This fires for ALL debug sessions, so we filter to only our marker.
disposables.push(
debugManager.onDidStartDebugSession((session) => {
if (session.configuration[TEST_SESSION_MARKER_KEY] === sessionMarker) {
ourSession = session;
traceVerbose(`[test-debug] Debug session started: ${session.name} (${session.id})`);
}
}),
);

// Handle debug session termination (user stops debugging, or tests complete).
// Only react to OUR session terminating - other parallel sessions should
// continue running independently.
disposables.push(
debugManager.onDidTerminateDebugSession((session) => {
if (ourSession && session.id === ourSession.id) {
traceVerbose(`[test-debug] Debug session terminated: ${session.name} (${session.id})`);
deferred.resolve();
callCallbackOnce();
}
}),
);

// Start the debug session
const started = await debugManager.startDebugging(workspaceFolder, launchArgs, sessionOptions);
if (!started) {
traceError('Failed to start debug session');
deferred.resolve();
callCallbackOnce();
}

// Clean up event subscriptions when debugging completes (success, failure, or cancellation)
deferred.promise.finally(() => {
disposables.forEach((d) => d.dispose());
});

return deferred.promise;
}

Expand Down Expand Up @@ -108,6 +185,12 @@ export class DebugLauncher implements ITestDebugLauncher {
subProcess: true,
};
}

// Use project name in debug session name if provided
if (options.project) {
debugConfig.name = `Debug Tests: ${options.project.name}`;
}

if (!debugConfig.rules) {
debugConfig.rules = [];
}
Expand Down Expand Up @@ -257,6 +340,22 @@ export class DebugLauncher implements ITestDebugLauncher {
// run via F5 style debugging.
launchArgs.purpose = [];

// For project-based execution, get the Python path from the project's environment.
// This ensures debug sessions use the correct interpreter for each project.
if (options.project && envExtApi.useEnvExtension()) {
try {
const pythonEnv = await envExtApi.getEnvironment(options.project.uri);
if (pythonEnv?.execInfo?.run?.executable) {
launchArgs.python = pythonEnv.execInfo.run.executable;
traceVerbose(
`[test-by-project] Debug session using Python path from project: ${launchArgs.python}`,
);
}
} catch (error) {
traceVerbose(`[test-by-project] Could not get environment for project, using default: ${error}`);
}
}

return launchArgs;
}

Expand Down
8 changes: 8 additions & 0 deletions src/client/testing/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { CancellationToken, DebugSessionOptions, OutputChannel, Uri } from 'vsco
import { Product } from '../../common/types';
import { TestSettingsPropertyNames } from '../configuration/types';
import { TestProvider } from '../types';
import { PythonProject } from '../../envExt/types';

export type UnitTestProduct = Product.pytest | Product.unittest;

Expand All @@ -26,6 +27,13 @@ export type LaunchOptions = {
pytestPort?: string;
pytestUUID?: string;
runTestIdsPort?: string;
/**
* Optional Python project for project-based execution.
* When provided, the debug launcher will:
* - Use the project's associated Python environment
* - Name the debug session after the project
*/
project?: PythonProject;
};

export enum TestFilter {
Expand Down
Loading
Loading