feat: implement fast path resolution for Python environment managers#1408
feat: implement fast path resolution for Python environment managers#1408eleanorjboyd wants to merge 7 commits intomicrosoft:mainfrom
Conversation
eleanorjboyd
commented
Mar 27, 2026
- 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.
…ure retry, dedup lambdas, test parameterization
…condition fix, failure retry
There was a problem hiding this comment.
Pull request overview
This PR introduces a shared “fast path” for EnvironmentManager.get() so managers can immediately resolve a persisted environment selection (from workspace state) without waiting for full environment discovery to complete, while discovery continues in the background.
Changes:
- Added a reusable
tryFastPathGet()helper (plus a sharedgetProjectFsPathForScope()accessor) and integrated it into built-in and tool-based managers. - Updated
get()implementations in SysPython/Venv/Conda/Pipenv/PyEnv managers to attempt fast resolution and trigger background initialization. - Added unit tests for both the shared helper and manager-level behavior; updated startup-flow documentation to describe the new execution fork.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/managers/common/fastPath.ts | New shared helper implementing the fast-path resolution + background init orchestration. |
| src/managers/builtin/sysPythonManager.ts | Uses fast-path helper before falling back to standard initialization. |
| src/managers/builtin/venvManager.ts | Uses fast-path helper and starts background refresh via internalRefresh. |
| src/managers/conda/condaEnvManager.ts | Uses fast-path helper and runs discovery under withProgress. |
| src/managers/pipenv/pipenvManager.ts | Uses fast-path helper and runs discovery under withProgress. |
| src/managers/pyenv/pyenvManager.ts | Uses fast-path helper and runs discovery under withProgress. |
| src/test/managers/common/fastPath.unit.test.ts | New unit tests validating tryFastPathGet() behavior and background init semantics. |
| src/test/managers/fastPath.get.unit.test.ts | New unit tests validating fast-path behavior across multiple managers’ get() implementations. |
| docs/startup-flow.md | Documentation updates describing the new fast-path vs slow-path fork during startup/selection. |
Comments suppressed due to low confidence (1)
src/managers/common/fastPath.ts:94
- Same as the traceError case above:
traceWarn(... ${err})will often lose the error stack/details. Prefer passing the error object as a separate argument totraceWarnsoutil.formatcan render it more helpfully.
traceWarn(
`[${opts.label}] Fast path resolve failed for '${persistedPath}', falling back to full init: ${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(); | ||
| }, |
There was a problem hiding this comment.
opts.startBackgroundInit() is invoked inside Promise.resolve(...). If startBackgroundInit throws synchronously (before returning a Promise/Thenable), the exception will escape and tryFastPathGet() will throw, breaking manager.get() calls. Wrap the call in try/catch (or a helper that converts sync-throws into a rejected promise) so failures are handled by the rejection path and the deferred is resolved/cleared consistently.
| opts.setInitialized(undefined); | ||
| deferredRef.resolve(); | ||
| }, |
There was a problem hiding this comment.
The error is logged via string interpolation (... ${err}), which discards useful details (e.g., stack traces) and can produce [object Object]. Since traceError/traceWarn ultimately call util.format, pass the error as a separate argument (e.g. traceError('...', err)) to preserve structured formatting.
This issue also appears on line 90 of the same file.
| three conditions checked: | ||
| a. `_initialized` deferred is undefined (never created) OR has not yet completed | ||
| b. scope is a `Uri` (not global/undefined) | ||
| c. a persisted env path exists in workspace state for this scope (folder Uri) | ||
|
|
||
| FAST PATH (run if above three conditions are true): | ||
| **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 | ||
| 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 | ||
| 4. `resolve(persistedPath)` | ||
| 1. failure → see SLOW PATH | ||
| 2. successful → return env immediately (background init continues in parallel) |
There was a problem hiding this comment.
The doc says fast path runs only when a persisted env path exists (condition c), but tryFastPathGet() currently creates _initialized and starts startBackgroundInit() before it awaits getPersistedPath(). Please adjust this section to reflect the actual control flow (e.g., background init can start even if no persisted path; the early return only happens when a persisted path exists and resolves).
| three conditions checked: | |
| a. `_initialized` deferred is undefined (never created) OR has not yet completed | |
| b. scope is a `Uri` (not global/undefined) | |
| c. a persisted env path exists in workspace state for this scope (folder Uri) | |
| FAST PATH (run if above three conditions are true): | |
| **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 | |
| 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 | |
| 4. `resolve(persistedPath)` | |
| 1. failure → see SLOW PATH | |
| 2. successful → return env immediately (background init continues in parallel) | |
| key checks: | |
| a. `_initialized` deferred is undefined (never created) OR has not yet completed | |
| b. scope is a `Uri` (not global/undefined) | |
| c. a persisted env path exists in workspace state for this scope (folder Uri) | |
| 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) |
| const lastCallArg = setInitialized.lastCall.args[0] as unknown; | ||
| assert.strictEqual(lastCallArg, undefined, 'Should clear initialized after background init failure'); | ||
| }); | ||
|
|
There was a problem hiding this comment.
There’s good coverage for rejected/Thenable startBackgroundInit, but no test for startBackgroundInit throwing synchronously (before returning a Promise/Thenable). Since tryFastPathGet currently calls it inline, adding a unit test for a sync throw would prevent regressions once it’s fixed to handle that case gracefully and to clear/resolve _initialized appropriately.
| 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', | |
| ); | |
| }); |