From b3a73e4dd8248798b80aafa047f959d8a4271398 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:38:27 -0700 Subject: [PATCH] feat: add local existence check for persisted paths in fast-path resolution --- src/managers/common/fastPath.ts | 43 ++++++++++++++++++ .../managers/common/fastPath.unit.test.ts | 45 ++++++++++++++++--- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/managers/common/fastPath.ts b/src/managers/common/fastPath.ts index aa69bd29..81370210 100644 --- a/src/managers/common/fastPath.ts +++ b/src/managers/common/fastPath.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +import * as fs from 'fs'; import { Uri } from 'vscode'; import { GetEnvironmentScope, PythonEnvironment, PythonEnvironmentApi } from '../../api'; import { traceError, traceVerbose, traceWarn } from '../../common/logging'; @@ -48,6 +49,28 @@ export function getProjectFsPathForScope(api: Pick { + try { + await fs.promises.access(persistedPath); + return true; + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return false; + } + // Unknown error (e.g. EACCES) — don't treat as missing; let resolve() decide. + return true; + } +} + /** * 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 @@ -100,6 +123,18 @@ export async function tryFastPathGet(opts: FastPathOptions): Promise): FastPathTestOptions { const setInitialized = sinon.stub(); - const persistedPath = path.resolve('persisted', 'path'); + const persistedPath = __filename; return { opts: { initialized: undefined, @@ -77,7 +77,7 @@ suite('tryFastPathGet', () => { }); test('returns resolved env for global scope when getGlobalPersistedPath returns a path', async () => { - const globalPath = path.resolve('usr', 'bin', 'python3'); + const globalPath = __filename; const resolve = sinon.stub().resolves(createMockEnv(globalPath)); const { opts } = createOpts({ scope: undefined, @@ -116,7 +116,7 @@ suite('tryFastPathGet', () => { }); test('reports stale when global cached path resolves to undefined', async () => { - const globalPath = path.resolve('usr', 'bin', 'python3'); + const globalPath = __filename; const { opts } = createOpts({ scope: undefined, getGlobalPersistedPath: sinon.stub().resolves(globalPath), @@ -132,7 +132,7 @@ suite('tryFastPathGet', () => { }); test('returns undefined for global scope when cached path resolve fails', async () => { - const globalPath = path.resolve('usr', 'bin', 'python3'); + const globalPath = __filename; const { opts } = createOpts({ scope: undefined, getGlobalPersistedPath: sinon.stub().resolves(globalPath), @@ -150,7 +150,7 @@ suite('tryFastPathGet', () => { }); test('global scope fast path starts background init when initialized is undefined', async () => { - const globalPath = path.resolve('usr', 'bin', 'python3'); + const globalPath = __filename; const startBackgroundInit = sinon.stub().resolves(); const { opts, setInitialized } = createOpts({ scope: undefined, @@ -201,6 +201,37 @@ suite('tryFastPathGet', () => { assert.strictEqual(result, undefined); }); + test('skips resolve and falls through when workspace persisted path no longer exists on disk', async () => { + const missingPath = path.resolve('does', 'not', 'exist', 'python-missing'); + const resolve = sinon.stub().resolves(createMockEnv(missingPath)); + const { opts } = createOpts({ + getPersistedPath: sinon.stub().resolves(missingPath), + resolve, + }); + const result = await tryFastPathGet(opts); + + assert.strictEqual(result, undefined, 'Should fall through when cached path is missing'); + assert.ok(resolve.notCalled, 'Should not invoke resolve (and thus PET) when path is missing'); + }); + + test('skips resolve and reports stale when global persisted path no longer exists on disk', async () => { + const missingPath = path.resolve('does', 'not', 'exist', 'python-missing'); + const resolve = sinon.stub().resolves(createMockEnv(missingPath)); + const { opts } = createOpts({ + scope: undefined, + getGlobalPersistedPath: sinon.stub().resolves(missingPath), + resolve, + }); + const result = await tryFastPathGet(opts); + + assert.strictEqual(result, undefined, 'Should fall through when cached global path is missing'); + assert.ok(resolve.notCalled, 'Should not invoke resolve (and thus PET) when path is missing'); + assert.ok(sendTelemetryStub.calledOnce, 'Should send telemetry for stale global cache'); + const [eventName, , props] = sendTelemetryStub.firstCall.args; + assert.strictEqual(eventName, EventNames.GLOBAL_ENV_CACHE); + assert.strictEqual(props.result, 'stale'); + }); + test('calls getProjectFsPath with the scope Uri', async () => { const scope = Uri.file(path.resolve('my', 'project')); const getProjectFsPath = sinon.stub().returns(scope.fsPath); @@ -214,7 +245,7 @@ suite('tryFastPathGet', () => { 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 getPersistedPath = sinon.stub().resolves(__filename); const { opts } = createOpts({ getProjectFsPath, getPersistedPath, @@ -266,7 +297,7 @@ suite('tryFastPathGet', () => { const getPersistedPath = sinon.stub().callsFake( () => new Promise((resolve) => { - releasePersistedRead = () => resolve(path.resolve('persisted', 'path')); + releasePersistedRead = () => resolve(__filename); }), ); const { opts, setInitialized } = createOpts({ getPersistedPath });