Skip to content

Commit b7aeb02

Browse files
rchiodoCopilot
andcommitted
Fast Pylance startup: don't block activation on environment enumeration
Make interpreter resolution non-blocking so Pylance can start immediately, even during a full environment refresh: - interpreterService.getActiveInterpreter races resolution against a 100ms timeout, serving the last-known/persisted interpreter when discovery is slow (the real value still resolves and updates listeners). - activationManager no longer awaits auto-select interpreter; it races a short timeout. - pythonEnvironments/index sets Conda.setSkipDeepProbe(true) when the environments extension is active, and conda.ts honors it to avoid slow deep probes. - envExt/api.legacy and interpreterService de-duplicate in-flight resolutions. - testController: defer project discovery (return undefined and self-heal on environment change) instead of throwing 'No Python environment found' when an env isn't assigned yet during fast startup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3434815 commit b7aeb02

13 files changed

Lines changed: 772 additions & 10 deletions

File tree

src/client/activation/activationManager.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@ import { IActiveResourceService, IDocumentManager, IWorkspaceService } from '../
1010
import { PYTHON_LANGUAGE } from '../common/constants';
1111
import { IFileSystem } from '../common/platform/types';
1212
import { IDisposable, IInterpreterPathService, Resource } from '../common/types';
13-
import { Deferred } from '../common/utils/async';
13+
import { Deferred, sleep } from '../common/utils/async';
1414
import { StopWatch } from '../common/utils/stopWatch';
1515
import { IInterpreterAutoSelectionService } from '../interpreter/autoSelection/types';
16-
import { traceDecoratorError } from '../logging';
16+
import { traceDecoratorError, traceError } from '../logging';
1717
import { sendActivationTelemetry } from '../telemetry/envFileTelemetry';
1818
import { IExtensionActivationManager, IExtensionActivationService, IExtensionSingleActivationService } from './types';
1919

20+
// Upper bound on how long workspace activation waits for interpreter auto-selection before
21+
// proceeding. Auto-selection still completes in the background after this point.
22+
const AUTO_SELECT_INTERPRETER_TIMEOUT_MS = 100;
23+
2024
@injectable()
2125
export class ExtensionActivationManager implements IExtensionActivationManager {
2226
public readonly activatedWorkspaces = new Set<string>();
@@ -94,7 +98,15 @@ export class ExtensionActivationManager implements IExtensionActivationManager {
9498

9599
if (this.workspaceService.isTrusted) {
96100
// Do not interact with interpreters in a untrusted workspace.
97-
await this.autoSelection.autoSelectInterpreter(resource);
101+
// Don't block activation (and therefore language server startup) on auto-selection.
102+
// On a cold start it can wait for a full environment refresh to complete, which would
103+
// delay starting the language server. Let it finish in the background; the selected
104+
// interpreter is reported to listeners (e.g. Pylance) via the environments API once it
105+
// is ready. We still wait briefly so the common warm-start case is unchanged.
106+
const autoSelection = this.autoSelection
107+
.autoSelectInterpreter(resource)
108+
.catch((ex) => traceError('Auto-selection of interpreter failed', ex));
109+
await Promise.race([autoSelection, sleep(AUTO_SELECT_INTERPRETER_TIMEOUT_MS)]);
98110
await this.interpreterPathService.copyOldInterpreterStorageValuesToNew(resource);
99111
}
100112
await sendActivationTelemetry(this.fileSystem, this.workspaceService, resource);

src/client/envExt/api.legacy.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ function toLegacyType(env: PythonEnvironment): PythonEnvironmentLegacy {
9999
}
100100

101101
const previousEnvMap = new Map<string, PythonEnvironment | undefined>();
102-
export async function getActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnvironmentLegacy | undefined> {
102+
const inFlightActiveInterpreter = new Map<string, Promise<PythonEnvironmentLegacy | undefined>>();
103+
104+
async function resolveActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnvironmentLegacy | undefined> {
103105
const api = await getEnvExtApi();
104106
const uri = resource ? api.getPythonProject(resource)?.uri : undefined;
105107

@@ -117,7 +119,23 @@ export async function getActiveInterpreterLegacy(resource?: Uri): Promise<Python
117119
});
118120
previousEnvMap.set(uri?.fsPath || '', pythonEnv);
119121
}
120-
return pythonEnv ? toLegacyType(pythonEnv) : undefined;
122+
return newEnv;
123+
}
124+
125+
export async function getActiveInterpreterLegacy(resource?: Uri): Promise<PythonEnvironmentLegacy | undefined> {
126+
// De-duplicate concurrent resolutions for the same resource. The underlying
127+
// `getEnvironment` call can block while the environments extension is performing a
128+
// refresh, so multiple startup callers (e.g. the language server watcher and the
129+
// configuration middleware) would otherwise each spawn their own blocking request.
130+
const key = resource?.fsPath ?? '';
131+
let inFlight = inFlightActiveInterpreter.get(key);
132+
if (!inFlight) {
133+
inFlight = resolveActiveInterpreterLegacy(resource).finally(() => {
134+
inFlightActiveInterpreter.delete(key);
135+
});
136+
inFlightActiveInterpreter.set(key, inFlight);
137+
}
138+
return inFlight;
121139
}
122140

