diff --git a/docs/startup-flow.md b/docs/startup-flow.md index 5029df1b..587203c5 100644 --- a/docs/startup-flow.md +++ b/docs/startup-flow.md @@ -5,19 +5,22 @@ user opens VS Code python environments extension begins activation -SYNC (`activate` in extension.ts): -1. create core objects: ProjectManager, EnvironmentManagers, ManagerReady -2. `setPythonApi()` β€” API object created, deferred resolved (API is now available to consumers) -3. create views (EnvManagerView, ProjectView), status bar, terminal manager -4. register all commands -5. activate() returns β€” extension is "active" from VS Code's perspective +**SYNC (`activate` in extension.ts):** +1. create StatusBar, ProjectManager, EnvVarManager, EnvironmentManagers, ManagerReady +2. create TerminalActivation, shell providers, TerminalManager +3. create ProjectCreators +4. `setPythonApi()` β€” API object created, deferred resolved (API is now available to consumers) +5. create views (EnvManagerView, ProjectView) +6. register all commands +7. activate() returns β€” extension is "active" from VS Code's perspective πŸ“Š TELEMETRY: EXTENSION.ACTIVATION_DURATION { duration } -ASYNC (setImmediate callback, still in extension.ts): +**ASYNC (setImmediate callback, still in extension.ts):** 1. spawn PET process (`createNativePythonFinder`) 1. sets up a JSON-RPC connection to it over stdin/stdout -2. register all built-in managers in parallel (Promise.all): +2. register all built-in managers + shell env init in parallel (Promise.all): + - `shellStartupVarsMgr.initialize()` - for each manager (system, conda, pyenv, pipenv, poetry): 1. check if tool exists (e.g. `getConda(nativeFinder)` asks PET for the conda binary) 2. if tool not found β†’ log, return early (manager not registered) @@ -26,17 +29,19 @@ ASYNC (setImmediate callback, still in extension.ts): - fires `onDidChangeEnvironmentManager` β†’ `ManagerReady` deferred resolves for this manager 3. all registrations complete (Promise.all resolves) ---- gate point: `applyInitialEnvironmentSelection` --- +**--- gate point: `applyInitialEnvironmentSelection` ---** + πŸ“Š TELEMETRY: ENV_SELECTION.STARTED { duration (activationβ†’here), registeredManagerCount, registeredManagerIds, workspaceFolderCount } 1. for each workspace folder + global scope (no workspace case), run `resolvePriorityChainCore` to find manager: - P1: pythonProjects[] setting β†’ specific manager for this project - P2: user-configured defaultEnvManager setting - P3: user-configured python.defaultInterpreterPath β†’ nativeFinder.resolve(path) - - P4: auto-discovery β†’ try venv manager (local .venv), fall back to system python - - for workspace scope: ask venv manager if there's a local env (.venv/venv in the folder) - - if found β†’ use venv manager with that env - - if not found β†’ fall back to system python manager + - P4: auto-discovery β†’ try venv manager, fall back to system python + - for workspace scope: call `venvManager.get(scope)` + - if venv found (local .venv/venv) β†’ use venv manager with that env + - if no local venv β†’ venv manager may still return its `globalEnv` (system Python) + - if venvManager.get returns undefined β†’ fall back to system python manager - for global scope: use system python manager directly 2. get the environment from the winning priority level: @@ -54,30 +59,55 @@ ASYNC (setImmediate callback, still in extension.ts): managerDiscovery β€” P1, P2, or P4 won (manager β†’ interpreter): `resolvePriorityChainCore` returns { manager, environment: undefined } - β†’ result.environment is undefined β†’ falls through to `await result.manager.get(scope)` - `manager.get(scope)` (e.g. `CondaEnvManager.get()`): - 4. `initialize()` β€” lazy, once-only per manager (guarded by deferred) - a. `nativeFinder.refresh(hardRefresh=false)`: - β†’ `handleSoftRefresh()` checks in-memory cache (Map) for key 'all' (bc one big scan, shared cache, all managers benefit) - - on reload: cache is empty (Map was destroyed) β†’ cache miss - - falls through to `handleHardRefresh()` - β†’ `handleHardRefresh()`: - - adds request to WorkerPool queue (concurrency 1, so serialized) - - when its turn comes, calls `doRefresh()`: - 1. `configure()` β€” JSON-RPC to PET with search paths, conda/poetry/pipenv paths, cache dir - 2. `refresh` β€” JSON-RPC to PET, PET scans filesystem - - PET may use its own on-disk cache (cacheDirectory) to speed this up - - PET streams back results as 'environment' and 'manager' notifications - - envs missing version/prefix get an inline resolve() call - 3. returns NativeInfo[] (all envs of all types) - - result stored in in-memory cache under key 'all' - β†’ subsequent managers calling nativeFinder.refresh(false) get cache hit β†’ instant - b. filter results to this manager's env type (e.g. conda filters to kind=conda) - c. convert NativeEnvInfo β†’ PythonEnvironment objects β†’ populate collection - d. `loadEnvMap()` β€” reads persisted env path from workspace state - β†’ matches path against freshly discovered collection via `findEnvironmentByPath()` - β†’ populates `fsPathToEnv` map - 5. look up scope in `fsPathToEnv` β†’ return the matched env + β†’ falls through to `await result.manager.get(scope)` + + **--- inner fork: fast path vs slow path (tryFastPathGet in fastPath.ts) ---** + Conditions checked before entering fast path: + a. `_initialized` deferred is undefined (never created) OR has not yet completed + b. scope is a `Uri` (not global/undefined) + + FAST PATH (background init kickoff + optional early return): + **Race-condition safety (runs before any await):** + 1. if `_initialized` doesn't exist yet: + - create deferred and **register immediately** via `setInitialized()` callback + - this blocks concurrent callers from spawning duplicate background inits + - kick off `startBackgroundInit()` as fire-and-forget + - this happens as soon as (a) and (b) are true, **even if** no persisted path exists + 2. get project fsPath: `getProjectFsPathForScope(api, scope)` + - prefers resolved project path if available, falls back to scope.fsPath + - shared across all managers to avoid lambda duplication + 3. read persisted path (only if scope is a `Uri`; may return undefined) + 4. if a persisted path exists: + - attempt `resolve(persistedPath)` + - failure (no env, mismatched manager, etc.) β†’ fall through to SLOW PATH + - success β†’ return env immediately (background init continues in parallel) + **Failure recovery (in startBackgroundInit error handler):** + - if background init throws: `setInitialized(undefined)` β€” clear deferred so next `get()` call retries init + + SLOW PATH β€” fast path conditions not met, or fast path failed: + 4. `initialize()` β€” lazy, once-only per manager (guarded by `_initialized` deferred) + **Once-only guarantee:** + - first caller creates `_initialized` deferred (if not already created by fast path) + - concurrent callers see the existing deferred and await it instead of re-running init + - deferred is **not cleared on failure** here (unlike in fast-path background handler) + so only one init attempt runs, but subsequent calls still await the same failed init + **Note:** In the fast path, if background init fails, the deferred is cleared to allow retry + a. `nativeFinder.refresh(hardRefresh=false)`: + β†’ internally calls `handleSoftRefresh()` β†’ computes cache key from options + - on reload: cache is empty (Map was destroyed) β†’ cache miss + - falls through to `handleHardRefresh()` + β†’ `handleHardRefresh()` adds request to WorkerPool queue (concurrency 1): + 1. run `configure()` to setup PET search paths + 2. run `refresh` β€” PET scans filesystem + - PET may use its own on-disk cache + 3. returns NativeInfo[] (all envs of all types) + - result stored in in-memory cache so subsequent managers get instant cache hit + b. filter results to this manager's env type (e.g. conda filters to kind=conda) + c. convert NativeEnvInfo β†’ PythonEnvironment objects β†’ populate collection + d. `loadEnvMap()` β€” reads persisted env path from workspace state + β†’ matches path against PET discovery results + β†’ populates `fsPathToEnv` map + 5. look up scope in `fsPathToEnv` β†’ return the matched env πŸ“Š TELEMETRY: ENV_SELECTION.RESULT (per scope) { duration (priority chain + manager.get), scope, prioritySource, managerId, path, hasPersistedSelection } @@ -86,8 +116,8 @@ ASYNC (setImmediate callback, still in extension.ts): πŸ“Š TELEMETRY: EXTENSION.MANAGER_REGISTRATION_DURATION { duration (activationβ†’here), result, failureStage?, errorType? } -POST-INIT: +**POST-INIT:** 1. register terminal package watcher 2. register settings change listener (`registerInterpreterSettingsChangeListener`) β€” re-runs priority chain if settings change 3. initialize terminal manager -4. send telemetry (manager selection, project structure, discovery summary) +4. send telemetry (manager selection, project structure, discovery summary) \ No newline at end of file diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 6604471c..9c6946fa 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -20,6 +20,7 @@ import { import { SysManagerStrings } from '../../common/localize'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; +import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest } from '../common/utils'; import { @@ -145,6 +146,22 @@ export class SysPythonManager implements EnvironmentManager { } async get(scope: GetEnvironmentScope): Promise { + const fastResult = await tryFastPathGet({ + initialized: this._initialized, + setInitialized: (deferred) => { + this._initialized = deferred; + }, + scope, + label: 'system', + getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), + getPersistedPath: (fsPath) => getSystemEnvForWorkspace(fsPath), + resolve: (p) => resolveSystemPythonEnvironmentPath(p, this.nativeFinder, this.api, this), + startBackgroundInit: () => this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering), + }); + if (fastResult) { + return fastResult.env; + } + await this.initialize(); if (scope instanceof Uri) { diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 9bf75add..3c860e1e 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -35,6 +35,7 @@ import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; import { findParentIfFile } from '../../features/envCommands'; +import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest, shortVersion, sortEnvironments } from '../common/utils'; import { promptInstallPythonViaUv } from './uvPythonInstaller'; @@ -366,6 +367,22 @@ export class VenvManager implements EnvironmentManager { } async get(scope: GetEnvironmentScope): Promise { + const fastResult = await tryFastPathGet({ + initialized: this._initialized, + setInitialized: (deferred) => { + this._initialized = deferred; + }, + scope, + label: 'venv', + getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), + getPersistedPath: (fsPath) => getVenvForWorkspace(fsPath), + resolve: (p) => resolveVenvPythonEnvironmentPath(p, this.nativeFinder, this.api, this, this.baseManager), + startBackgroundInit: () => this.internalRefresh(undefined, false, VenvManagerStrings.venvInitialize), + }); + if (fastResult) { + return fastResult.env; + } + await this.initialize(); if (!scope) { diff --git a/src/managers/common/fastPath.ts b/src/managers/common/fastPath.ts new file mode 100644 index 00000000..f8a47e31 --- /dev/null +++ b/src/managers/common/fastPath.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { Uri } from 'vscode'; +import { GetEnvironmentScope, PythonEnvironment, PythonEnvironmentApi } from '../../api'; +import { traceError, traceWarn } from '../../common/logging'; +import { createDeferred, Deferred } from '../../common/utils/deferred'; + +/** + * Options for the fast-path resolution in manager.get(). + */ +export interface FastPathOptions { + /** The current _initialized deferred (may be undefined if init hasn't started). */ + initialized: Deferred | undefined; + /** Updates the manager's _initialized deferred. */ + setInitialized: (initialized: Deferred | undefined) => void; + /** The scope passed to get(). */ + scope: GetEnvironmentScope; + /** Label for log messages, e.g. 'venv', 'conda'. */ + label: string; + /** Gets the project fsPath for a given Uri scope. */ + getProjectFsPath: (scope: Uri) => string; + /** Reads the persisted env path for a workspace fsPath. */ + getPersistedPath: (workspaceFsPath: string) => Promise; + /** Resolves a persisted path to a full PythonEnvironment. */ + resolve: (persistedPath: string) => Promise; + /** Starts background initialization (full discovery). Returns a promise that completes when init is done. */ + startBackgroundInit: () => Promise | Thenable; +} + +/** + * Result from a successful fast-path resolution. + */ +export interface FastPathResult { + /** The resolved environment. */ + env: PythonEnvironment; +} + +/** + * Gets the fsPath for a scope by preferring the resolved project path when available. + */ +export function getProjectFsPathForScope(api: Pick, scope: Uri): string { + return api.getPythonProject(scope)?.uri.fsPath ?? scope.fsPath; +} + +/** + * Attempts fast-path resolution for manager.get(): if full initialization hasn't completed yet + * and there's a persisted environment for the workspace, resolve it directly via nativeFinder + * instead of waiting for full discovery. + * + * Returns the resolved environment (with an optional new deferred) if successful, or undefined + * to fall through to the normal init path. + */ +export async function tryFastPathGet(opts: FastPathOptions): Promise { + if (!(opts.scope instanceof Uri)) { + return undefined; + } + + if (opts.initialized?.completed) { + return undefined; + } + + let deferred = opts.initialized; + if (!deferred) { + // Register deferred before any await to avoid concurrent callers starting duplicate inits. + deferred = createDeferred(); + opts.setInitialized(deferred); + const deferredRef = deferred; + try { + Promise.resolve(opts.startBackgroundInit()).then( + () => deferredRef.resolve(), + (err) => { + traceError(`[${opts.label}] Background initialization failed:`, err); + // Allow subsequent get()/initialize() calls to retry after a background init failure. + opts.setInitialized(undefined); + deferredRef.resolve(); + }, + ); + } catch (syncErr) { + traceError(`[${opts.label}] Background initialization threw synchronously:`, syncErr); + opts.setInitialized(undefined); + deferredRef.resolve(); + } + } + + const fsPath = opts.getProjectFsPath(opts.scope); + const persistedPath = await opts.getPersistedPath(fsPath); + + if (persistedPath) { + try { + const resolved = await opts.resolve(persistedPath); + if (resolved) { + return { env: resolved }; + } + } catch (err) { + traceWarn(`[${opts.label}] Fast path resolve failed for '${persistedPath}', falling back to full init:`, err); + } + } + + return undefined; +} diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 2bff5b68..e004b5d3 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -24,6 +24,7 @@ import { traceError } from '../../common/logging'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; import { showErrorMessage, showInformationMessage, withProgress } from '../../common/window.apis'; +import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { CondaSourcingStatus } from './condaSourcingUtils'; import { @@ -260,6 +261,32 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { } } async get(scope: GetEnvironmentScope): Promise { + const fastResult = await tryFastPathGet({ + initialized: this._initialized, + setInitialized: (deferred) => { + this._initialized = deferred; + }, + scope, + label: 'conda', + getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), + getPersistedPath: (fsPath) => getCondaForWorkspace(fsPath), + resolve: (p) => resolveCondaPath(p, this.nativeFinder, this.api, this.log, this), + startBackgroundInit: () => + withProgress({ location: ProgressLocation.Window, title: CondaStrings.condaDiscovering }, async () => { + this.collection = await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this); + await this.loadEnvMap(); + this._onDidChangeEnvironments.fire( + this.collection.map((e) => ({ + environment: e, + kind: EnvironmentChangeKind.add, + })), + ); + }), + }); + if (fastResult) { + return fastResult.env; + } + await this.initialize(); if (scope instanceof Uri) { let env = this.fsPathToEnv.get(normalizePath(scope.fsPath)); diff --git a/src/managers/pipenv/pipenvManager.ts b/src/managers/pipenv/pipenvManager.ts index 32c99a9c..8d69208a 100644 --- a/src/managers/pipenv/pipenvManager.ts +++ b/src/managers/pipenv/pipenvManager.ts @@ -18,6 +18,7 @@ import { PipenvStrings } from '../../common/localize'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; import { withProgress } from '../../common/window.apis'; +import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { clearPipenvCache, @@ -255,6 +256,35 @@ export class PipenvManager implements EnvironmentManager { } async get(scope: GetEnvironmentScope): Promise { + const fastResult = await tryFastPathGet({ + initialized: this._initialized, + setInitialized: (deferred) => { + this._initialized = deferred; + }, + scope, + label: 'pipenv', + getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), + getPersistedPath: (fsPath) => getPipenvForWorkspace(fsPath), + resolve: (p) => resolvePipenvPath(p, this.nativeFinder, this.api, this), + startBackgroundInit: () => + withProgress( + { location: ProgressLocation.Window, title: PipenvStrings.pipenvDiscovering }, + async () => { + this.collection = await refreshPipenv(false, this.nativeFinder, this.api, this); + await this.loadEnvMap(); + this._onDidChangeEnvironments.fire( + this.collection.map((e) => ({ + environment: e, + kind: EnvironmentChangeKind.add, + })), + ); + }, + ), + }); + if (fastResult) { + return fastResult.env; + } + await this.initialize(); if (scope === undefined) { diff --git a/src/managers/pyenv/pyenvManager.ts b/src/managers/pyenv/pyenvManager.ts index 31f9d360..60c0d8b4 100644 --- a/src/managers/pyenv/pyenvManager.ts +++ b/src/managers/pyenv/pyenvManager.ts @@ -20,6 +20,7 @@ import { traceError, traceInfo } from '../../common/logging'; import { createDeferred, Deferred } from '../../common/utils/deferred'; import { normalizePath } from '../../common/utils/pathUtils'; import { withProgress } from '../../common/window.apis'; +import { getProjectFsPathForScope, tryFastPathGet } from '../common/fastPath'; import { NativePythonFinder } from '../common/nativePythonFinder'; import { getLatest } from '../common/utils'; import { @@ -142,6 +143,32 @@ export class PyEnvManager implements EnvironmentManager, Disposable { } async get(scope: GetEnvironmentScope): Promise { + const fastResult = await tryFastPathGet({ + initialized: this._initialized, + setInitialized: (deferred) => { + this._initialized = deferred; + }, + scope, + label: 'pyenv', + getProjectFsPath: (s) => getProjectFsPathForScope(this.api, s), + getPersistedPath: (fsPath) => getPyenvForWorkspace(fsPath), + resolve: (p) => resolvePyenvPath(p, this.nativeFinder, this.api, this), + startBackgroundInit: () => + withProgress({ location: ProgressLocation.Window, title: PyenvStrings.pyenvDiscovering }, async () => { + this.collection = await refreshPyenv(false, this.nativeFinder, this.api, this); + await this.loadEnvMap(); + this._onDidChangeEnvironments.fire( + this.collection.map((e) => ({ + environment: e, + kind: EnvironmentChangeKind.add, + })), + ); + }), + }); + if (fastResult) { + return fastResult.env; + } + await this.initialize(); if (scope instanceof Uri) { let env = this.fsPathToEnv.get(normalizePath(scope.fsPath)); diff --git a/src/test/managers/common/fastPath.unit.test.ts b/src/test/managers/common/fastPath.unit.test.ts new file mode 100644 index 00000000..ab43d66b --- /dev/null +++ b/src/test/managers/common/fastPath.unit.test.ts @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { PythonEnvironment } from '../../../api'; +import { createDeferred } from '../../../common/utils/deferred'; +import { FastPathOptions, tryFastPathGet } from '../../../managers/common/fastPath'; + +function createMockEnv(envPath: string): PythonEnvironment { + return { + envId: { id: 'test-env', managerId: 'test' }, + name: 'Test Env', + displayName: 'Test Env', + version: '3.11.0', + displayPath: envPath, + environmentPath: Uri.file(envPath), + sysPrefix: envPath, + execInfo: { run: { executable: envPath } }, + }; +} + +interface FastPathTestOptions { + opts: FastPathOptions; + setInitialized: sinon.SinonStub; +} + +function createOpts(overrides?: Partial): FastPathTestOptions { + const setInitialized = sinon.stub(); + const persistedPath = path.resolve('persisted', 'path'); + return { + opts: { + initialized: undefined, + setInitialized, + scope: Uri.file(path.resolve('test', 'workspace')), + label: 'test', + getProjectFsPath: (s) => s.fsPath, + getPersistedPath: sinon.stub().resolves(persistedPath), + resolve: sinon.stub().resolves(createMockEnv(persistedPath)), + startBackgroundInit: sinon.stub().resolves(), + ...overrides, + }, + setInitialized, + }; +} + +suite('tryFastPathGet', () => { + test('returns resolved env when persisted path exists and init not started', async () => { + const { opts } = createOpts(); + const result = await tryFastPathGet(opts); + + assert.ok(result, 'Should return a result'); + assert.strictEqual(result!.env.envId.id, 'test-env'); + }); + + test('returns undefined when scope is undefined', async () => { + const { opts } = createOpts({ scope: undefined }); + const result = await tryFastPathGet(opts); + + assert.strictEqual(result, undefined); + assert.ok((opts.getPersistedPath as sinon.SinonStub).notCalled); + }); + + test('returns undefined when init is already completed', async () => { + const deferred = createDeferred(); + deferred.resolve(); + const { opts } = createOpts({ initialized: deferred }); + const result = await tryFastPathGet(opts); + + assert.strictEqual(result, undefined); + assert.ok((opts.getPersistedPath as sinon.SinonStub).notCalled); + }); + + test('returns undefined when no persisted path', async () => { + const { opts } = createOpts({ + getPersistedPath: sinon.stub().resolves(undefined), + }); + const result = await tryFastPathGet(opts); + + assert.strictEqual(result, undefined); + }); + + test('returns undefined when resolve returns undefined', async () => { + const { opts } = createOpts({ + resolve: sinon.stub().resolves(undefined), + }); + const result = await tryFastPathGet(opts); + + assert.strictEqual(result, undefined); + }); + + test('returns undefined when resolve throws', async () => { + const { opts } = createOpts({ + resolve: sinon.stub().rejects(new Error('resolve failed')), + }); + const result = await tryFastPathGet(opts); + + assert.strictEqual(result, undefined); + }); + + test('calls getProjectFsPath with the scope Uri', async () => { + const scope = Uri.file(path.resolve('my', 'project')); + const getProjectFsPath = sinon.stub().returns(scope.fsPath); + const { opts } = createOpts({ scope, getProjectFsPath }); + await tryFastPathGet(opts); + + assert.ok(getProjectFsPath.calledOnce); + assert.strictEqual(getProjectFsPath.firstCall.args[0], scope); + }); + + test('passes project fsPath to getPersistedPath', async () => { + const projectPath = path.resolve('project', 'path'); + const getProjectFsPath = sinon.stub().returns(projectPath); + const getPersistedPath = sinon.stub().resolves(path.resolve('persisted')); + const { opts } = createOpts({ + getProjectFsPath, + getPersistedPath, + resolve: sinon.stub().resolves(undefined), + }); + await tryFastPathGet(opts); + + assert.strictEqual(getPersistedPath.firstCall.args[0], projectPath); + }); + + test('does not call startBackgroundInit when initialized already exists (in-progress)', async () => { + const existing = createDeferred(); // not resolved + const startBackgroundInit = sinon.stub().resolves(); + const { opts, setInitialized } = createOpts({ initialized: existing, startBackgroundInit }); + const result = await tryFastPathGet(opts); + + assert.ok(result, 'Should return env'); + assert.ok(startBackgroundInit.notCalled, 'Should not start background init'); + assert.ok(setInitialized.notCalled, 'Should not update initialized state'); + }); + + test('kicks off background init and sets initialized when initialized is undefined', async () => { + const startBackgroundInit = sinon.stub().resolves(); + const { opts, setInitialized } = createOpts({ startBackgroundInit }); + const result = await tryFastPathGet(opts); + + assert.ok(result, 'Should return fast-path result'); + assert.ok(startBackgroundInit.calledOnce, 'Should call startBackgroundInit'); + assert.ok(setInitialized.calledOnce, 'Should set initialized immediately'); + }); + + test('background init failure resets initialized for retry', async () => { + const startBackgroundInit = sinon.stub().rejects(new Error('init crashed')); + const { opts, setInitialized } = createOpts({ startBackgroundInit }); + const result = await tryFastPathGet(opts); + + assert.ok(result, 'Should still return resolved env'); + assert.ok(setInitialized.called, 'Should set initialized before async work'); + + // Allow background init promise rejection handler to run. + await new Promise((resolve) => setImmediate(resolve)); + + const lastCallArg = setInitialized.lastCall.args[0] as unknown; + assert.strictEqual(lastCallArg, undefined, 'Should clear initialized after background init failure'); + }); + + test('sets initialized before awaiting persisted path', async () => { + let releasePersistedRead: (() => void) | undefined; + const getPersistedPath = sinon.stub().callsFake( + () => + new Promise((resolve) => { + releasePersistedRead = () => resolve(path.resolve('persisted', 'path')); + }), + ); + const { opts, setInitialized } = createOpts({ getPersistedPath }); + const pending = tryFastPathGet(opts); + + assert.ok(setInitialized.calledOnce, 'Should set initialized before hitting first await'); + + releasePersistedRead!(); + await pending; + }); + + test('works with Thenable return from startBackgroundInit', async () => { + // Simulate withProgress returning a Thenable (not a full Promise) + const thenable = { then: (resolve: () => void) => resolve() }; + const { opts } = createOpts({ + startBackgroundInit: sinon.stub().returns(thenable), + }); + const result = await tryFastPathGet(opts); + + assert.ok(result, 'Should resolve successfully with Thenable init'); + }); + + test('synchronous background init failure resets initialized for retry', async () => { + const startBackgroundInit = sinon.stub().throws(new Error('init crashed sync')); + const { opts, setInitialized } = createOpts({ startBackgroundInit }); + const result = await tryFastPathGet(opts); + + assert.ok(result, 'Should still return resolved env even when background init throws synchronously'); + assert.ok( + setInitialized.called, + 'Should set initialized before attempting background init even when it throws synchronously', + ); + + // Allow any background init error handling to run. + await new Promise((resolve) => setImmediate(resolve)); + + const lastCallArg = setInitialized.lastCall.args[0] as unknown; + assert.strictEqual( + lastCallArg, + undefined, + 'Should clear initialized after synchronous background init failure', + ); + }); +}); diff --git a/src/test/managers/fastPath.get.unit.test.ts b/src/test/managers/fastPath.get.unit.test.ts new file mode 100644 index 00000000..3694449f --- /dev/null +++ b/src/test/managers/fastPath.get.unit.test.ts @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import { Uri } from 'vscode'; +import { + EnvironmentManager, + GetEnvironmentScope, + PythonEnvironment, + PythonEnvironmentApi, + PythonProject, +} from '../../api'; +import { createDeferred } from '../../common/utils/deferred'; +import * as windowApis from '../../common/window.apis'; +import * as sysCache from '../../managers/builtin/cache'; +import { SysPythonManager } from '../../managers/builtin/sysPythonManager'; +import * as sysUtils from '../../managers/builtin/utils'; +import { VenvManager } from '../../managers/builtin/venvManager'; +import * as venvUtils from '../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../managers/common/nativePythonFinder'; +import { CondaEnvManager } from '../../managers/conda/condaEnvManager'; +import * as condaUtils from '../../managers/conda/condaUtils'; +import { PipenvManager } from '../../managers/pipenv/pipenvManager'; +import * as pipenvUtils from '../../managers/pipenv/pipenvUtils'; +import { PyEnvManager } from '../../managers/pyenv/pyenvManager'; +import * as pyenvUtils from '../../managers/pyenv/pyenvUtils'; + +interface ManagerUnderTest { + get(scope: GetEnvironmentScope): Promise; + initialize(): Promise; +} + +interface ManagerCaseContext { + manager: ManagerUnderTest; + getPersistedStub: sinon.SinonStub; + resolveStub: sinon.SinonStub; +} + +interface ManagerCase { + name: string; + managerId: string; + persistedPath: string; + createContext: (sandbox: sinon.SinonSandbox) => ManagerCaseContext; +} + +const testUri = Uri.file(path.resolve('test-workspace')); + +function createMockEnv(managerId: string, envPath: string): PythonEnvironment { + return { + envId: { id: `${managerId}-env`, managerId }, + name: 'Test Env', + displayName: 'Test Env', + version: '3.11.0', + displayPath: envPath, + environmentPath: Uri.file(envPath), + sysPrefix: envPath, + execInfo: { run: { executable: envPath } }, + }; +} + +function createMockApi(projectUri?: Uri): sinon.SinonStubbedInstance { + const project: PythonProject | undefined = projectUri ? ({ uri: projectUri } as PythonProject) : undefined; + return { + getPythonProject: sinon.stub().returns(project), + getPythonProjects: sinon.stub().returns(project ? [project] : []), + createPythonEnvironmentItem: sinon + .stub() + .callsFake((_info: unknown, _mgr: unknown) => createMockEnv('test', path.resolve('resolved'))), + } as unknown as sinon.SinonStubbedInstance; +} + +function createMockNativeFinder(): sinon.SinonStubbedInstance { + return { + resolve: sinon.stub(), + refresh: sinon.stub().resolves([]), + } as unknown as sinon.SinonStubbedInstance; +} + +function createMockLog(): sinon.SinonStubbedInstance { + return { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + trace: sinon.stub(), + append: sinon.stub(), + appendLine: sinon.stub(), + clear: sinon.stub(), + show: sinon.stub(), + hide: sinon.stub(), + dispose: sinon.stub(), + replace: sinon.stub(), + name: 'test-log', + logLevel: 2, + onDidChangeLogLevel: sinon.stub() as unknown as import('vscode').Event, + } as unknown as sinon.SinonStubbedInstance; +} + +function stubInitialize(manager: ManagerUnderTest, sandbox: sinon.SinonSandbox): void { + sandbox.stub(manager, 'initialize').resolves(); +} + +function createManagerCases(): ManagerCase[] { + return [ + { + name: 'VenvManager', + managerId: 'ms-python.python:venv', + persistedPath: path.resolve('test-workspace', '.venv'), + createContext: (sandbox: sinon.SinonSandbox) => { + const getPersistedStub = sandbox.stub(venvUtils, 'getVenvForWorkspace'); + const resolveStub = sandbox.stub(venvUtils, 'resolveVenvPythonEnvironmentPath'); + const manager = new VenvManager( + createMockNativeFinder(), + createMockApi(testUri), + {} as EnvironmentManager, + createMockLog(), + ); + return { manager, getPersistedStub, resolveStub }; + }, + }, + { + name: 'CondaEnvManager', + managerId: 'ms-python.python:conda', + persistedPath: path.resolve('test', 'conda', 'envs', 'myenv'), + createContext: (sandbox: sinon.SinonSandbox) => { + const getPersistedStub = sandbox.stub(condaUtils, 'getCondaForWorkspace'); + const resolveStub = sandbox.stub(condaUtils, 'resolveCondaPath'); + sandbox.stub(condaUtils, 'refreshCondaEnvs').resolves([]); + const manager = new CondaEnvManager(createMockNativeFinder(), createMockApi(testUri), createMockLog()); + return { manager, getPersistedStub, resolveStub }; + }, + }, + { + name: 'SysPythonManager', + managerId: 'ms-python.python:system', + persistedPath: path.resolve('test', 'bin', 'python3'), + createContext: (sandbox: sinon.SinonSandbox) => { + const getPersistedStub = sandbox.stub(sysCache, 'getSystemEnvForWorkspace'); + const resolveStub = sandbox.stub(sysUtils, 'resolveSystemPythonEnvironmentPath'); + sandbox.stub(sysUtils, 'refreshPythons').resolves([]); + const manager = new SysPythonManager(createMockNativeFinder(), createMockApi(testUri), createMockLog()); + return { manager, getPersistedStub, resolveStub }; + }, + }, + { + name: 'PyEnvManager', + managerId: 'ms-python.python:pyenv', + persistedPath: path.resolve('test', '.pyenv', 'versions', '3.11.0', 'bin', 'python'), + createContext: (sandbox: sinon.SinonSandbox) => { + const getPersistedStub = sandbox.stub(pyenvUtils, 'getPyenvForWorkspace'); + const resolveStub = sandbox.stub(pyenvUtils, 'resolvePyenvPath'); + sandbox.stub(pyenvUtils, 'refreshPyenv').resolves([]); + const manager = new PyEnvManager(createMockNativeFinder(), createMockApi(testUri)); + return { manager, getPersistedStub, resolveStub }; + }, + }, + { + name: 'PipenvManager', + managerId: 'ms-python.python:pipenv', + persistedPath: path.resolve('test', '.local', 'share', 'virtualenvs', 'project-abc123', 'bin', 'python'), + createContext: (sandbox: sinon.SinonSandbox) => { + const getPersistedStub = sandbox.stub(pipenvUtils, 'getPipenvForWorkspace'); + const resolveStub = sandbox.stub(pipenvUtils, 'resolvePipenvPath'); + sandbox.stub(pipenvUtils, 'refreshPipenv').resolves([]); + const manager = new PipenvManager(createMockNativeFinder(), createMockApi(testUri)); + return { manager, getPersistedStub, resolveStub }; + }, + }, + ]; +} + +function runSharedFastPathTests(managerCase: ManagerCase, getSandbox: () => sinon.SinonSandbox): void { + test('fast path: returns resolved env when persisted path exists and init not started', async () => { + const sandbox = getSandbox(); + const { manager, getPersistedStub, resolveStub } = managerCase.createContext(sandbox); + const mockEnv = createMockEnv(managerCase.managerId, managerCase.persistedPath); + getPersistedStub.resolves(managerCase.persistedPath); + resolveStub.resolves(mockEnv); + + const result = await manager.get(testUri); + + assert.strictEqual(result, mockEnv); + assert.ok(getPersistedStub.called); + assert.ok(resolveStub.called); + }); + + test('slow path: no persisted env', async () => { + const sandbox = getSandbox(); + const { manager, getPersistedStub, resolveStub } = managerCase.createContext(sandbox); + getPersistedStub.resolves(undefined); + stubInitialize(manager, sandbox); + + const result = await manager.get(testUri); + + assert.strictEqual(result, undefined); + assert.ok(resolveStub.notCalled); + }); + + test('slow path: resolve throws', async () => { + const sandbox = getSandbox(); + const { manager, getPersistedStub, resolveStub } = managerCase.createContext(sandbox); + getPersistedStub.resolves(managerCase.persistedPath); + resolveStub.rejects(new Error('resolve failed')); + stubInitialize(manager, sandbox); + + const result = await manager.get(testUri); + + assert.strictEqual(result, undefined); + }); + + test('skip fast path: scope is undefined', async () => { + const sandbox = getSandbox(); + const { manager, getPersistedStub } = managerCase.createContext(sandbox); + stubInitialize(manager, sandbox); + + await manager.get(undefined); + + assert.ok(getPersistedStub.notCalled); + }); + + test('skip fast path: already initialized', async () => { + const sandbox = getSandbox(); + const { manager, getPersistedStub } = managerCase.createContext(sandbox); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const managerAny = manager as any; + managerAny._initialized = createDeferred(); + managerAny._initialized.resolve(); + + stubInitialize(manager, sandbox); + await manager.get(testUri); + + assert.ok(getPersistedStub.notCalled); + }); + + test('fast path: still fires when _initialized exists but not completed', async () => { + const sandbox = getSandbox(); + const { manager, getPersistedStub, resolveStub } = managerCase.createContext(sandbox); + const mockEnv = createMockEnv(managerCase.managerId, managerCase.persistedPath); + getPersistedStub.resolves(managerCase.persistedPath); + resolveStub.resolves(mockEnv); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const managerAny = manager as any; + managerAny._initialized = createDeferred(); + + const result = await manager.get(testUri); + + assert.strictEqual(result, mockEnv); + assert.ok(getPersistedStub.called); + assert.ok(resolveStub.called); + }); + + test('fast path: does not replace existing deferred', async () => { + const sandbox = getSandbox(); + const { manager, getPersistedStub, resolveStub } = managerCase.createContext(sandbox); + const mockEnv = createMockEnv(managerCase.managerId, managerCase.persistedPath); + getPersistedStub.resolves(managerCase.persistedPath); + resolveStub.resolves(mockEnv); + + const existingDeferred = createDeferred(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const managerAny = manager as any; + managerAny._initialized = existingDeferred; + + await manager.get(testUri); + + assert.strictEqual(managerAny._initialized, existingDeferred, 'Should preserve existing deferred'); + }); +} + +suite('Manager get() fast path', () => { + let sandbox: sinon.SinonSandbox; + + setup(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(windowApis, 'withProgress').callsFake((_opts, cb) => cb(undefined as never, undefined as never)); + }); + + teardown(() => { + sandbox.restore(); + }); + + createManagerCases().forEach((managerCase) => { + suite(managerCase.name, () => { + runSharedFastPathTests(managerCase, () => sandbox); + }); + }); + + suite('VenvManager specific', () => { + test('fast path: background init failure resets _initialized for retry', async () => { + const persistedPath = path.resolve('test-workspace', '.venv'); + const mockEnv = createMockEnv('ms-python.python:venv', persistedPath); + const getVenvStub = sandbox.stub(venvUtils, 'getVenvForWorkspace').resolves(persistedPath); + const resolveVenvStub = sandbox.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(mockEnv); + + const manager = new VenvManager( + createMockNativeFinder(), + createMockApi(testUri), + {} as EnvironmentManager, + createMockLog(), + ); + + const internalRefreshStub = sandbox.stub( + manager as unknown as { internalRefresh: () => Promise }, + 'internalRefresh', + ); + internalRefreshStub.rejects(new Error('discovery crashed')); + + const result = await manager.get(testUri); + assert.strictEqual(result, mockEnv); + assert.ok(getVenvStub.calledOnce); + assert.ok(resolveVenvStub.calledOnce); + + await new Promise((resolve) => setImmediate(resolve)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + assert.strictEqual((manager as any)._initialized, undefined, 'Should clear initialized after failure'); + }); + + test('fast path: uses scope.fsPath when getPythonProject returns undefined', async () => { + const persistedPath = path.resolve('test-workspace', '.venv'); + const mockEnv = createMockEnv('ms-python.python:venv', persistedPath); + const getVenvStub = sandbox.stub(venvUtils, 'getVenvForWorkspace').resolves(persistedPath); + const resolveVenvStub = sandbox.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(mockEnv); + + const mockApi = { + getPythonProject: sinon.stub().returns(undefined), + getPythonProjects: sinon.stub().returns([]), + createPythonEnvironmentItem: sinon.stub(), + } as unknown as sinon.SinonStubbedInstance; + + const manager = new VenvManager( + createMockNativeFinder(), + mockApi, + {} as EnvironmentManager, + createMockLog(), + ); + const result = await manager.get(testUri); + + assert.strictEqual(result, mockEnv); + assert.strictEqual(getVenvStub.firstCall.args[0], testUri.fsPath, 'Should fall back to scope.fsPath'); + assert.ok(resolveVenvStub.called); + }); + }); +});