diff --git a/src/features/envManagers.ts b/src/features/envManagers.ts index 8c3c4d8c..3e005a1e 100644 --- a/src/features/envManagers.ts +++ b/src/features/envManagers.ts @@ -605,6 +605,12 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { } } + getLastKnownEnvironment(scope: GetEnvironmentScope): PythonEnvironment | undefined { + const project = scope ? this.pm.get(scope) : undefined; + const key = project ? project.uri.toString() : 'global'; + return this._activeSelection.get(key); + } + getProjectEnvManagers(uris: Uri[]): InternalEnvironmentManager[] { const projectEnvManagers: InternalEnvironmentManager[] = []; uris.forEach((uri) => { diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index afac13f5..0544c4fd 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -44,6 +44,7 @@ import { PythonPackageImpl, PythonProjectManager, } from '../internal.api'; +import { timeout } from '../common/utils/asyncUtils'; import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './common/managerReady'; import { EnvVarManager } from './execution/envVariableManager'; import { runAsTask } from './execution/runAsTask'; @@ -51,6 +52,12 @@ import { runInBackground } from './execution/runInBackground'; import { runInTerminal } from './terminal/runInTerminal'; import { TerminalManager } from './terminal/terminalManager'; +// Maximum time getEnvironment will block before serving the last-known environment while a +// slow initial resolution/refresh continues in the background. Keeps consumers (e.g. Pylance's +// workspace/configuration handler) from hanging on the full environment enumeration at startup. +const GET_ENVIRONMENT_TIMEOUT_MS = 1000; +const GET_ENVIRONMENT_TIMED_OUT = Symbol('getEnvironmentTimedOut'); + class PythonEnvironmentApiImpl implements PythonEnvironmentApi { private readonly _onDidChangeEnvironments = new EventEmitter(); private readonly _onDidChangeEnvironment = new EventEmitter(); @@ -216,8 +223,29 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi { } async getEnvironment(scope: GetEnvironmentScope): Promise { const currentScope = checkUri(scope) as GetEnvironmentScope; - await waitForEnvManager(currentScope ? [currentScope] : undefined); - return this.envManagers.getEnvironment(currentScope); + + // Don't block callers (notably Pylance's workspace/configuration handler) on a potentially + // slow initial environment resolution/refresh. Race the real resolution against a short + // timeout; if it doesn't complete promptly, return the last-known environment for the scope. + // The resolution continues in the background and consumers are notified of the real value + // via onDidChangeEnvironment once it settles. + const resolution = (async () => { + await waitForEnvManager(currentScope ? [currentScope] : undefined); + return this.envManagers.getEnvironment(currentScope); + })(); + + const result = await Promise.race([ + resolution, + timeout(GET_ENVIRONMENT_TIMEOUT_MS).then(() => GET_ENVIRONMENT_TIMED_OUT), + ]); + if (result !== GET_ENVIRONMENT_TIMED_OUT) { + return result as PythonEnvironment | undefined; + } + + // Keep the background resolution alive so the cache/last-known value gets populated and the + // change event fires once it finishes. + resolution.catch((ex) => traceError('Failed to resolve environment in background', ex)); + return this.envManagers.getLastKnownEnvironment(currentScope); } onDidChangeEnvironment: Event = this._onDidChangeEnvironment.event; async resolveEnvironment(context: ResolveEnvironmentContext): Promise { diff --git a/src/internal.api.ts b/src/internal.api.ts index e2f010cb..04a198ac 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -140,6 +140,13 @@ export interface EnvironmentManagers extends Disposable { getEnvironment(scope: GetEnvironmentScope): Promise; refreshEnvironment(scope: GetEnvironmentScope): Promise; + /** + * Synchronously returns the last-known environment for a scope without triggering a refresh. + * Used to serve a value promptly while a slow initial environment resolution runs in the + * background. Returns undefined if no environment has been resolved for the scope yet. + */ + getLastKnownEnvironment(scope: GetEnvironmentScope): PythonEnvironment | undefined; + getProjectEnvManagers(uris: Uri[]): InternalEnvironmentManager[]; } diff --git a/src/test/features/envManagers.lastKnown.unit.test.ts b/src/test/features/envManagers.lastKnown.unit.test.ts new file mode 100644 index 00000000..eb44755b --- /dev/null +++ b/src/test/features/envManagers.lastKnown.unit.test.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +/** + * Tests for PythonEnvironmentManagers.getLastKnownEnvironment, the synchronous accessor that + * lets the public getEnvironment API serve a value promptly while a slow initial resolution + * runs in the background (avoids blocking consumers such as Pylance's configuration handler). + */ + +import { Extension } from 'vscode'; + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import * as typeMoq from 'typemoq'; +import { EventEmitter, Uri } from 'vscode'; +import { + DidChangeEnvironmentEventArgs, + DidChangeEnvironmentsEventArgs, + EnvironmentManager, + GetEnvironmentScope, + PythonEnvironment, + PythonEnvironmentId, +} from '../../api'; +import * as extensionApis from '../../common/extension.apis'; +import { PythonEnvironmentManagers } from '../../features/envManagers'; +import * as settingHelpers from '../../features/settings/settingHelpers'; +import { PythonProjectManager } from '../../internal.api'; +import { setupNonThenable } from '../mocks/helper'; + +suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { + let envManagers: PythonEnvironmentManagers; + let projectManager: typeMoq.IMock; + + function makeEnv(id: string): PythonEnvironment { + const envId: PythonEnvironmentId = { id, managerId: 'test-manager' }; + return { + envId, + name: id, + displayName: id, + displayPath: `/path/${id}`, + version: '3.11.0', + environmentPath: Uri.file(`/path/${id}`), + execInfo: { run: { executable: `/path/${id}/python`, args: [] } }, + sysPrefix: `/path/${id}`, + } as PythonEnvironment; + } + + setup(() => { + const mockPythonExtension = { id: 'ms-python.python', extensionPath: '/mock/python/extension' }; + const mockEnvsExtension = { id: 'ms-python.vscode-python-envs', extensionPath: '/mock/envs/extension' }; + + const getExtensionStub = sinon.stub(extensionApis, 'getExtension'); + getExtensionStub.withArgs('ms-python.python').returns(mockPythonExtension as Extension); + getExtensionStub.withArgs('ms-python.vscode-python-envs').returns(mockEnvsExtension as Extension); + sinon + .stub(extensionApis, 'allExtensions') + .returns([mockPythonExtension, mockEnvsExtension] as Extension[]); + + projectManager = typeMoq.Mock.ofType(); + setupNonThenable(projectManager); + // No project for a scope -> refreshEnvironment/getLastKnownEnvironment use the 'global' key. + projectManager.setup((pm) => pm.get(typeMoq.It.isAny())).returns(() => undefined); + + envManagers = new PythonEnvironmentManagers(projectManager.object); + }); + + teardown(() => { + sinon.restore(); + envManagers.dispose(); + }); + + function registerManager(getImpl: (scope: GetEnvironmentScope) => Promise): string { + const onDidChangeEnvironment = new EventEmitter(); + const onDidChangeEnvironments = new EventEmitter(); + const manager = { + name: 'test-env-mgr', + displayName: 'Test Env Manager', + preferredPackageManagerId: 'ms-python.python:pip', + onDidChangeEnvironment: onDidChangeEnvironment.event, + onDidChangeEnvironments: onDidChangeEnvironments.event, + get: getImpl, + getEnvironments: async () => [], + set: async () => undefined, + resolve: async () => undefined, + refresh: async () => undefined, + } as unknown as EnvironmentManager; + + envManagers.registerEnvironmentManager(manager); + const id = envManagers.managers[0].id; + // Force the default environment manager (used for undefined/global scope) to resolve to ours. + sinon.stub(settingHelpers, 'getDefaultEnvManagerSetting').returns(id); + return id; + } + + test('returns undefined before any environment has been resolved', () => { + registerManager(async () => makeEnv('env1')); + assert.strictEqual(envManagers.getLastKnownEnvironment(undefined), undefined); + }); + + test('returns the active environment after it has been resolved', async () => { + const env = makeEnv('env1'); + registerManager(async () => env); + + // Refreshing the active selection populates the last-known cache. + await envManagers.refreshEnvironment(undefined); + + // Now available synchronously without any await or refresh. + assert.strictEqual(envManagers.getLastKnownEnvironment(undefined), env); + }); + + test('reflects the most recent environment after it changes', async () => { + let current = makeEnv('env1'); + registerManager(async () => current); + + await envManagers.refreshEnvironment(undefined); + assert.strictEqual(envManagers.getLastKnownEnvironment(undefined)?.envId.id, 'env1'); + + current = makeEnv('env2'); + await envManagers.refreshEnvironment(undefined); + assert.strictEqual(envManagers.getLastKnownEnvironment(undefined)?.envId.id, 'env2'); + }); +});