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 1/4] 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 }); From c58ca226691048d8f2db6614571548f92e04be09 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:05:42 -0700 Subject: [PATCH 2/4] fix: IPC handle is longer than 103 chars error in build --- .github/workflows/pr-check.yml | 20 ++++---------------- .github/workflows/push-check.yml | 15 +++------------ .vscode-test.mjs | 8 ++++++-- build/setup-test-user-data.mjs | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 30 deletions(-) create mode 100644 build/setup-test-user-data.mjs diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 0a3fc323..440cf8b9 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -152,10 +152,7 @@ jobs: run: npm run compile-tests - name: Configure Test Settings - run: | - mkdir -p .vscode-test/user-data/User - echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json - shell: bash + run: node build/setup-test-user-data.mjs - name: Run Smoke Tests (Linux) if: runner.os == 'Linux' @@ -245,10 +242,7 @@ jobs: run: npm run compile-tests - name: Configure Test Settings - run: | - mkdir -p .vscode-test/user-data/User - echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json - shell: bash + run: node build/setup-test-user-data.mjs - name: Run E2E Tests (Linux) if: runner.os == 'Linux' @@ -338,10 +332,7 @@ jobs: run: npm run compile-tests - name: Configure Test Settings - run: | - mkdir -p .vscode-test/user-data/User - echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json - shell: bash + run: node build/setup-test-user-data.mjs - name: Run Integration Tests (Linux) if: runner.os == 'Linux' @@ -388,10 +379,7 @@ jobs: run: npm run compile-tests - name: Configure Test Settings - run: | - mkdir -p .vscode-test/user-data/User - echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json - shell: bash + run: node build/setup-test-user-data.mjs - name: Run Integration Tests Multi-Root (Linux) if: runner.os == 'Linux' diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index e00f0aaf..062e2efa 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -153,10 +153,7 @@ jobs: run: npm run compile-tests - name: Configure Test Settings - run: | - mkdir -p .vscode-test/user-data/User - echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json - shell: bash + run: node build/setup-test-user-data.mjs - name: Run Smoke Tests (Linux) if: runner.os == 'Linux' @@ -246,10 +243,7 @@ jobs: run: npm run compile-tests - name: Configure Test Settings - run: | - mkdir -p .vscode-test/user-data/User - echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json - shell: bash + run: node build/setup-test-user-data.mjs - name: Run E2E Tests (Linux) if: runner.os == 'Linux' @@ -339,10 +333,7 @@ jobs: run: npm run compile-tests - name: Configure Test Settings - run: | - mkdir -p .vscode-test/user-data/User - echo '{"python.useEnvironmentsExtension": true}' > .vscode-test/user-data/User/settings.json - shell: bash + run: node build/setup-test-user-data.mjs - name: Run Integration Tests (Linux) if: runner.os == 'Linux' diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 79b6d242..6b7c4498 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,8 +1,12 @@ import { defineConfig } from '@vscode/test-cli'; +import * as os from 'os'; import * as path from 'path'; -// Explicit user data directory - ensures VS Code reads our settings.json -const userDataDir = path.resolve('.vscode-test/user-data'); +// Keep this path short: macOS caps Unix-domain socket paths at 103 chars and +// VS Code creates `/-main.sock`. An in-workspace location +// (e.g. /Users/runner/work///.vscode-test/user-data) overflows. +// Must match build/setup-test-user-data.mjs. +const userDataDir = path.join(os.tmpdir(), 'vsct-ud'); export default defineConfig([ { diff --git a/build/setup-test-user-data.mjs b/build/setup-test-user-data.mjs new file mode 100644 index 00000000..12211668 --- /dev/null +++ b/build/setup-test-user-data.mjs @@ -0,0 +1,19 @@ +// Writes the VS Code test user-data settings.json to a short path under +// os.tmpdir(). Used by CI to seed `python.useEnvironmentsExtension=true`. +// `.vscode-test.mjs` MUST compute the same `userDataDir` so both sides agree. +// +// Why os.tmpdir(): macOS Unix-domain socket paths are capped at 103 chars. +// VS Code creates `/-main.sock`, so an in-workspace path +// like `/Users/runner/work///.vscode-test/user-data/...` overflows. +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const userDataDir = path.join(os.tmpdir(), 'vsct-ud'); +const userDir = path.join(userDataDir, 'User'); +fs.mkdirSync(userDir, { recursive: true }); +fs.writeFileSync( + path.join(userDir, 'settings.json'), + JSON.stringify({ 'python.useEnvironmentsExtension': true }) + '\n', +); +console.log(userDataDir); From 8e005225e1e043b05f057a3450002e0869e2f83f Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 8 Jun 2026 09:00:32 -0700 Subject: [PATCH 3/4] test: stub fs.access in fast-path get tests The fast-path resolver now does a real fs.promises.access on the persisted path. The manager-level fast-path tests use synthetic paths that don't exist, causing tryFastPathGet to fall through to real initialization (and TypeError / 180s timeouts). Stub access to resolve so the fast path still fires. --- src/test/managers/fastPath.get.unit.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/managers/fastPath.get.unit.test.ts b/src/test/managers/fastPath.get.unit.test.ts index 5ad62c74..62a48a88 100644 --- a/src/test/managers/fastPath.get.unit.test.ts +++ b/src/test/managers/fastPath.get.unit.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import * as assert from 'assert'; +import * as fs from 'fs'; import * as path from 'path'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; @@ -284,6 +285,9 @@ suite('Manager get() fast path', () => { setup(() => { sandbox = sinon.createSandbox(); sandbox.stub(windowApis, 'withProgress').callsFake((_opts, cb) => cb(undefined as never, undefined as never)); + // fastPath.ts now does a real fs.access on the persisted path; these tests use + // synthetic paths that don't exist on disk, so pretend every path is present. + sandbox.stub(fs.promises, 'access').resolves(); }); teardown(() => { From d593f7c9b4ba6f596dd2bdc3a1201a0b164502a4 Mon Sep 17 00:00:00 2001 From: eleanorjboyd <26030610+eleanorjboyd@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:08:11 -0700 Subject: [PATCH 4/4] fix: seed test settings.json from .vscode-test.mjs Move the python.useEnvironmentsExtension=true seed into the test config so local 'npm run smoke-test' / e2e / integration runs match CI. Without it the extension short-circuits during activation and registration tests fail. Drops the duplicated CI step and helper. --- .github/workflows/pr-check.yml | 12 ------------ .github/workflows/push-check.yml | 9 --------- .vscode-test.mjs | 12 +++++++++++- build/setup-test-user-data.mjs | 19 ------------------- 4 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 build/setup-test-user-data.mjs diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 440cf8b9..17f8a97f 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -151,9 +151,6 @@ jobs: - name: Compile Tests run: npm run compile-tests - - name: Configure Test Settings - run: node build/setup-test-user-data.mjs - - name: Run Smoke Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@v1 @@ -241,9 +238,6 @@ jobs: - name: Compile Tests run: npm run compile-tests - - name: Configure Test Settings - run: node build/setup-test-user-data.mjs - - name: Run E2E Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@v1 @@ -331,9 +325,6 @@ jobs: - name: Compile Tests run: npm run compile-tests - - name: Configure Test Settings - run: node build/setup-test-user-data.mjs - - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@v1 @@ -378,9 +369,6 @@ jobs: - name: Compile Tests run: npm run compile-tests - - name: Configure Test Settings - run: node build/setup-test-user-data.mjs - - name: Run Integration Tests Multi-Root (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@v1 diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 062e2efa..a0dab018 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -152,9 +152,6 @@ jobs: - name: Compile Tests run: npm run compile-tests - - name: Configure Test Settings - run: node build/setup-test-user-data.mjs - - name: Run Smoke Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@v1 @@ -242,9 +239,6 @@ jobs: - name: Compile Tests run: npm run compile-tests - - name: Configure Test Settings - run: node build/setup-test-user-data.mjs - - name: Run E2E Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@v1 @@ -332,9 +326,6 @@ jobs: - name: Compile Tests run: npm run compile-tests - - name: Configure Test Settings - run: node build/setup-test-user-data.mjs - - name: Run Integration Tests (Linux) if: runner.os == 'Linux' uses: GabrielBB/xvfb-action@v1 diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 6b7c4498..23d18eb9 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,13 +1,23 @@ import { defineConfig } from '@vscode/test-cli'; +import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; // Keep this path short: macOS caps Unix-domain socket paths at 103 chars and // VS Code creates `/-main.sock`. An in-workspace location // (e.g. /Users/runner/work///.vscode-test/user-data) overflows. -// Must match build/setup-test-user-data.mjs. const userDataDir = path.join(os.tmpdir(), 'vsct-ud'); +// Seed user settings.json so the extension actually activates: without +// `python.useEnvironmentsExtension=true` it short-circuits during activation +// and the smoke/e2e/integration tests see no registered managers. +const userDir = path.join(userDataDir, 'User'); +fs.mkdirSync(userDir, { recursive: true }); +fs.writeFileSync( + path.join(userDir, 'settings.json'), + JSON.stringify({ 'python.useEnvironmentsExtension': true }) + '\n', +); + export default defineConfig([ { label: 'smokeTests', diff --git a/build/setup-test-user-data.mjs b/build/setup-test-user-data.mjs deleted file mode 100644 index 12211668..00000000 --- a/build/setup-test-user-data.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Writes the VS Code test user-data settings.json to a short path under -// os.tmpdir(). Used by CI to seed `python.useEnvironmentsExtension=true`. -// `.vscode-test.mjs` MUST compute the same `userDataDir` so both sides agree. -// -// Why os.tmpdir(): macOS Unix-domain socket paths are capped at 103 chars. -// VS Code creates `/-main.sock`, so an in-workspace path -// like `/Users/runner/work///.vscode-test/user-data/...` overflows. -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; - -const userDataDir = path.join(os.tmpdir(), 'vsct-ud'); -const userDir = path.join(userDataDir, 'User'); -fs.mkdirSync(userDir, { recursive: true }); -fs.writeFileSync( - path.join(userDir, 'settings.json'), - JSON.stringify({ 'python.useEnvironmentsExtension': true }) + '\n', -); -console.log(userDataDir);