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
18 changes: 15 additions & 3 deletions src/client/activation/activationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
Expand Down Expand Up @@ -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);
Expand Down
22 changes: 20 additions & 2 deletions src/client/envExt/api.legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ function toLegacyType(env: PythonEnvironment): PythonEnvironmentLegacy {
}

const previousEnvMap = new Map<string, PythonEnvironment | undefined>();
export async function getActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnvironmentLegacy | undefined> {
const inFlightActiveInterpreter = new Map<string, Promise<PythonEnvironmentLegacy | undefined>>();

async function resolveActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnvironmentLegacy | undefined> {
const api = await getEnvExtApi();
const uri = resource ? api.getPythonProject(resource)?.uri : undefined;

Expand All @@ -117,7 +119,23 @@ export async function getActiveInterpreterLegacy(resource?: Uri): Promise<Python
});
previousEnvMap.set(uri?.fsPath || '', pythonEnv);
}
return pythonEnv ? toLegacyType(pythonEnv) : undefined;
return newEnv;
}

export async function getActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnvironmentLegacy | undefined> {
// 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<void> {
Expand Down
99 changes: 99 additions & 0 deletions src/client/interpreter/interpreterService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
IDisposableRegistry,
IInstaller,
IInterpreterPathService,
IPersistentState,
IPersistentStateFactory,
Product,
} from '../common/types';
import { IServiceContainer } from '../ioc/types';
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string, Promise<StoredPythonEnvironment | undefined>>();

// 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<StoredPythonEnvironment | undefined>
>();

constructor(
@inject(IServiceContainer) private serviceContainer: IServiceContainer,
@inject(IComponentAdapter) private readonly pyenvs: IComponentAdapter,
Expand Down Expand Up @@ -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[] {
Expand All @@ -219,6 +254,70 @@ export class InterpreterService implements Disposable, IInterpreterService {
}

public async getActiveInterpreter(resource?: Uri): Promise<PythonEnvironment | undefined> {
const workspaceService = this.serviceContainer.get<IWorkspaceService>(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<StoredPythonEnvironment | undefined> {
let state = this.persistedActiveInterpreter.get(key);
if (!state) {
const factory = this.serviceContainer.get<IPersistentStateFactory>(IPersistentStateFactory);
state = factory.createWorkspacePersistentState<StoredPythonEnvironment | undefined>(
`${LAST_KNOWN_ACTIVE_INTERPRETER_KEY_PREFIX}${key}`,
undefined,
);
this.persistedActiveInterpreter.set(key, state);
}
return state;
}

private async resolveActiveInterpreter(resource?: Uri): Promise<StoredPythonEnvironment | undefined> {
if (useEnvExtension()) {
return getActiveInterpreterLegacy(resource);
}
Expand Down
27 changes: 27 additions & 0 deletions src/client/pythonEnvironments/common/environmentManagers/conda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,15 @@ export class Conda {
*/
private static condaPromise = new Map<string | undefined, Promise<Conda | undefined>>();

/**
* 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<string | undefined, Promise<CondaInfo> | undefined>();

/**
Expand Down Expand Up @@ -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<string | undefined, Promise<Conda | undefined>>();
}

/**
* Locates the preferred "conda" utility on this system by considering user settings,
* binaries on PATH, Python interpreters in the registry, and known install locations.
Expand All @@ -314,13 +335,19 @@ 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.
yield customCondaPath;
}
// 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();
}
Expand Down
5 changes: 5 additions & 0 deletions src/client/pythonEnvironments/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -61,6 +62,10 @@ export async function initialize(ext: ExtensionState): Promise<IDiscoveryAPI> {
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.
Expand Down
22 changes: 19 additions & 3 deletions src/client/testing/testController/common/testProjectRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -178,16 +180,30 @@ 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<ProjectAdapter> {
private async createProjectAdapter(
pythonProject: PythonProject,
workspaceUri: Uri,
): Promise<ProjectAdapter | undefined> {
const projectId = getProjectId(pythonProject.uri);
traceInfo(`[test-by-project] Creating adapter for: ${pythonProject.name} at ${projectId}`);

// Resolve Python environment
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
Expand Down
Loading
Loading