Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions src/features/envManagers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
32 changes: 30 additions & 2 deletions src/features/pythonApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,20 @@ 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';
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<DidChangeEnvironmentsEventArgs>();
private readonly _onDidChangeEnvironment = new EventEmitter<DidChangeEnvironmentEventArgs>();
Expand Down Expand Up @@ -216,8 +223,29 @@ class PythonEnvironmentApiImpl implements PythonEnvironmentApi {
}
async getEnvironment(scope: GetEnvironmentScope): Promise<PythonEnvironment | undefined> {
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<DidChangeEnvironmentEventArgs> = this._onDidChangeEnvironment.event;
async resolveEnvironment(context: ResolveEnvironmentContext): Promise<PythonEnvironment | undefined> {
Expand Down
7 changes: 7 additions & 0 deletions src/internal.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ export interface EnvironmentManagers extends Disposable {
getEnvironment(scope: GetEnvironmentScope): Promise<PythonEnvironment | undefined>;
refreshEnvironment(scope: GetEnvironmentScope): Promise<void>;

/**
* 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[];
}

Expand Down
122 changes: 122 additions & 0 deletions src/test/features/envManagers.lastKnown.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<PythonProjectManager>;

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<unknown>);
getExtensionStub.withArgs('ms-python.vscode-python-envs').returns(mockEnvsExtension as Extension<unknown>);
sinon
.stub(extensionApis, 'allExtensions')
.returns([mockPythonExtension, mockEnvsExtension] as Extension<unknown>[]);

projectManager = typeMoq.Mock.ofType<PythonProjectManager>();
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<PythonEnvironment | undefined>): string {
const onDidChangeEnvironment = new EventEmitter<DidChangeEnvironmentEventArgs>();
const onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
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');
});
});
Loading