123141
export async function setInterpreterLegacy(pythonPath: string, uri: Uri | undefined): Promise<void> {

src/client/interpreter/interpreterService.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
IDisposableRegistry,
1919
IInstaller,
2020
IInterpreterPathService,
21+
IPersistentState,
22+
IPersistentStateFactory,
2123
Product,
2224
} from '../common/types';
2325
import { IServiceContainer } from '../ioc/types';
@@ -49,6 +51,15 @@ import { getActiveInterpreterLegacy } from '../envExt/api.legacy';
4951

5052
type StoredPythonEnvironment = PythonEnvironment & { store?: boolean };
5153

54+
// Upper bound on how long `getActiveInterpreter` may block its caller. Resolving the active
55+
// interpreter can wait on a full environment refresh (especially with the environments
56+
// extension enabled), which would otherwise delay language server startup.
57+
const GET_ACTIVE_INTERPRETER_TIMEOUT_MS = 100;
58+
const ACTIVE_INTERPRETER_TIMED_OUT = Symbol('activeInterpreterTimedOut');
59+
60+
// Key prefix for the persisted (cross-restart) last-known active interpreter, one entry per workspace.
61+
const LAST_KNOWN_ACTIVE_INTERPRETER_KEY_PREFIX = 'lastKnownActiveInterpreter:';
62+
5263
@injectable()
5364
export class InterpreterService implements Disposable, IInterpreterService {
5465
public async hasInterpreters(
@@ -100,6 +111,23 @@ export class InterpreterService implements Disposable, IInterpreterService {
100111
{ path: string; workspaceFolder: WorkspaceFolder | undefined }
101112
>();
102113

114+
// Last successfully resolved active interpreter per workspace, served when a resolution
115+
// doesn't complete within `GET_ACTIVE_INTERPRETER_TIMEOUT_MS`.
116+
private readonly lastKnownActiveInterpreter = new Map<
117+
string,
118+
{ resource: Uri | undefined; env: StoredPythonEnvironment | undefined }
119+
>();
120+
121+
// In-flight active interpreter resolutions, de-duplicated per workspace.
122+
private readonly inFlightActiveInterpreter = new Map<string, Promise<StoredPythonEnvironment | undefined>>();
123+
124+
// Persisted (cross-restart) last-known active interpreter, one persistent-state handle per workspace.
125+
// Lets a warm start serve a real interpreter within the timeout budget before discovery completes.
126+
private readonly persistedActiveInterpreter = new Map<
127+
string,
128+
IPersistentState<StoredPythonEnvironment | undefined>
129+
>();
130+
103131
constructor(
104132
@inject(IServiceContainer) private serviceContainer: IServiceContainer,
105133
@inject(IComponentAdapter) private readonly pyenvs: IComponentAdapter,
@@ -198,6 +226,13 @@ export class InterpreterService implements Disposable, IInterpreterService {
198226
}),
199227
);
200228
disposables.push(this.interpreterPathService.onDidChange((i) => this._onConfigChanged(i.uri)));
229+
// Auto-selection completes in the background (it is no longer awaited before activation),
230+
// which updates the python path setting once an interpreter is chosen. Re-run the config
231+
// change handler so the newly selected interpreter is reported to listeners (e.g. Pylance).
232+
// `_onConfigChanged` is gated on the python path actually changing, and
233+
// `reportActiveInterpreterChanged` de-duplicates by path, so this is a no-op when nothing
234+
// changed. The environments-extension path reports its own changes via the legacy API.
235+
disposables.push(this.configService.onDidChange(() => this._onConfigChanged()));
201236
}
202237

203238
public getInterpreters(resource?: Uri): PythonEnvironment[] {
@@ -219,6 +254,72 @@ export class InterpreterService implements Disposable, IInterpreterService {
219254
}
220255

221256
public async getActiveInterpreter(resource?: Uri): Promise<PythonEnvironment | undefined> {
257+
const workspaceService = this.serviceContainer.get<IWorkspaceService>(IWorkspaceService);
258+
const key = workspaceService.getWorkspaceFolderIdentifier(resource);
259+
260+
// De-duplicate concurrent resolutions for the same workspace.
261+
let resolution = this.inFlightActiveInterpreter.get(key);
262+
if (!resolution) {
263+
resolution = this.resolveActiveInterpreter(resource)
264+
.then((env) => {
265+
this.lastKnownActiveInterpreter.set(key, { resource, env });
266+
if (env) {
267+
// Persist the resolved interpreter so a future window/session can serve a
268+
// real value within the timeout budget before discovery/refresh completes.
269+
this.getPersistedActiveInterpreterState(key)
270+
.updateValue(env)
271+
.catch((ex) => traceError('Failed to persist active interpreter', ex));
272+
}
273+
return env;
274+
})
275+
.catch((ex) => {
276+
traceError('Failed to get active interpreter', ex);
277+
return undefined;
278+
})
279+
.finally(() => {
280+
this.inFlightActiveInterpreter.delete(key);
281+
});
282+
this.inFlightActiveInterpreter.set(key, resolution);
283+
}
284+
285+
// Don't block callers (notably language server startup) on a potentially slow
286+
// environment discovery/refresh. If resolution doesn't complete promptly, return the
287+
// last-known interpreter (or undefined on a cold start). The resolution promise is not
288+
// abandoned, so listeners are still notified of the real value once it becomes available:
289+
// the env-extension path reports via the environments API (see getActiveInterpreterLegacy),
290+
// and the native path reports via `_onConfigChanged` once auto-selection completes.
291+
const result = await Promise.race([
292+
resolution,
293+
sleep(GET_ACTIVE_INTERPRETER_TIMEOUT_MS).then(() => ACTIVE_INTERPRETER_TIMED_OUT),
294+
]);
295+
if (result !== ACTIVE_INTERPRETER_TIMED_OUT) {
296+
return result as StoredPythonEnvironment | undefined;
297+
}
298+
// Prefer the value resolved earlier this session; otherwise fall back to the persisted
299+
// value from a previous session so warm restarts still serve a real interpreter quickly.
300+
const cached = this.lastKnownActiveInterpreter.get(key);
301+
if (cached) {
302+
return cached.env;
303+
}
304+
return this.getPersistedActiveInterpreterState(key).value;
305+
}
306+
307+
private getPersistedActiveInterpreterState(
308+
key: string,
309+
): IPersistentState<StoredPythonEnvironment | undefined> {
310+
let state = this.persistedActiveInterpreter.get(key);
311+
if (!state) {
312+
const factory = this.serviceContainer.get<IPersistentStateFactory>(IPersistentStateFactory);
313+
state = factory.createWorkspacePersistentState<StoredPythonEnvironment | undefined>(
314+
`${LAST_KNOWN_ACTIVE_INTERPRETER_KEY_PREFIX}${key}`,
315+
undefined,
316+
);
317+
this.persistedActiveInterpreter.set(key, state);
318+
}
319+
return state;
320+
}
321+
322+
private async resolveActiveInterpreter(resource?: Uri): Promise<StoredPythonEnvironment | undefined> {
222323
if (useEnvExtension()) {
223324
return getActiveInterpreterLegacy(resource);
224325
}

src/client/pythonEnvironments/common/environmentManagers/conda.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,15 @@ export class Conda {
257257
*/
258258
private static condaPromise = new Map<string | undefined, Promise<Conda | undefined>>();
259259

260+
/**
261+
* When another component (e.g. the Python Environments extension via pet) owns
262+
* environment discovery, the legacy registry/known-path probing performed by
263+
* locate() is redundant and was a significant startup cost (sequential
264+
* `conda info --json` probes and reg.exe spawns). When set, locate() only honors
265+
* the explicit `python.condaPath` setting and `conda` on PATH.
266+
*/
267+
private static skipDeepProbe = false;
268+
260269
private condaInfoCached = new Map<string | undefined, Promise<CondaInfo> | undefined>();
261270

262271
/**
@@ -296,6 +305,18 @@ export class Conda {
296305
Conda.condaPromise.set(undefined, Promise.resolve(new Conda(condaPath)));
297306
}
298307

308+
/**
309+
* Restrict {@link locate} to the explicit `python.condaPath` setting and `conda` on
310+
* PATH, skipping the expensive registry/known-path filesystem probing. Used when
311+
* environment discovery is delegated to another component (e.g. the Python
312+
* Environments extension), which is the source of truth for locating conda.
313+
*/
314+
public static setSkipDeepProbe(value: boolean): void {
315+
Conda.skipDeepProbe = value;
316+
// Drop any cached resolution so the new probing policy takes effect.
317+
Conda.condaPromise = new Map<string | undefined, Promise<Conda | undefined>>();
318+
}
319+
299320
/**
300321
* Locates the preferred "conda" utility on this system by considering user settings,
301322
* binaries on PATH, Python interpreters in the registry, and known install locations.
@@ -314,13 +335,19 @@ export class Conda {
314335
const suffix = getOSType() === OSType.Windows ? 'Scripts\\conda.exe' : 'bin/conda';
315336

316337
// Produce a list of candidate binaries to be probed by exec'ing them.
338+
const skipDeepProbe = Conda.skipDeepProbe;
317339
async function* getCandidates() {
318340
if (customCondaPath && customCondaPath !== 'conda') {
319341
// If user has specified a custom conda path, use it first.
320342
yield customCondaPath;
321343
}
322344
// Check unqualified filename first, in case it's on PATH.
323345
yield 'conda';
346+
if (skipDeepProbe) {
347+
// Discovery is delegated to another component (e.g. the Python
348+
// Environments extension); skip the costly registry/known-path probing.
349+
return;
350+
}
324351
if (getOSType() === OSType.Windows) {
325352
yield* getCandidatesFromRegistry();
326353
}

src/client/pythonEnvironments/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { getNativePythonFinder } from './base/locators/common/nativePythonFinder
4545
import { createNativeEnvironmentsApi } from './nativeAPI';
4646
import { useEnvExtension } from '../envExt/api.internal';
4747
import { createEnvExtApi } from '../envExt/envExtApi';
48+
import { Conda } from './common/environmentManagers/conda';
4849

4950
const PYTHON_ENV_INFO_CACHE_KEY = 'PYTHON_ENV_INFO_CACHEv2';
5051

@@ -61,6 +62,10 @@ export async function initialize(ext: ExtensionState): Promise<IDiscoveryAPI> {
6162
initializeLegacyExternalDependencies(ext.legacyIOC.serviceContainer);
6263

6364
if (useEnvExtension()) {
65+
// The Python Environments extension (via pet) owns environment discovery,
66+
// including locating conda. Skip the legacy registry/known-path conda probing,
67+
// which is redundant here and was a significant startup cost.
68+
Conda.setSkipDeepProbe(true);
6469
const api = await createEnvExtApi(ext.disposables);
6570
registerNewDiscoveryForIOC(
6671
// These are what get wrapped in the legacy adapter.

src/client/testing/testController/common/testProjectRegistry.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,9 @@ export class TestProjectRegistry {
152152
for (const pythonProject of workspaceProjects) {
153153
try {
154154
const adapter = await this.createProjectAdapter(pythonProject, workspaceUri);
155-
adapters.push(adapter);
155+
if (adapter) {
156+
adapters.push(adapter);
157+
}
156158
} catch (error) {
157159
traceError(`[test-by-project] Failed to create adapter for ${pythonProject.uri.fsPath}:`, error);
158160
}
@@ -178,16 +180,30 @@ export class TestProjectRegistry {
178180
* - **DiscoveryAdapter:** Discovers tests scoped to this project's root directory
179181
* - **ExecutionAdapter:** Runs tests for this project using its Python environment
180182
*
183+
* Returns `undefined` when the Python Environments extension has not yet assigned an
184+
* environment to the project. This is expected during startup: extension activation no longer
185+
* waits for the environments extension's initial refresh to complete, so discovery can run
186+
* before environments are resolved. The project is re-discovered once an environment is
187+
* assigned (see the controller's environment-change subscription).
181188
*/
182-
private async createProjectAdapter(pythonProject: PythonProject, workspaceUri: Uri): Promise<ProjectAdapter> {
189+
private async createProjectAdapter(
190+
pythonProject: PythonProject,
191+
workspaceUri: Uri,
192+
): Promise<ProjectAdapter | undefined> {
183193
const projectId = getProjectId(pythonProject.uri);
184194
traceInfo(`[test-by-project] Creating adapter for: ${pythonProject.name} at ${projectId}`);
185195

186196
// Resolve Python environment
187197
const envExtApi = await getEnvExtApi();
188198
const pythonEnvironment = await envExtApi.getEnvironment(pythonProject.uri);
189199
if (!pythonEnvironment) {
190-
throw new Error(`No Python environment found for project ${projectId}`);
200+
// Not an error: the environments extension may not have assigned an environment to
201+
// this project yet. Defer it; the controller re-discovers the workspace when an
202+
// environment is later assigned (onDidChangeEnvironment).
203+
traceInfo(
204+
`[test-by-project] No Python environment resolved yet for project ${projectId}; deferring until one is assigned`,
205+
);
206+
return undefined;
191207
}
192208

193209
// Create test infrastructure

0 commit comments

Comments
 (0)