From efa1ba5b58825c7bd22b7cde6a6680ed88b735c6 Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Mon, 22 Jun 2026 15:08:47 -0700 Subject: [PATCH 1/2] Return last-known environment when getEnvironment times out Bound EnvironmentManagers.getEnvironment with a timeout so it returns the last-known environment instead of blocking on a full environment refresh. Adds a getLastKnownEnvironment accessor plus unit tests. This keeps Pylance config fetches responsive at startup; the background resolution still completes and fires onDidChangeEnvironment to self-correct. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/features/envManagers.ts | 6 + src/features/pythonApi.ts | 34 ++++- src/internal.api.ts | 7 + .../envManagers.lastKnown.unit.test.ts | 123 ++++++++++++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/test/features/envManagers.lastKnown.unit.test.ts diff --git a/src/features/envManagers.ts b/src/features/envManagers.ts index b78e8bca..afc18fbc 100644 --- a/src/features/envManagers.ts +++ b/src/features/envManagers.ts @@ -475,6 +475,12 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { return newEnv; } + getLastKnownEnvironment(scope: GetEnvironmentScope): PythonEnvironment | undefined { + const project = scope ? this.pm.get(scope) : undefined; + const key = project ? project.uri.toString() : 'global'; + return this._previousEnvironments.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 cf1e0485..f40b9069 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -39,7 +39,8 @@ import { PythonProjectManager, } from '../internal.api'; import { createDeferred } from '../common/utils/deferred'; -import { traceInfo } from '../common/logging'; +import { traceInfo, traceError } from '../common/logging'; +import { timeout } from '../common/utils/asyncUtils'; import { pickEnvironmentManager } from '../common/pickers/managers'; import { handlePythonPath } from '../common/utils/pythonPath'; import { TerminalManager } from './terminal/terminalManager'; @@ -50,6 +51,12 @@ import { EnvVarManager } from './execution/envVariableManager'; import { checkUri } from '../common/utils/pathUtils'; import { waitForAllEnvManagers, waitForEnvManager, waitForEnvManagerId } from './common/managerReady'; +// 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(); @@ -215,8 +222,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 ce258e31..c7f8300e 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -114,6 +114,13 @@ export interface EnvironmentManagers extends Disposable { setEnvironmentsIfUnset(scope: Uri[] | string, environment?: PythonEnvironment): Promise; getEnvironment(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..81f4e7ca --- /dev/null +++ b/src/test/features/envManagers.lastKnown.unit.test.ts @@ -0,0 +1,123 @@ +// 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). + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +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 -> getEnvironment/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 environment resolved by a prior getEnvironment call', async () => { + const env = makeEnv('env1'); + registerManager(async () => env); + + const resolved = await envManagers.getEnvironment(undefined); + assert.strictEqual(resolved, env); + + // 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.getEnvironment(undefined); + assert.strictEqual(envManagers.getLastKnownEnvironment(undefined)?.envId.id, 'env1'); + + current = makeEnv('env2'); + await envManagers.getEnvironment(undefined); + assert.strictEqual(envManagers.getLastKnownEnvironment(undefined)?.envId.id, 'env2'); + }); +}); From 68b56632a1c653dcd839dd8c8a888026ae3a3466 Mon Sep 17 00:00:00 2001 From: Rich Chiodo false Date: Wed, 24 Jun 2026 10:27:44 -0700 Subject: [PATCH 2/2] Remove unused eslint-disable directive in test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/test/features/envManagers.lastKnown.unit.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/features/envManagers.lastKnown.unit.test.ts b/src/test/features/envManagers.lastKnown.unit.test.ts index 81f4e7ca..a658e415 100644 --- a/src/test/features/envManagers.lastKnown.unit.test.ts +++ b/src/test/features/envManagers.lastKnown.unit.test.ts @@ -7,7 +7,6 @@ * runs in the background (avoids blocking consumers such as Pylance's configuration handler). */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { Extension } from 'vscode'; import * as assert from 'assert';