diff --git a/src/client/activation/activationManager.ts b/src/client/activation/activationManager.ts index 9e97c5c48857..57e545413f78 100644 --- a/src/client/activation/activationManager.ts +++ b/src/client/activation/activationManager.ts @@ -10,13 +10,17 @@ import { IActiveResourceService, IDocumentManager, IWorkspaceService } from '../ import { PYTHON_LANGUAGE } from '../common/constants'; import { IFileSystem } from '../common/platform/types'; import { IDisposable, IInterpreterPathService, Resource } from '../common/types'; -import { Deferred } from '../common/utils/async'; +import { Deferred, sleep } from '../common/utils/async'; import { StopWatch } from '../common/utils/stopWatch'; import { IInterpreterAutoSelectionService } from '../interpreter/autoSelection/types'; -import { traceDecoratorError } from '../logging'; +import { traceDecoratorError, traceError } from '../logging'; import { sendActivationTelemetry } from '../telemetry/envFileTelemetry'; import { IExtensionActivationManager, IExtensionActivationService, IExtensionSingleActivationService } from './types'; +// Upper bound on how long workspace activation waits for interpreter auto-selection before +// proceeding. Auto-selection still completes in the background after this point. +const AUTO_SELECT_INTERPRETER_TIMEOUT_MS = 100; + @injectable() export class ExtensionActivationManager implements IExtensionActivationManager { public readonly activatedWorkspaces = new Set(); @@ -94,7 +98,15 @@ export class ExtensionActivationManager implements IExtensionActivationManager { if (this.workspaceService.isTrusted) { // Do not interact with interpreters in a untrusted workspace. - await this.autoSelection.autoSelectInterpreter(resource); + // Don't block activation (and therefore language server startup) on auto-selection. + // On a cold start it can wait for a full environment refresh to complete, which would + // delay starting the language server. Let it finish in the background; the selected + // interpreter is reported to listeners (e.g. Pylance) via the environments API once it + // is ready. We still wait briefly so the common warm-start case is unchanged. + const autoSelection = this.autoSelection + .autoSelectInterpreter(resource) + .catch((ex) => traceError('Auto-selection of interpreter failed', ex)); + await Promise.race([autoSelection, sleep(AUTO_SELECT_INTERPRETER_TIMEOUT_MS)]); await this.interpreterPathService.copyOldInterpreterStorageValuesToNew(resource); } await sendActivationTelemetry(this.fileSystem, this.workspaceService, resource); diff --git a/src/client/envExt/api.legacy.ts b/src/client/envExt/api.legacy.ts index 6f2e60774033..679607fca68d 100644 --- a/src/client/envExt/api.legacy.ts +++ b/src/client/envExt/api.legacy.ts @@ -99,7 +99,9 @@ function toLegacyType(env: PythonEnvironment): PythonEnvironmentLegacy { } const previousEnvMap = new Map(); -export async function getActiveInterpreterLegacy(resource?: Uri): Promise { +const inFlightActiveInterpreter = new Map>(); + +async function resolveActiveInterpreterLegacy(resource?: Uri): Promise { const api = await getEnvExtApi(); const uri = resource ? api.getPythonProject(resource)?.uri : undefined; @@ -117,7 +119,23 @@ export async function getActiveInterpreterLegacy(resource?: Uri): Promise { + // De-duplicate concurrent resolutions for the same resource. The underlying + // `getEnvironment` call can block while the environments extension is performing a + // refresh, so multiple startup callers (e.g. the language server watcher and the + // configuration middleware) would otherwise each spawn their own blocking request. + const key = resource?.fsPath ?? ''; + let inFlight = inFlightActiveInterpreter.get(key); + if (!inFlight) { + inFlight = resolveActiveInterpreterLegacy(resource).finally(() => { + inFlightActiveInterpreter.delete(key); + }); + inFlightActiveInterpreter.set(key, inFlight); + } + return inFlight; } export async function setInterpreterLegacy(pythonPath: string, uri: Uri | undefined): Promise { diff --git a/src/client/interpreter/interpreterService.ts b/src/client/interpreter/interpreterService.ts index ad06fd7d051d..54c1e0b585b8 100644 --- a/src/client/interpreter/interpreterService.ts +++ b/src/client/interpreter/interpreterService.ts @@ -18,6 +18,8 @@ import { IDisposableRegistry, IInstaller, IInterpreterPathService, + IPersistentState, + IPersistentStateFactory, Product, } from '../common/types'; import { IServiceContainer } from '../ioc/types'; @@ -49,6 +51,15 @@ import { getActiveInterpreterLegacy } from '../envExt/api.legacy'; type StoredPythonEnvironment = PythonEnvironment & { store?: boolean }; +// Upper bound on how long `getActiveInterpreter` may block its caller. Resolving the active +// interpreter can wait on a full environment refresh (especially with the environments +// extension enabled), which would otherwise delay language server startup. +const GET_ACTIVE_INTERPRETER_TIMEOUT_MS = 100; +const ACTIVE_INTERPRETER_TIMED_OUT = Symbol('activeInterpreterTimedOut'); + +// Key prefix for the persisted (cross-restart) last-known active interpreter, one entry per workspace. +const LAST_KNOWN_ACTIVE_INTERPRETER_KEY_PREFIX = 'lastKnownActiveInterpreter:'; + @injectable() export class InterpreterService implements Disposable, IInterpreterService { public async hasInterpreters( @@ -100,6 +111,23 @@ export class InterpreterService implements Disposable, IInterpreterService { { path: string; workspaceFolder: WorkspaceFolder | undefined } >(); + // Last successfully resolved active interpreter per workspace, served when a resolution + // doesn't complete within `GET_ACTIVE_INTERPRETER_TIMEOUT_MS`. + private readonly lastKnownActiveInterpreter = new Map< + string, + { resource: Uri | undefined; env: StoredPythonEnvironment | undefined } + >(); + + // In-flight active interpreter resolutions, de-duplicated per workspace. + private readonly inFlightActiveInterpreter = new Map>(); + + // Persisted (cross-restart) last-known active interpreter, one persistent-state handle per workspace. + // Lets a warm start serve a real interpreter within the timeout budget before discovery completes. + private readonly persistedActiveInterpreter = new Map< + string, + IPersistentState + >(); + constructor( @inject(IServiceContainer) private serviceContainer: IServiceContainer, @inject(IComponentAdapter) private readonly pyenvs: IComponentAdapter, @@ -198,6 +226,13 @@ export class InterpreterService implements Disposable, IInterpreterService { }), ); disposables.push(this.interpreterPathService.onDidChange((i) => this._onConfigChanged(i.uri))); + // Auto-selection completes in the background (it is no longer awaited before activation), + // which updates the python path setting once an interpreter is chosen. Re-run the config + // change handler so the newly selected interpreter is reported to listeners (e.g. Pylance). + // `_onConfigChanged` is gated on the python path actually changing, and + // `reportActiveInterpreterChanged` de-duplicates by path, so this is a no-op when nothing + // changed. The environments-extension path reports its own changes via the legacy API. + disposables.push(this.configService.onDidChange(() => this._onConfigChanged())); } public getInterpreters(resource?: Uri): PythonEnvironment[] { @@ -219,6 +254,70 @@ export class InterpreterService implements Disposable, IInterpreterService { } public async getActiveInterpreter(resource?: Uri): Promise { + const workspaceService = this.serviceContainer.get(IWorkspaceService); + const key = workspaceService.getWorkspaceFolderIdentifier(resource); + + // De-duplicate concurrent resolutions for the same workspace. + let resolution = this.inFlightActiveInterpreter.get(key); + if (!resolution) { + resolution = this.resolveActiveInterpreter(resource) + .then((env) => { + this.lastKnownActiveInterpreter.set(key, { resource, env }); + if (env) { + // Persist the resolved interpreter so a future window/session can serve a + // real value within the timeout budget before discovery/refresh completes. + this.getPersistedActiveInterpreterState(key) + .updateValue(env) + .catch((ex) => traceError('Failed to persist active interpreter', ex)); + } + return env; + }) + .catch((ex) => { + traceError('Failed to get active interpreter', ex); + return undefined; + }) + .finally(() => { + this.inFlightActiveInterpreter.delete(key); + }); + this.inFlightActiveInterpreter.set(key, resolution); + } + + // Don't block callers (notably language server startup) on a potentially slow + // environment discovery/refresh. If resolution doesn't complete promptly, return the + // last-known interpreter (or undefined on a cold start). The resolution promise is not + // abandoned, so listeners are still notified of the real value once it becomes available: + // the env-extension path reports via the environments API (see getActiveInterpreterLegacy), + // and the native path reports via `_onConfigChanged` once auto-selection completes. + const result = await Promise.race([ + resolution, + sleep(GET_ACTIVE_INTERPRETER_TIMEOUT_MS).then(() => ACTIVE_INTERPRETER_TIMED_OUT), + ]); + if (result !== ACTIVE_INTERPRETER_TIMED_OUT) { + return result as StoredPythonEnvironment | undefined; + } + // Prefer the value resolved earlier this session; otherwise fall back to the persisted + // value from a previous session so warm restarts still serve a real interpreter quickly. + const cached = this.lastKnownActiveInterpreter.get(key); + if (cached) { + return cached.env; + } + return this.getPersistedActiveInterpreterState(key).value; + } + + private getPersistedActiveInterpreterState(key: string): IPersistentState { + let state = this.persistedActiveInterpreter.get(key); + if (!state) { + const factory = this.serviceContainer.get(IPersistentStateFactory); + state = factory.createWorkspacePersistentState( + `${LAST_KNOWN_ACTIVE_INTERPRETER_KEY_PREFIX}${key}`, + undefined, + ); + this.persistedActiveInterpreter.set(key, state); + } + return state; + } + + private async resolveActiveInterpreter(resource?: Uri): Promise { if (useEnvExtension()) { return getActiveInterpreterLegacy(resource); } diff --git a/src/client/pythonEnvironments/common/environmentManagers/conda.ts b/src/client/pythonEnvironments/common/environmentManagers/conda.ts index c1bfd7d68bc2..54a11eb3f150 100644 --- a/src/client/pythonEnvironments/common/environmentManagers/conda.ts +++ b/src/client/pythonEnvironments/common/environmentManagers/conda.ts @@ -257,6 +257,15 @@ export class Conda { */ private static condaPromise = new Map>(); + /** + * When another component (e.g. the Python Environments extension via pet) owns + * environment discovery, the legacy registry/known-path probing performed by + * locate() is redundant and was a significant startup cost (sequential + * `conda info --json` probes and reg.exe spawns). When set, locate() only honors + * the explicit `python.condaPath` setting and `conda` on PATH. + */ + private static skipDeepProbe = false; + private condaInfoCached = new Map | undefined>(); /** @@ -296,6 +305,18 @@ export class Conda { Conda.condaPromise.set(undefined, Promise.resolve(new Conda(condaPath))); } + /** + * Restrict {@link locate} to the explicit `python.condaPath` setting and `conda` on + * PATH, skipping the expensive registry/known-path filesystem probing. Used when + * environment discovery is delegated to another component (e.g. the Python + * Environments extension), which is the source of truth for locating conda. + */ + public static setSkipDeepProbe(value: boolean): void { + Conda.skipDeepProbe = value; + // Drop any cached resolution so the new probing policy takes effect. + Conda.condaPromise = new Map>(); + } + /** * Locates the preferred "conda" utility on this system by considering user settings, * binaries on PATH, Python interpreters in the registry, and known install locations. @@ -314,6 +335,7 @@ export class Conda { const suffix = getOSType() === OSType.Windows ? 'Scripts\\conda.exe' : 'bin/conda'; // Produce a list of candidate binaries to be probed by exec'ing them. + const skipDeepProbe = Conda.skipDeepProbe; async function* getCandidates() { if (customCondaPath && customCondaPath !== 'conda') { // If user has specified a custom conda path, use it first. @@ -321,6 +343,11 @@ export class Conda { } // Check unqualified filename first, in case it's on PATH. yield 'conda'; + if (skipDeepProbe) { + // Discovery is delegated to another component (e.g. the Python + // Environments extension); skip the costly registry/known-path probing. + return; + } if (getOSType() === OSType.Windows) { yield* getCandidatesFromRegistry(); } diff --git a/src/client/pythonEnvironments/index.ts b/src/client/pythonEnvironments/index.ts index 299dfab59132..15e5242e789a 100644 --- a/src/client/pythonEnvironments/index.ts +++ b/src/client/pythonEnvironments/index.ts @@ -45,6 +45,7 @@ import { getNativePythonFinder } from './base/locators/common/nativePythonFinder import { createNativeEnvironmentsApi } from './nativeAPI'; import { useEnvExtension } from '../envExt/api.internal'; import { createEnvExtApi } from '../envExt/envExtApi'; +import { Conda } from './common/environmentManagers/conda'; const PYTHON_ENV_INFO_CACHE_KEY = 'PYTHON_ENV_INFO_CACHEv2'; @@ -61,6 +62,10 @@ export async function initialize(ext: ExtensionState): Promise { initializeLegacyExternalDependencies(ext.legacyIOC.serviceContainer); if (useEnvExtension()) { + // The Python Environments extension (via pet) owns environment discovery, + // including locating conda. Skip the legacy registry/known-path conda probing, + // which is redundant here and was a significant startup cost. + Conda.setSkipDeepProbe(true); const api = await createEnvExtApi(ext.disposables); registerNewDiscoveryForIOC( // These are what get wrapped in the legacy adapter. diff --git a/src/client/testing/testController/common/testProjectRegistry.ts b/src/client/testing/testController/common/testProjectRegistry.ts index 4f0702ad584c..f864dec1d9f3 100644 --- a/src/client/testing/testController/common/testProjectRegistry.ts +++ b/src/client/testing/testController/common/testProjectRegistry.ts @@ -152,7 +152,9 @@ export class TestProjectRegistry { for (const pythonProject of workspaceProjects) { try { const adapter = await this.createProjectAdapter(pythonProject, workspaceUri); - adapters.push(adapter); + if (adapter) { + adapters.push(adapter); + } } catch (error) { traceError(`[test-by-project] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error); } @@ -178,8 +180,16 @@ export class TestProjectRegistry { * - **DiscoveryAdapter:** Discovers tests scoped to this project's root directory * - **ExecutionAdapter:** Runs tests for this project using its Python environment * + * Returns `undefined` when the Python Environments extension has not yet assigned an + * environment to the project. This is expected during startup: extension activation no longer + * waits for the environments extension's initial refresh to complete, so discovery can run + * before environments are resolved. The project is re-discovered once an environment is + * assigned (see the controller's environment-change subscription). */ - private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise { + private async createProjectAdapter( + pythonProject: PythonProject, + workspaceUri: Uri, + ): Promise { const projectId = getProjectId(pythonProject.uri); traceInfo(`[test-by-project] Creating adapter for: ${pythonProject.name} at ${projectId}`); @@ -187,7 +197,13 @@ export class TestProjectRegistry { const envExtApi = await getEnvExtApi(); const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri); if (!pythonEnvironment) { - throw new Error(`No Python environment found for project ${projectId}`); + // Not an error: the environments extension may not have assigned an environment to + // this project yet. Defer it; the controller re-discovers the workspace when an + // environment is later assigned (onDidChangeEnvironment). + traceInfo( + `[test-by-project] No Python environment resolved yet for project ${projectId}; deferring until one is assigned`, + ); + return undefined; } // Create test infrastructure diff --git a/src/client/testing/testController/controller.ts b/src/client/testing/testController/controller.ts index d93e91803a9b..f1e679f95dd9 100644 --- a/src/client/testing/testController/controller.ts +++ b/src/client/testing/testController/controller.ts @@ -50,7 +50,12 @@ import { TestProjectRegistry } from './common/testProjectRegistry'; import { createTestAdapters, getProjectId } from './common/projectUtils'; import { executeTestsForProjects } from './common/projectTestExecution'; import { useEnvExtension, getEnvExtApi } from '../../envExt/api.internal'; -import { DidChangePythonProjectsEventArgs, PythonProject } from '../../envExt/types'; +import { DidChangeEnvironmentEventArgs, DidChangePythonProjectsEventArgs, PythonProject } from '../../envExt/types'; + +// Coalescing window for environment-driven test re-discovery. The environments extension can +// assign environments to many projects in quick succession during its initial refresh; waiting +// briefly after the last change avoids re-discovering the workspace once per project. +const ENV_CHANGE_REDISCOVERY_DELAY_MS = 1000; @injectable() export class PythonTestController implements ITestController, IExtensionSingleActivationService { @@ -74,6 +79,12 @@ export class PythonTestController implements ITestController, IExtensionSingleAc private readonly refreshData: IDelayedTrigger; + // Debounced trigger that re-discovers workspaces once their environments resolve after activation. + private readonly envChangeRediscoverTrigger: IDelayedTrigger; + + // Workspaces awaiting an environment-driven re-discovery, coalesced by `envChangeRediscoverTrigger`. + private readonly pendingEnvChangeWorkspaces = new Map(); + private refreshCancellation: CancellationTokenSource; private readonly refreshingCompletedEvent: EventEmitter = new EventEmitter(); @@ -130,6 +141,18 @@ export class PythonTestController implements ITestController, IExtensionSingleAc this.disposables.push(delayTrigger); this.refreshData = delayTrigger; + const envChangeRediscoverTrigger = new DelayedTrigger( + () => { + this.rediscoverPendingEnvChangeWorkspaces().catch((ex) => + traceError('[test-by-project] Failed to re-discover after environment change:', ex), + ); + }, + ENV_CHANGE_REDISCOVERY_DELAY_MS, + 'Env Change Re-discovery', + ); + this.disposables.push(envChangeRediscoverTrigger); + this.envChangeRediscoverTrigger = envChangeRediscoverTrigger; + this.disposables.push( this.testController.createRunProfile( 'Run Tests', @@ -233,6 +256,9 @@ export class PythonTestController implements ITestController, IExtensionSingleAc }); // Subscribe to project changes to update test tree when projects are added/removed await this.subscribeToProjectChanges(); + // Subscribe to environment changes so projects that had no resolved environment at + // activation get discovered once the environments extension assigns one. + await this.subscribeToEnvironmentChanges(); return; } @@ -262,6 +288,87 @@ export class PythonTestController implements ITestController, IExtensionSingleAc } } + /** + * Subscribes to environment assignment changes from the Python Environments API. + * + * At activation the environments extension may not have assigned an environment to every + * project yet, so per-project discovery can fail with "No Python environment found" and fall + * back to a placeholder default project. When an environment is later assigned (or changed), + * re-discover the affected workspace so the test tree self-heals without blocking startup. + */ + private async subscribeToEnvironmentChanges(): Promise { + try { + const envExtApi = await getEnvExtApi(); + this.disposables.push( + envExtApi.onDidChangeEnvironment((event: DidChangeEnvironmentEventArgs) => { + this.handleEnvironmentChange(event); + }), + ); + traceInfo('[test-by-project] Subscribed to Python environment changes'); + } catch (error) { + traceError('[test-by-project] Failed to subscribe to environment changes:', error); + } + } + + /** + * Queues a debounced re-discovery for the workspace affected by an environment change. + * Only reacts when an environment became available or changed (`event.new` is set); clearing + * an environment does not enable any new discovery. + */ + private handleEnvironmentChange(event: DidChangeEnvironmentEventArgs): void { + if (!event.new) { + return; + } + + const affected: WorkspaceFolder[] = []; + if (event.uri) { + const workspace = this.workspaceService.getWorkspaceFolder(event.uri); + if (workspace) { + affected.push(workspace); + } + } else { + // A global environment change can affect any workspace. + affected.push(...(this.workspaceService.workspaceFolders ?? [])); + } + + let queued = false; + for (const workspace of affected) { + // Only workspaces already in project-based mode can be re-discovered this way. + if (this.projectRegistry.hasProjects(workspace.uri)) { + this.pendingEnvChangeWorkspaces.set(workspace.uri.toString(), workspace); + queued = true; + } + } + + if (queued) { + this.envChangeRediscoverTrigger.trigger(); + } + } + + /** + * Drains the pending set and re-discovers each affected workspace, re-registering project + * adapters (picking up environments resolved since activation) and refreshing the test tree. + */ + private async rediscoverPendingEnvChangeWorkspaces(): Promise { + const workspaces = Array.from(this.pendingEnvChangeWorkspaces.values()); + this.pendingEnvChangeWorkspaces.clear(); + + for (const workspace of workspaces) { + traceInfo( + `[test-by-project] Environment change detected; re-discovering projects for ${workspace.uri.fsPath}`, + ); + try { + // eslint-disable-next-line no-await-in-loop + await this.discoverAllProjectsInWorkspace(workspace.uri); + } catch (error) { + traceError( + `[test-by-project] Failed to re-discover after environment change for ${workspace.uri.fsPath}:`, + error, + ); + } + } + } + /** * Handles changes to Python projects (added or removed). * Cleans up stale test items and re-discovers projects and tests for affected workspaces. diff --git a/src/test/activation/activationManager.unit.test.ts b/src/test/activation/activationManager.unit.test.ts index 6ee2572214b8..dddbc3b6dad1 100644 --- a/src/test/activation/activationManager.unit.test.ts +++ b/src/test/activation/activationManager.unit.test.ts @@ -9,6 +9,7 @@ import { anything, instance, mock, verify, when } from 'ts-mockito'; import * as typemoq from 'typemoq'; import { TextDocument, Uri, WorkspaceFolder } from 'vscode'; import { ExtensionActivationManager } from '../../client/activation/activationManager'; +import { IExtensionActivationService } from '../../client/activation/types'; import { IApplicationDiagnostics } from '../../client/application/types'; import { ActiveResourceService } from '../../client/common/application/activeResource'; import { IActiveResourceService, IDocumentManager, IWorkspaceService } from '../../client/common/application/types'; @@ -175,6 +176,51 @@ suite('Activation Manager', () => { appDiagnostics.verifyAll(); }); + test('Does not block activation when interpreter auto-selection is slow', async () => { + const resource = Uri.parse('two'); + const workspaceFolder = { + index: 0, + name: 'one', + uri: resource, + }; + when(workspaceService.getWorkspaceFolder(resource)).thenReturn(workspaceFolder); + + // Auto-selection never resolves (simulating a long-running first-run refresh). + autoSelection + .setup((a) => a.autoSelectInterpreter(resource)) + .returns(() => new Promise(() => undefined)) + .verifiable(typemoq.Times.once()); + appDiagnostics + .setup((a) => a.performPreStartupHealthCheck(resource)) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + const activationService = typemoq.Mock.ofType(); + activationService + .setup((a) => a.activate(typemoq.It.isAny(), typemoq.It.isAny())) + .returns(() => Promise.resolve()) + .verifiable(typemoq.Times.once()); + + managerTest = new ExtensionActivationManagerTest( + [activationService.object], + [], + documentManager.object, + autoSelection.object, + appDiagnostics.object, + instance(workspaceService), + instance(fileSystem), + instance(activeResourceService), + interpreterPathService.object, + ); + + // Should resolve even though auto-selection never does. + await managerTest.activateWorkspace(resource); + + autoSelection.verifyAll(); + appDiagnostics.verifyAll(); + activationService.verifyAll(); + }); + test('Initialize will add event handlers and will dispose them when running dispose', async () => { const disposable = typemoq.Mock.ofType(); const disposable2 = typemoq.Mock.ofType(); diff --git a/src/test/envExt/api.legacy.unit.test.ts b/src/test/envExt/api.legacy.unit.test.ts new file mode 100644 index 000000000000..2d9d681f3fa2 --- /dev/null +++ b/src/test/envExt/api.legacy.unit.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +'use strict'; + +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import * as apiInternal from '../../client/envExt/api.internal'; +import * as environmentApi from '../../client/environmentApi'; +import * as workspaceApis from '../../client/common/vscodeApis/workspaceApis'; +import { getActiveInterpreterLegacy } from '../../client/envExt/api.legacy'; +import { PythonEnvironment } from '../../client/envExt/types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +function buildEnv(executable: string, version: string): PythonEnvironment { + return { + envId: { id: executable, managerId: 'ms-python.python:system' }, + name: 'env', + displayName: 'env', + displayPath: executable, + version, + environmentPath: Uri.file(executable), + execInfo: { run: { executable } }, + sysPrefix: '/usr', + } as any; +} + +suite('Env extension legacy API - getActiveInterpreterLegacy', () => { + let getEnvExtApiStub: sinon.SinonStub; + let getEnvironmentStub: sinon.SinonStub; + + setup(() => { + getEnvExtApiStub = sinon.stub(apiInternal, 'getEnvExtApi'); + getEnvExtApiStub.resolves({ getPythonProject: () => undefined } as any); + getEnvironmentStub = sinon.stub(apiInternal, 'getEnvironment'); + sinon.stub(environmentApi, 'reportActiveInterpreterChanged'); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([] as any); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(undefined); + }); + + teardown(() => { + sinon.restore(); + }); + + test('De-duplicates concurrent calls for the same resource', async () => { + let resolveEnv: (value: PythonEnvironment) => void = () => undefined; + const envPromise = new Promise((resolve) => { + resolveEnv = resolve; + }); + getEnvironmentStub.returns(envPromise); + + const promise1 = getActiveInterpreterLegacy(undefined); + const promise2 = getActiveInterpreterLegacy(undefined); + + // Unblock the shared resolution and let both callers settle. + resolveEnv(buildEnv('/usr/bin/python', '3.10.0')); + const [result1, result2] = await Promise.all([promise1, promise2]); + + expect(result1?.path).to.equal('/usr/bin/python'); + expect(result2?.path).to.equal('/usr/bin/python'); + // Both concurrent callers shared a single underlying resolution. + expect(getEnvironmentStub.callCount).to.equal(1); + }); + + test('Issues a fresh resolution once the previous one has completed', async () => { + getEnvironmentStub.resolves(buildEnv('/usr/bin/python', '3.10.0')); + + await getActiveInterpreterLegacy(undefined); + await getActiveInterpreterLegacy(undefined); + + expect(getEnvironmentStub.callCount).to.equal(2); + }); +}); diff --git a/src/test/interpreters/interpreterService.unit.test.ts b/src/test/interpreters/interpreterService.unit.test.ts index 1d521dad8ec8..299909ff96ff 100644 --- a/src/test/interpreters/interpreterService.unit.test.ts +++ b/src/test/interpreters/interpreterService.unit.test.ts @@ -29,7 +29,12 @@ import { IInterpreterAutoSelectionProxyService, } from '../../client/interpreter/autoSelection/types'; import { IPythonPathUpdaterServiceManager } from '../../client/interpreter/configuration/types'; -import { IComponentAdapter, IInterpreterDisplay, IInterpreterHelper } from '../../client/interpreter/contracts'; +import { + IActivatedEnvironmentLaunch, + IComponentAdapter, + IInterpreterDisplay, + IInterpreterHelper, +} from '../../client/interpreter/contracts'; import { InterpreterService } from '../../client/interpreter/interpreterService'; import { ServiceContainer } from '../../client/ioc/container'; import { ServiceManager } from '../../client/ioc/serviceManager'; @@ -64,10 +69,15 @@ suite('Interpreters service', () => { let appShell: TypeMoq.IMock; let reportActiveInterpreterChangedStub: sinon.SinonStub; let useEnvExtensionStub: sinon.SinonStub; + let workspacePersistedValues: Map; setup(() => { useEnvExtensionStub = sinon.stub(extapi, 'useEnvExtension'); useEnvExtensionStub.returns(false); + workspacePersistedValues = new Map(); + // Capture the store in a per-setup local so background resolutions leaked from earlier + // tests write into their own (now-unreferenced) map rather than the current test's map. + const persistedStore = workspacePersistedValues; const cont = new Container(); serviceManager = new ServiceManager(cont); @@ -107,6 +117,20 @@ suite('Interpreters service', () => { }; return state as any; }); + persistentStateFactory + .setup((p) => p.createWorkspacePersistentState(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns((stateKey: string, defaultValue: unknown) => { + const state = { + get value() { + return persistedStore.has(stateKey) ? persistedStore.get(stateKey) : defaultValue; + }, + updateValue: (newValue: unknown) => { + persistedStore.set(stateKey, newValue); + return Promise.resolve(); + }, + }; + return state as any; + }); serviceManager.addSingletonInstance(IExperimentService, experiments.object); serviceManager.addSingletonInstance(IDisposableRegistry, []); @@ -305,4 +329,162 @@ suite('Interpreters service', () => { interpreterDisplay.verifyAll(); expect(reportActiveInterpreterChangedStub.notCalled).to.be.equal(true); }); + + suite('getActiveInterpreter (non-blocking)', () => { + const pythonPath = path.join('usr', 'bin', 'python3'); + let activatedEnvLaunch: TypeMoq.IMock; + + function delayed(value: T, ms: number): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); + } + + setup(() => { + activatedEnvLaunch = createTypeMoq(); + activatedEnvLaunch + .setup((a) => a.selectIfLaunchedViaActivatedEnv(TypeMoq.It.isAny())) + .returns(() => Promise.resolve(pythonPath)); + serviceManager.addSingletonInstance( + IActivatedEnvironmentLaunch, + activatedEnvLaunch.object, + ); + workspace.setup((w) => w.getWorkspaceFolderIdentifier(TypeMoq.It.isAny())).returns(() => 'key'); + }); + + test('Returns the resolved interpreter when resolution is fast', async () => { + const env1 = { path: pythonPath } as any; + pyenvs.setup((p) => p.getInterpreterDetails(TypeMoq.It.isAny())).returns(() => Promise.resolve(env1)); + + const service = new InterpreterService(serviceContainer, pyenvs.object); + const result = await service.getActiveInterpreter(undefined); + + expect(result).to.equal(env1); + }); + + test('Returns the last-known interpreter when resolution is slow', async () => { + const env1 = { path: pythonPath } as any; + const env2 = { path: path.join('usr', 'bin', 'other') } as any; + let count = 0; + pyenvs + .setup((p) => p.getInterpreterDetails(TypeMoq.It.isAny())) + .returns(() => { + count += 1; + return count === 1 ? Promise.resolve(env1) : delayed(env2, 1000); + }); + + const service = new InterpreterService(serviceContainer, pyenvs.object); + // First call resolves quickly and caches the value. + const first = await service.getActiveInterpreter(undefined); + expect(first).to.equal(env1); + + // Second call's resolution is slow, so the cached value is served promptly. + const start = Date.now(); + const second = await service.getActiveInterpreter(undefined); + expect(Date.now() - start).to.be.lessThan(900); + expect(second).to.equal(env1); + }); + + test('Returns undefined on a cold start when resolution times out', async () => { + pyenvs + .setup((p) => p.getInterpreterDetails(TypeMoq.It.isAny())) + .returns(() => delayed({ path: pythonPath } as any, 1000)); + + const service = new InterpreterService(serviceContainer, pyenvs.object); + const start = Date.now(); + const result = await service.getActiveInterpreter(undefined); + + expect(Date.now() - start).to.be.lessThan(900); + expect(result).to.equal(undefined); + }); + + test('Background resolution still populates the cache after a timeout', async () => { + const env1 = { path: pythonPath } as any; + pyenvs.setup((p) => p.getInterpreterDetails(TypeMoq.It.isAny())).returns(() => delayed(env1, 200)); + + const service = new InterpreterService(serviceContainer, pyenvs.object); + // First call times out and returns undefined, but its resolution keeps running. + const first = await service.getActiveInterpreter(undefined); + expect(first).to.equal(undefined); + + // Give the background resolution time to complete and populate the cache. + await delayed(undefined, 400); + + // A subsequent slow call now serves the value resolved in the background. + const second = await service.getActiveInterpreter(undefined); + expect(second).to.equal(env1); + }); + + test('De-duplicates concurrent resolutions for the same workspace', async () => { + const env1 = { path: pythonPath } as any; + pyenvs.setup((p) => p.getInterpreterDetails(TypeMoq.It.isAny())).returns(() => delayed(env1, 50)); + + const service = new InterpreterService(serviceContainer, pyenvs.object); + const [r1, r2] = await Promise.all([ + service.getActiveInterpreter(undefined), + service.getActiveInterpreter(undefined), + ]); + + expect(r1).to.equal(env1); + expect(r2).to.equal(env1); + activatedEnvLaunch.verify( + (a) => a.selectIfLaunchedViaActivatedEnv(TypeMoq.It.isAny()), + TypeMoq.Times.once(), + ); + }); + + test('Persists the resolved interpreter for future sessions', async () => { + const env1 = { path: pythonPath } as any; + pyenvs.setup((p) => p.getInterpreterDetails(TypeMoq.It.isAny())).returns(() => Promise.resolve(env1)); + + const service = new InterpreterService(serviceContainer, pyenvs.object); + const result = await service.getActiveInterpreter(undefined); + expect(result).to.equal(env1); + + // Allow the fire-and-forget persistence write to settle. + await delayed(undefined, 0); + expect(workspacePersistedValues.get('lastKnownActiveInterpreter:key')).to.deep.equal(env1); + }); + + test('Serves the persisted interpreter on a cold start when resolution times out', async () => { + const persisted = { path: path.join('usr', 'bin', 'persisted') } as any; + workspacePersistedValues.set('lastKnownActiveInterpreter:key', persisted); + pyenvs + .setup((p) => p.getInterpreterDetails(TypeMoq.It.isAny())) + .returns(() => delayed({ path: pythonPath } as any, 1000)); + + const service = new InterpreterService(serviceContainer, pyenvs.object); + const start = Date.now(); + const result = await service.getActiveInterpreter(undefined); + + expect(Date.now() - start).to.be.lessThan(900); + expect(result).to.equal(persisted); + }); + }); + + test('A configuration change re-runs the config change handler to report a background selection', async () => { + const service = new InterpreterService(serviceContainer, pyenvs.object); + const documentManager = createTypeMoq(); + documentManager + .setup((d) => d.onDidChangeActiveTextEditor(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns(() => ({ dispose: noop })); + serviceManager.addSingletonInstance(IDocumentManager, documentManager.object); + + const configChangeHandlers: Array<(e: unknown) => unknown> = []; + configService + .setup((c) => c.onDidChange(TypeMoq.It.isAny())) + .returns((cb) => { + configChangeHandlers.push(cb); + return { dispose: noop }; + }); + + const onConfigChangedStub = sinon.stub(service, '_onConfigChanged').resolves(); + + service.initialize(); + + expect(configChangeHandlers.length).to.be.greaterThan(0); + for (const handler of configChangeHandlers) { + // eslint-disable-next-line no-await-in-loop + await handler(undefined); + } + sinon.assert.calledOnce(onConfigChangedStub); + }); }); diff --git a/src/test/pythonEnvironments/common/environmentManagers/conda.unit.test.ts b/src/test/pythonEnvironments/common/environmentManagers/conda.unit.test.ts index 9480dffe6a59..d2f155eb8893 100644 --- a/src/test/pythonEnvironments/common/environmentManagers/conda.unit.test.ts +++ b/src/test/pythonEnvironments/common/environmentManagers/conda.unit.test.ts @@ -209,6 +209,7 @@ suite('Conda and its environments are located correctly', () => { teardown(() => { condaVersionOutput = ''; + Conda.setSkipDeepProbe(false); sinon.restore(); }); @@ -249,6 +250,62 @@ suite('Conda and its environments are located correctly', () => { await expectConda('/bin/conda'); }); + suite('When deep probe is disabled (discovery delegated)', () => { + test('Skips registry/known-path probing and does not locate conda', async () => { + osType = platform.OSType.Linux; + homeDir = '/home/user'; + // conda only exists in a well-known location, not on PATH and not in the setting. + files = { + opt: { + anaconda: { + bin: { + conda: JSON.stringify(condaInfo('4.8.0')), + }, + }, + }, + }; + + Conda.setSkipDeepProbe(true); + + const conda = await Conda.getConda(); + expect(conda).to.equal(undefined, 'conda should not be located via deep probe when disabled'); + }); + + test('Still honors the `python.condaPath` setting', async () => { + getPythonSetting.withArgs('condaPath').returns('condaPath/conda'); + files = { + condaPath: { + conda: JSON.stringify(condaInfo('4.8.0')), + }, + }; + + Conda.setSkipDeepProbe(true); + + await expectConda('/condaPath/conda'); + }); + + test('Still finds conda on PATH', async () => { + osType = platform.OSType.Linux; + execPath = ['/bin']; + files = { + bin: { + conda: JSON.stringify(condaInfo('4.8.0')), + }, + opt: { + anaconda: { + bin: { + conda: JSON.stringify(condaInfo('4.8.1')), + }, + }, + }, + }; + + Conda.setSkipDeepProbe(true); + + await expectConda('/bin/conda'); + }); + }); + test('Use conda.bat when possible over conda.exe on windows', async () => { osType = platform.OSType.Windows; diff --git a/src/test/testing/testController/common/testProjectRegistry.unit.test.ts b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts index 5d04930d0e88..99b1335655c4 100644 --- a/src/test/testing/testController/common/testProjectRegistry.unit.test.ts +++ b/src/test/testing/testController/common/testProjectRegistry.unit.test.ts @@ -238,6 +238,61 @@ suite('TestProjectRegistry', () => { expect(projects).to.have.length(1); expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); }); + + test('should defer a project whose environment is not yet resolved and fall back to default', async () => { + const workspaceUri = Uri.file('/workspace'); + const projectUri = Uri.file('/workspace/project1'); + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [{ name: 'project1', uri: projectUri }], + // Environment not assigned yet (e.g. environments extension still refreshing). + getEnvironment: sandbox.stub().resolves(undefined), + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + // The unresolved project is deferred (not a hard failure), so with no other projects we + // fall back to a single default project rather than throwing. + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(workspaceUri.fsPath); + }); + + test('should keep resolved projects and defer only the ones without an environment', async () => { + const workspaceUri = Uri.file('/workspace'); + const resolvedUri = Uri.file('/workspace/resolved'); + const unresolvedUri = Uri.file('/workspace/unresolved'); + + const mockPythonEnv: PythonEnvironment = { + name: 'env1', + displayName: 'Python 3.11', + shortDisplayName: 'Python 3.11', + displayPath: '/usr/bin/python3', + version: '3.11.8', + environmentPath: Uri.file('/usr/bin/python3'), + sysPrefix: '/usr', + execInfo: { run: { executable: '/usr/bin/python3' } }, + envId: { id: 'env1', managerId: 'manager1' }, + }; + + sandbox.stub(envExtApiInternal, 'useEnvExtension').returns(true); + sandbox.stub(envExtApiInternal, 'getEnvExtApi').resolves({ + getPythonProjects: () => [ + { name: 'resolved', uri: resolvedUri }, + { name: 'unresolved', uri: unresolvedUri }, + ], + getEnvironment: sandbox + .stub() + .callsFake(async (uri: Uri) => (uri.fsPath === resolvedUri.fsPath ? mockPythonEnv : undefined)), + } as any); + + const projects = await registry.discoverAndRegisterProjects(workspaceUri); + + // Only the project with a resolved environment is registered; the other is deferred and + // picked up later via re-discovery, so we do NOT collapse to a default project. + expect(projects).to.have.length(1); + expect(projects[0].projectUri.fsPath).to.equal(resolvedUri.fsPath); + }); }); suite('configureNestedProjectIgnores', () => { diff --git a/src/test/testing/testController/controller.unit.test.ts b/src/test/testing/testController/controller.unit.test.ts index feb5f36fc797..f91592aea332 100644 --- a/src/test/testing/testController/controller.unit.test.ts +++ b/src/test/testing/testController/controller.unit.test.ts @@ -341,4 +341,65 @@ suite('PythonTestController', () => { assert.strictEqual(projects[0].projectUri.toString(), workspaceUri.toString()); }); }); + + suite('environment change self-heal', () => { + const workspaceUri: Uri = vscode.Uri.file('/workspace/root'); + const workspaceFolder = ({ uri: workspaceUri, name: 'root', index: 0 } as unknown) as vscode.WorkspaceFolder; + + function setupController(hasProjects: boolean) { + const controller = createController(); + (controller as any).workspaceService.getWorkspaceFolder = sandbox.stub().returns(workspaceFolder); + (controller as any).workspaceService.workspaceFolders = [workspaceFolder]; + sandbox.stub((controller as any).projectRegistry, 'hasProjects').returns(hasProjects); + const triggerStub = sandbox.stub((controller as any).envChangeRediscoverTrigger, 'trigger'); + const rediscoverStub = sandbox + .stub(controller as any, 'discoverAllProjectsInWorkspace') + .resolves(undefined); + return { controller, triggerStub, rediscoverStub }; + } + + const newEnv = { envId: { id: 'env-1', managerId: 'm' } } as any; + + test('queues the affected workspace and triggers re-discovery when an environment is assigned', () => { + const { controller, triggerStub } = setupController(true); + + (controller as any).handleEnvironmentChange({ uri: workspaceUri, old: undefined, new: newEnv }); + + const pending = (controller as any).pendingEnvChangeWorkspaces as Map; + assert.strictEqual(pending.has(workspaceUri.toString()), true); + assert.strictEqual(triggerStub.calledOnce, true); + }); + + test('ignores changes that clear the environment (no new env)', () => { + const { controller, triggerStub } = setupController(true); + + (controller as any).handleEnvironmentChange({ uri: workspaceUri, old: newEnv, new: undefined }); + + const pending = (controller as any).pendingEnvChangeWorkspaces as Map; + assert.strictEqual(pending.size, 0); + assert.strictEqual(triggerStub.notCalled, true); + }); + + test('ignores workspaces that are not in project-based mode', () => { + const { controller, triggerStub } = setupController(false); + + (controller as any).handleEnvironmentChange({ uri: workspaceUri, old: undefined, new: newEnv }); + + const pending = (controller as any).pendingEnvChangeWorkspaces as Map; + assert.strictEqual(pending.size, 0); + assert.strictEqual(triggerStub.notCalled, true); + }); + + test('re-discovers each pending workspace and clears the queue', async () => { + const { controller, rediscoverStub } = setupController(true); + + const pending = (controller as any).pendingEnvChangeWorkspaces as Map; + pending.set(workspaceUri.toString(), workspaceFolder); + + await (controller as any).rediscoverPendingEnvChangeWorkspaces(); + + assert.strictEqual(rediscoverStub.calledOnceWithExactly(workspaceUri), true); + assert.strictEqual(pending.size, 0); + }); + }); });