Skip to content

Commit 4e46444

Browse files
authored
feat: implement fast path resolution for Python environment managers (microsoft#1408)
- Added fast path resolution logic in SysPythonManager, VenvManager, CondaEnvManager, PipenvManager, and PyEnvManager to directly resolve persisted environments without waiting for full initialization. - Enhanced error handling with logging for failed resolutions in the fast path. - Introduced unit tests to validate the fast path behavior across different environment managers, ensuring correct resolution and background initialization handling.
1 parent 7fb8f8b commit 4e46444

File tree

9 files changed

+847
-39
lines changed

9 files changed

+847
-39
lines changed

docs/startup-flow.md

Lines changed: 69 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,22 @@
55
user opens VS Code
66
python environments extension begins activation
77

8-
SYNC (`activate` in extension.ts):
9-
1. create core objects: ProjectManager, EnvironmentManagers, ManagerReady
10-
2. `setPythonApi()` — API object created, deferred resolved (API is now available to consumers)
11-
3. create views (EnvManagerView, ProjectView), status bar, terminal manager
12-
4. register all commands
13-
5. activate() returns — extension is "active" from VS Code's perspective
8+
**SYNC (`activate` in extension.ts):**
9+
1. create StatusBar, ProjectManager, EnvVarManager, EnvironmentManagers, ManagerReady
10+
2. create TerminalActivation, shell providers, TerminalManager
11+
3. create ProjectCreators
12+
4. `setPythonApi()` — API object created, deferred resolved (API is now available to consumers)
13+
5. create views (EnvManagerView, ProjectView)
14+
6. register all commands
15+
7. activate() returns — extension is "active" from VS Code's perspective
1416

1517
📊 TELEMETRY: EXTENSION.ACTIVATION_DURATION { duration }
1618

17-
ASYNC (setImmediate callback, still in extension.ts):
19+
**ASYNC (setImmediate callback, still in extension.ts):**
1820
1. spawn PET process (`createNativePythonFinder`)
1921
1. sets up a JSON-RPC connection to it over stdin/stdout
20-
2. register all built-in managers in parallel (Promise.all):
22+
2. register all built-in managers + shell env init in parallel (Promise.all):
23+
- `shellStartupVarsMgr.initialize()`
2124
- for each manager (system, conda, pyenv, pipenv, poetry):
2225
1. check if tool exists (e.g. `getConda(nativeFinder)` asks PET for the conda binary)
2326
2. if tool not found → log, return early (manager not registered)
@@ -26,17 +29,19 @@ ASYNC (setImmediate callback, still in extension.ts):
2629
- fires `onDidChangeEnvironmentManager``ManagerReady` deferred resolves for this manager
2730
3. all registrations complete (Promise.all resolves)
2831

29-
--- gate point: `applyInitialEnvironmentSelection` ---
32+
**--- gate point: `applyInitialEnvironmentSelection` ---**
33+
3034
📊 TELEMETRY: ENV_SELECTION.STARTED { duration (activation→here), registeredManagerCount, registeredManagerIds, workspaceFolderCount }
3135

3236
1. for each workspace folder + global scope (no workspace case), run `resolvePriorityChainCore` to find manager:
3337
- P1: pythonProjects[] setting → specific manager for this project
3438
- P2: user-configured defaultEnvManager setting
3539
- P3: user-configured python.defaultInterpreterPath → nativeFinder.resolve(path)
36-
- P4: auto-discovery → try venv manager (local .venv), fall back to system python
37-
- for workspace scope: ask venv manager if there's a local env (.venv/venv in the folder)
38-
- if found → use venv manager with that env
39-
- if not found → fall back to system python manager
40+
- P4: auto-discovery → try venv manager, fall back to system python
41+
- for workspace scope: call `venvManager.get(scope)`
42+
- if venv found (local .venv/venv) → use venv manager with that env
43+
- if no local venv → venv manager may still return its `globalEnv` (system Python)
44+
- if venvManager.get returns undefined → fall back to system python manager
4045
- for global scope: use system python manager directly
4146

4247
2. get the environment from the winning priority level:
@@ -54,30 +59,55 @@ ASYNC (setImmediate callback, still in extension.ts):
5459

5560
managerDiscovery — P1, P2, or P4 won (manager → interpreter):
5661
`resolvePriorityChainCore` returns { manager, environment: undefined }
57-
→ result.environment is undefined → falls through to `await result.manager.get(scope)`
58-
`manager.get(scope)` (e.g. `CondaEnvManager.get()`):
59-
4. `initialize()` — lazy, once-only per manager (guarded by deferred)
60-
a. `nativeFinder.refresh(hardRefresh=false)`:
61-
`handleSoftRefresh()` checks in-memory cache (Map) for key 'all' (bc one big scan, shared cache, all managers benefit)
62-
- on reload: cache is empty (Map was destroyed) → cache miss
63-
- falls through to `handleHardRefresh()`
64-
`handleHardRefresh()`:
65-
- adds request to WorkerPool queue (concurrency 1, so serialized)
66-
- when its turn comes, calls `doRefresh()`:
67-
1. `configure()` — JSON-RPC to PET with search paths, conda/poetry/pipenv paths, cache dir
68-
2. `refresh` — JSON-RPC to PET, PET scans filesystem
69-
- PET may use its own on-disk cache (cacheDirectory) to speed this up
70-
- PET streams back results as 'environment' and 'manager' notifications
71-
- envs missing version/prefix get an inline resolve() call
72-
3. returns NativeInfo[] (all envs of all types)
73-
- result stored in in-memory cache under key 'all'
74-
→ subsequent managers calling nativeFinder.refresh(false) get cache hit → instant
75-
b. filter results to this manager's env type (e.g. conda filters to kind=conda)
76-
c. convert NativeEnvInfo → PythonEnvironment objects → populate collection
77-
d. `loadEnvMap()` — reads persisted env path from workspace state
78-
→ matches path against freshly discovered collection via `findEnvironmentByPath()`
79-
→ populates `fsPathToEnv` map
80-
5. look up scope in `fsPathToEnv` → return the matched env
62+
→ falls through to `await result.manager.get(scope)`
63+
64+
**--- inner fork: fast path vs slow path (tryFastPathGet in fastPath.ts) ---**
65+
Conditions checked before entering fast path:
66+
a. `_initialized` deferred is undefined (never created) OR has not yet completed
67+
b. scope is a `Uri` (not global/undefined)
68+
69+
FAST PATH (background init kickoff + optional early return):
70+
**Race-condition safety (runs before any await):**
71+
1. if `_initialized` doesn't exist yet:
72+
- create deferred and **register immediately** via `setInitialized()` callback
73+
- this blocks concurrent callers from spawning duplicate background inits
74+
- kick off `startBackgroundInit()` as fire-and-forget
75+
- this happens as soon as (a) and (b) are true, **even if** no persisted path exists
76+
2. get project fsPath: `getProjectFsPathForScope(api, scope)`
77+
- prefers resolved project path if available, falls back to scope.fsPath
78+
- shared across all managers to avoid lambda duplication
79+
3. read persisted path (only if scope is a `Uri`; may return undefined)
80+
4. if a persisted path exists:
81+
- attempt `resolve(persistedPath)`
82+
- failure (no env, mismatched manager, etc.) → fall through to SLOW PATH
83+
- success → return env immediately (background init continues in parallel)
84+
**Failure recovery (in startBackgroundInit error handler):**
85+
- if background init throws: `setInitialized(undefined)` — clear deferred so next `get()` call retries init
86+
87+
SLOW PATH — fast path conditions not met, or fast path failed:
88+
4. `initialize()` — lazy, once-only per manager (guarded by `_initialized` deferred)
89+
**Once-only guarantee:**
90+
- first caller creates `_initialized` deferred (if not already created by fast path)
91+
- concurrent callers see the existing deferred and await it instead of re-running init
92+
- deferred is **not cleared on failure** here (unlike in fast-path background handler)
93+
so only one init attempt runs, but subsequent calls still await the same failed init
94+
**Note:** In the fast path, if background init fails, the deferred is cleared to allow retry
95+
a. `nativeFinder.refresh(hardRefresh=false)`:
96+
→ internally calls `handleSoftRefresh()` → computes cache key from options
97+
- on reload: cache is empty (Map was destroyed) → cache miss
98+
- falls through to `handleHardRefresh()`
99+
→ `handleHardRefresh()` adds request to WorkerPool queue (concurrency 1):
100+
1. run `configure()` to setup PET search paths
101+
2. run `refresh` — PET scans filesystem
102+
- PET may use its own on-disk cache
103+
3. returns NativeInfo[] (all envs of all types)
104+
- result stored in in-memory cache so subsequent managers get instant cache hit
105+
b. filter results to this manager's env type (e.g. conda filters to kind=conda)
106+
c. convert NativeEnvInfo → PythonEnvironment objects → populate collection
107+
d. `loadEnvMap()` — reads persisted env path from workspace state
108+
→ matches path against PET discovery results
109+
→ populates `fsPathToEnv` map
110+
5. look up scope in `fsPathToEnv` → return the matched env
81111

82112
📊 TELEMETRY: ENV_SELECTION.RESULT (per scope) { duration (priority chain + manager.get), scope, prioritySource, managerId, path, hasPersistedSelection }
83113

@@ -86,8 +116,8 @@ ASYNC (setImmediate callback, still in extension.ts):
86116

87117
📊 TELEMETRY: EXTENSION.MANAGER_REGISTRATION_DURATION { duration (activation→here), result, failureStage?, errorType? }
88118

89-
POST-INIT:
119+
**POST-INIT:**
90120
1. register terminal package watcher
91121
2. register settings change listener (`registerInterpreterSettingsChangeListener`) — re-runs priority chain if settings change
92122
3. initialize terminal manager
93-
4. send telemetry (manager selection, project structure, discovery summary)
123+
4. send telemetry (manager selection, project structure, discovery summary)

src/managers/builtin/sysPythonManager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
import { SysManagerStrings } from '../../common/localize';
2121
import { createDeferred, Deferred } from '../../common/utils/deferred';
2222
import { normalizePath } from '../../common/utils/pathUtils';
23+
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
2324
import { NativePythonFinder } from '../common/nativePythonFinder';
2425
import { getLatest } from '../common/utils';
2526
import {
@@ -145,6 +146,22 @@ export class SysPythonManager implements EnvironmentManager {
145146
}
146147

147148
async get(scope: GetEnvironmentScope): Promise<PythonEnvironment | undefined> {
149+
const fastResult = await tryFastPathGet({
150+
initialized: this._initialized,
151+
setInitialized: (deferred) => {
152+
this._initialized = deferred;
153+
},
154+
scope,
155+
label: 'system',
156+
getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s),
157+
getPersistedPath: (fsPath) => getSystemEnvForWorkspace(fsPath),
158+
resolve: (p) => resolveSystemPythonEnvironmentPath(p, this.nativeFinder, this.api, this),
159+
startBackgroundInit: () => this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering),
160+
});
161+
if (fastResult) {
162+
return fastResult.env;
163+
}
164+
148165
await this.initialize();
149166

150167
if (scope instanceof Uri) {

src/managers/builtin/venvManager.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { createDeferred, Deferred } from '../../common/utils/deferred';
3535
import { normalizePath } from '../../common/utils/pathUtils';
3636
import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis';
3737
import { findParentIfFile } from '../../features/envCommands';
38+
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
3839
import { NativePythonFinder } from '../common/nativePythonFinder';
3940
import { getLatest, shortVersion, sortEnvironments } from '../common/utils';
4041
import { promptInstallPythonViaUv } from './uvPythonInstaller';
@@ -366,6 +367,22 @@ export class VenvManager implements EnvironmentManager {
366367
}
367368

368369
async get(scope: GetEnvironmentScope): Promise<PythonEnvironment | undefined> {
370+
const fastResult = await tryFastPathGet({
371+
initialized: this._initialized,
372+
setInitialized: (deferred) => {
373+
this._initialized = deferred;
374+
},
375+
scope,
376+
label: 'venv',
377+
getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s),
378+
getPersistedPath: (fsPath) => getVenvForWorkspace(fsPath),
379+
resolve: (p) => resolveVenvPythonEnvironmentPath(p, this.nativeFinder, this.api, this, this.baseManager),
380+
startBackgroundInit: () => this.internalRefresh(undefined, false, VenvManagerStrings.venvInitialize),
381+
});
382+
if (fastResult) {
383+
return fastResult.env;
384+
}
385+
369386
await this.initialize();
370387

371388
if (!scope) {

src/managers/common/fastPath.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import { Uri } from 'vscode';
5+
import { GetEnvironmentScope, PythonEnvironment, PythonEnvironmentApi } from '../../api';
6+
import { traceError, traceWarn } from '../../common/logging';
7+
import { createDeferred, Deferred } from '../../common/utils/deferred';
8+
9+
/**
10+
* Options for the fast-path resolution in manager.get().
11+
*/
12+
export interface FastPathOptions {
13+
/** The current _initialized deferred (may be undefined if init hasn't started). */
14+
initialized: Deferred<void> | undefined;
15+
/** Updates the manager's _initialized deferred. */
16+
setInitialized: (initialized: Deferred<void> | undefined) => void;
17+
/** The scope passed to get(). */
18+
scope: GetEnvironmentScope;
19+
/** Label for log messages, e.g. 'venv', 'conda'. */
20+
label: string;
21+
/** Gets the project fsPath for a given Uri scope. */
22+
getProjectFsPath: (scope: Uri) => string;
23+
/** Reads the persisted env path for a workspace fsPath. */
24+
getPersistedPath: (workspaceFsPath: string) => Promise<string | undefined>;
25+
/** Resolves a persisted path to a full PythonEnvironment. */
26+
resolve: (persistedPath: string) => Promise<PythonEnvironment | undefined>;
27+
/** Starts background initialization (full discovery). Returns a promise that completes when init is done. */
28+
startBackgroundInit: () => Promise<void> | Thenable<void>;
29+
}
30+
31+
/**
32+
* Result from a successful fast-path resolution.
33+
*/
34+
export interface FastPathResult {
35+
/** The resolved environment. */
36+
env: PythonEnvironment;
37+
}
38+
39+
/**
40+
* Gets the fsPath for a scope by preferring the resolved project path when available.
41+
*/
42+
export function getProjectFsPathForScope(api: Pick<PythonEnvironmentApi, 'getPythonProject'>, scope: Uri): string {
43+
return api.getPythonProject(scope)?.uri.fsPath ?? scope.fsPath;
44+
}
45+
46+
/**
47+
* Attempts fast-path resolution for manager.get(): if full initialization hasn't completed yet
48+
* and there's a persisted environment for the workspace, resolve it directly via nativeFinder
49+
* instead of waiting for full discovery.
50+
*
51+
* Returns the resolved environment (with an optional new deferred) if successful, or undefined
52+
* to fall through to the normal init path.
53+
*/
54+
export async function tryFastPathGet(opts: FastPathOptions): Promise<FastPathResult | undefined> {
55+
if (!(opts.scope instanceof Uri)) {
56+
return undefined;
57+
}
58+
59+
if (opts.initialized?.completed) {
60+
return undefined;
61+
}
62+
63+
let deferred = opts.initialized;
64+
if (!deferred) {
65+
// Register deferred before any await to avoid concurrent callers starting duplicate inits.
66+
deferred = createDeferred<void>();
67+
opts.setInitialized(deferred);
68+
const deferredRef = deferred;
69+
try {
70+
Promise.resolve(opts.startBackgroundInit()).then(
71+
() => deferredRef.resolve(),
72+
(err) => {
73+
traceError(`[${opts.label}] Background initialization failed:`, err);
74+
// Allow subsequent get()/initialize() calls to retry after a background init failure.
75+
opts.setInitialized(undefined);
76+
deferredRef.resolve();
77+
},
78+
);
79+
} catch (syncErr) {
80+
traceError(`[${opts.label}] Background initialization threw synchronously:`, syncErr);
81+
opts.setInitialized(undefined);
82+
deferredRef.resolve();
83+
}
84+
}
85+
86+
const fsPath = opts.getProjectFsPath(opts.scope);
87+
const persistedPath = await opts.getPersistedPath(fsPath);
88+
89+
if (persistedPath) {
90+
try {
91+
const resolved = await opts.resolve(persistedPath);
92+
if (resolved) {
93+
return { env: resolved };
94+
}
95+
} catch (err) {
96+
traceWarn(`[${opts.label}] Fast path resolve failed for '${persistedPath}', falling back to full init:`, err);
97+
}
98+
}
99+
100+
return undefined;
101+
}

src/managers/conda/condaEnvManager.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { traceError } from '../../common/logging';
2424
import { createDeferred, Deferred } from '../../common/utils/deferred';
2525
import { normalizePath } from '../../common/utils/pathUtils';
2626
import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis';
27+
import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath';
2728
import { NativePythonFinder } from '../common/nativePythonFinder';
2829
import { CondaSourcingStatus } from './condaSourcingUtils';
2930
import {
@@ -260,6 +261,32 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
260261
}
261262
}
262263
async get(scope: GetEnvironmentScope): Promise<PythonEnvironment | undefined> {
264+
const fastResult = await tryFastPathGet({
265+
initialized: this._initialized,
266+
setInitialized: (deferred) => {
267+
this._initialized = deferred;
268+
},
269+
scope,
270+
label: 'conda',
271+
getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s),
272+
getPersistedPath: (fsPath) => getCondaForWorkspace(fsPath),
273+
resolve: (p) => resolveCondaPath(p, this.nativeFinder, this.api, this.log, this),
274+
startBackgroundInit: () =>
275+
withProgress({ location: ProgressLocation.Window, title: CondaStrings.condaDiscovering }, async () => {
276+
this.collection = await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this);
277+
await this.loadEnvMap();
278+
this._onDidChangeEnvironments.fire(
279+
this.collection.map((e) => ({
280+
environment: e,
281+
kind: EnvironmentChangeKind.add,
282+
})),
283+
);
284+
}),
285+
});
286+
if (fastResult) {
287+
return fastResult.env;
288+
}
289+
263290
await this.initialize();
264291
if (scope instanceof Uri) {
265292
let env = this.fsPathToEnv.get(normalizePath(scope.fsPath));

0 commit comments

Comments
 (0)