From c3cb0231e33828ce8811b2960be2b67340c93812 Mon Sep 17 00:00:00 2001 From: Eduardo Villalpando Mello Date: Mon, 17 Aug 2026 19:37:13 -0400 Subject: [PATCH 1/7] test: cover registered package manager lifecycles (#1704) ## Summary Adds a package-manager-centric integration baseline that intentionally precedes and de-risks #1686, so the package-manager command refactor is exercised against behavior established on `main`. - drives one stateful install/list/direct-package/uninstall lifecycle per active profile - uses unique disposable projects and manager-owned disposable environments - exercises the live registered manager instances through a runtime-gated integration-test bridge - guards registry completeness so every registered package-manager ID has an active fixture or explicit deferral - covers normal Pip execution and Conda when their runtime prerequisites are available - records an uncached baseline instead of assuming a newly created environment is empty - restores workspace-scoped configuration from `inspect()` snapshots and performs guarded failure-safe cleanup - defers Poetry pending a Poetry-owned project/lockfile lifecycle - defers uv-backed Pip because changing the machine-scoped selection reliably within one extension host was not stable on `main`, while available-version lookup would also introduce `uv tool run pip` network seeding - pins the disposable integration-test user profile to normal Pip execution ## Validation - `npm run compile` - `npm run compile-tests` - `npm run lint` - `npm run unittest` - targeted `packageManagement.integration.test.js`: 3 passing, 2 prerequisite skips locally - Pip skipped because quick create selected Python 3.15.0 alpha, whose bundled Pip metadata is incomplete - Conda skipped because Conda is not installed - reviewer specialist: clean, no Critical or Important findings The active Pip and Conda fixtures require package-index/network access when their runtime prerequisites are present. Fixes #1701 --------- Copilot-Session: 6b2fe9b5-38ea-442f-b07a-b6c71134d480 Copilot-Session: 3fd1a810-6840-4ac9-ac33-c8a9fda4bfc4 --- .../testing-workflow.instructions.md | 1 + .github/workflows/pr-check.yml | 8 + .github/workflows/push-check.yml | 8 + api/CHANGELOG.md | 7 + api/package-lock.json | 4 +- api/package.json | 2 +- examples/sample1/src/api.ts | 111 ++++--- src/api.ts | 112 +++++--- src/extension.ts | 15 + src/features/pythonApi.ts | 9 +- src/internal.api.ts | 11 +- src/managers/builtin/pipPackageManager.ts | 73 +++-- src/managers/builtin/utils.ts | 6 +- src/managers/builtin/venvManager.ts | 15 +- src/managers/builtin/venvUtils.ts | 36 ++- src/managers/common/packageChanges.ts | 15 +- src/managers/conda/condaPackageManager.ts | 13 +- src/managers/poetry/poetryPackageManager.ts | 18 +- .../packageManagement.integration.test.ts | 8 +- .../packageManager.integration.test.ts | 271 ++++++++++++++++++ .../builtin/pipPackageManager.unit.test.ts | 20 ++ .../builtin/pipPackageRefresh.unit.test.ts | 53 ++++ .../managers/builtin/pipVersions.unit.test.ts | 35 ++- .../venvManager.createRemove.unit.test.ts | 22 +- .../builtin/venvUtils.removeVenv.unit.test.ts | 31 ++ .../common/packageChanges.unit.test.ts | 29 ++ .../conda/condaPackageManager.unit.test.ts | 41 +++ 27 files changed, 813 insertions(+), 161 deletions(-) create mode 100644 src/test/integration/packageManager.integration.test.ts create mode 100644 src/test/managers/builtin/pipPackageRefresh.unit.test.ts create mode 100644 src/test/managers/conda/condaPackageManager.unit.test.ts diff --git a/.github/instructions/testing-workflow.instructions.md b/.github/instructions/testing-workflow.instructions.md index b374c38d1..68958773b 100644 --- a/.github/instructions/testing-workflow.instructions.md +++ b/.github/instructions/testing-workflow.instructions.md @@ -606,3 +606,4 @@ envConfig.inspect - **Never skip tests to hide infrastructure problems**: If tests require native binaries (like `pet`), the CI workflow must build/download them. Skipping tests when infrastructure is missing gives false confidence. Build from source (like vscode-python does) rather than skipping. Tests should fail clearly when something is wrong (2) - **No retries for masking flakiness**: Mocha `retries` should not be used to mask test flakiness. If a test is flaky, fix the root cause. Retries hide real issues and slow down CI (1) - **pet binary is required for environment manager registration**: The smoke/E2E/integration tests require the `pet` binary from `microsoft/python-environment-tools` to be built and placed in `python-env-tools/bin/`. Without it, `waitForApiReady()` will timeout because managers never register. CI must build pet from source using `cargo build --release --package pet` (2) +- **Check exact project registration with `getPythonProjects()`**: `getPythonProject(uri)` can return a containing parent project, so it cannot prove that a nested project was registered or unregistered (1) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 1298ffa0b..9297f7df6 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -335,6 +335,14 @@ jobs: if: runner.os != 'Linux' run: npm run integration-test + - name: Run Package Manager Network Integration Tests + if: runner.os == 'Linux' && matrix.python-version == '3.12' + uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 + env: + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' + with: + run: npm run integration-test -- --grep "Package Manager" + integration-tests-multiroot: name: Integration Tests (Multi-Root) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index 96867be26..23db9b117 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -335,3 +335,11 @@ jobs: - name: Run Integration Tests (non-Linux) if: runner.os != 'Linux' run: npm run integration-test + + - name: Run Package Manager Network Integration Tests + if: runner.os == 'Linux' && matrix.python-version == '3.12' + uses: GabrielBB/xvfb-action@86d97bde4a65fe9b290c0b3fb92c2c4ed0e5302d # v1.6 + env: + VSC_PYTHON_PACKAGE_NETWORK_TEST: '1' + with: + run: npm run integration-test -- --grep "Package Manager" diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index 082eac300..616edca22 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to the `@vscode/python-environments` API package are documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] + +### Added + +- Added `PackageManagementInteractionOptions` with an optional `runHeadless?: boolean` property, mixed into `PackageManagementOptions`. When `true`, package management operations run without any user prompts or interaction — steps that would normally require input, such as selecting packages to install when none are specified, are skipped instead of prompting — for automated or headless scenarios such as integration tests. +- Added `RemoveEnvironmentOptions` with an optional `runHeadless?: boolean` property to remove environments without a confirmation prompt in automated or headless scenarios. + ## [1.1.0] ### Added diff --git a/api/package-lock.json b/api/package-lock.json index 8363de4f9..7745eab9a 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vscode/python-environments", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vscode/python-environments", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "dependencies": { "@renovatebot/pep440": "^3.1.0" diff --git a/api/package.json b/api/package.json index 7f68cf0b2..6b1c6e70e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,7 +1,7 @@ { "name": "@vscode/python-environments", "description": "An API facade for the Python Environments extension in VS Code", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "Microsoft Corporation" }, diff --git a/examples/sample1/src/api.ts b/examples/sample1/src/api.ts index c45ae1cbd..00512e001 100644 --- a/examples/sample1/src/api.ts +++ b/examples/sample1/src/api.ts @@ -329,6 +329,17 @@ export interface QuickCreateConfig { readonly detail?: string; } +/** + * Options controlling environment removal. + */ +export interface RemoveEnvironmentOptions { + /** + * When `true`, removes the environment without prompting for confirmation. + * Intended for automated or headless scenarios. Defaults to `false`. + */ + runHeadless?: boolean; +} + /** * Interface representing an environment manager. */ @@ -392,7 +403,7 @@ export interface EnvironmentManager { * @param environment - The Python environment to remove. * @returns A promise that resolves when the environment is removed. */ - remove?(environment: PythonEnvironment): Promise; + remove?(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; /** * Refreshes the list of Python environments within the specified scope. @@ -739,49 +750,62 @@ export interface GetPackagesOptions { } /** - * Options for package management. + * Options controlling user interaction during package management operations. */ -export type PackageManagementOptions = - | { - /** - * Upgrade the packages if it is already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall?: string[]; - } - | { - /** - * Upgrade the packages if it is already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install?: string[]; +export interface PackageManagementInteractionOptions { + /** + * When `true`, the package management operation runs without any user prompts or + * interaction and relies solely on the packages provided in the options. Any step + * that would normally require user input — such as selecting packages to install + * when none are specified — is skipped instead of prompting the user. Intended for + * automated or headless scenarios such as integration tests. Defaults to `false`. + */ + runHeadless?: boolean; +} - /** - * The list of packages to uninstall. - */ - uninstall: string[]; - }; +export type PackageManagementOptions = PackageManagementInteractionOptions & + ( + | { + /** + * Upgrade the packages if it is already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall?: string[]; + } + | { + /** + * Upgrade the packages if it is already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install?: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall: string[]; + } + ); /** * Options for creating a Python environment. @@ -881,9 +905,10 @@ export interface PythonEnvironmentManagementApi { * Remove a Python environment. * * @param environment The Python environment to remove. + * @param options Optional parameters controlling environment removal. * @returns A promise that resolves when the environment has been removed. */ - removeEnvironment(environment: PythonEnvironment): Promise; + removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; } export interface PythonEnvironmentsApi { diff --git a/src/api.ts b/src/api.ts index 5d63a3aef..2779d27b0 100644 --- a/src/api.ts +++ b/src/api.ts @@ -345,6 +345,17 @@ export interface QuickCreateConfig { readonly detail?: string; } +/** + * Options controlling environment removal. + */ +export interface RemoveEnvironmentOptions { + /** + * When `true`, removes the environment without prompting for confirmation. + * Intended for automated or headless scenarios. Defaults to `false`. + */ + runHeadless?: boolean; +} + /** * Interface representing an environment manager. * @@ -425,7 +436,7 @@ export interface EnvironmentManager { * Invoked to delete the given environment. Typical triggers include an explicit user * action (such as a "Delete Environment" command) and programmatic removal via the API. */ - remove?(environment: PythonEnvironment): Promise; + remove?(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; /** * Refreshes the list of Python environments within the specified scope. @@ -872,47 +883,63 @@ export interface GetPackagesOptions { skipCache?: boolean; } -export type PackageManagementOptions = - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install: string[]; - - /** - * The list of packages to uninstall. - */ - uninstall?: string[]; - } - | { - /** - * Upgrade the packages if they are already installed. - */ - upgrade?: boolean; - - /** - * Show option to skip package installation or uninstallation. - */ - showSkipOption?: boolean; - /** - * The list of packages to install. - */ - install?: string[]; +/** + * Options controlling user interaction during package management operations. + */ +export interface PackageManagementInteractionOptions { + /** + * When `true`, the package management operation runs without any user prompts or + * interaction and relies solely on the packages provided in the options. Any step + * that would normally require user input — such as selecting packages to install + * when none are specified — is skipped instead of prompting the user. Intended for + * automated or headless scenarios such as integration tests. Defaults to `false`. + */ + runHeadless?: boolean; +} - /** - * The list of packages to uninstall. - */ - uninstall: string[]; - }; +export type PackageManagementOptions = PackageManagementInteractionOptions & + ( + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall?: string[]; + } + | { + /** + * Upgrade the packages if they are already installed. + */ + upgrade?: boolean; + + /** + * Show option to skip package installation or uninstallation. + */ + showSkipOption?: boolean; + /** + * The list of packages to install. + */ + install?: string[]; + + /** + * The list of packages to uninstall. + */ + uninstall: string[]; + } + ); /** * Options for creating a Python environment. @@ -1011,9 +1038,10 @@ export interface PythonEnvironmentManagementApi { * Remove a Python environment. * * @param environment The Python environment to remove. + * @param options Optional parameters controlling environment removal. * @returns A promise that resolves when the environment has been removed. */ - removeEnvironment(environment: PythonEnvironment): Promise; + removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise; } export interface PythonEnvironmentsApi { diff --git a/src/extension.ts b/src/extension.ts index 1d2d98aee..46f89009b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -258,6 +258,21 @@ export async function activate(context: ExtensionContext): Promise + envManagers.packageManagers.map((manager) => manager.id), + ), + commands.registerCommand( + 'python-envs.test.getDirectPackageNames', + async (environment: PythonEnvironment) => { + const manager = envManagers.getPackageManager(environment); + const names = await manager?.getDirectPackageNames?.(environment); + return names ? Array.from(names) : undefined; + }, + ), + ] + : []), commands.registerCommand('python-envs.searchSettings', async () => { await openSearchSettings(); }), diff --git a/src/features/pythonApi.ts b/src/features/pythonApi.ts index 9c494b9eb..e93ed0cdb 100644 --- a/src/features/pythonApi.ts +++ b/src/features/pythonApi.ts @@ -29,6 +29,7 @@ import { PythonTerminalCreateOptions, PythonTerminalExecutionOptions, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from '../api'; @@ -107,9 +108,7 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { this.previousProjects = current; if (added.length > 0 || removed.length > 0) { - traceInfo( - `Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`, - ); + traceInfo(`Python API: Projects changed. Added: ${added.length}, Removed: ${removed.length}`); this._onDidChangePythonProjects.fire({ added, removed }); } }), @@ -197,13 +196,13 @@ export class PythonEnvironmentApiImpl implements PythonEnvironmentApi { return result; } } - async removeEnvironment(environment: PythonEnvironment): Promise { + async removeEnvironment(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { await waitForEnvManagerId([environment.envId.managerId]); const manager = this.envManagers.getEnvironmentManager(environment); if (!manager) { return Promise.reject(new Error('No environment manager found')); } - return manager.remove(environment); + return manager.remove(environment, options); } async refreshEnvironments(scope: RefreshEnvironmentsScope): Promise { const currentScope = checkUri(scope) as RefreshEnvironmentsScope; diff --git a/src/internal.api.ts b/src/internal.api.ts index 9b09d5cf8..6d41cb5c3 100644 --- a/src/internal.api.ts +++ b/src/internal.api.ts @@ -26,6 +26,7 @@ import { PythonProjectCreator, QuickCreateConfig, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from './api'; @@ -208,9 +209,9 @@ export class InternalEnvironmentManager implements EnvironmentManager { return this.manager.remove !== undefined; } - remove(scope: PythonEnvironment): Promise { + remove(scope: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { return this.manager.remove - ? this.manager.remove(scope) + ? this.manager.remove(scope, options) : Promise.reject(new RemoveEnvironmentNotSupported(`Remove Environment not supported by: ${this.id}`)); } @@ -405,6 +406,12 @@ export class InternalPackageManager implements PackageManager { : Promise.resolve(undefined); } + getDirectPackageNames(environment: PythonEnvironment): Promise | undefined> { + return this.manager.getDirectPackageNames + ? this.manager.getDirectPackageNames(environment) + : Promise.resolve(undefined); + } + formatInstallSpec(packageName: string, version: string): string { return this.manager.formatInstallSpec ? this.manager.formatInstallSpec(packageName, version) diff --git a/src/managers/builtin/pipPackageManager.ts b/src/managers/builtin/pipPackageManager.ts index bd3bbb761..836244bb7 100644 --- a/src/managers/builtin/pipPackageManager.ts +++ b/src/managers/builtin/pipPackageManager.ts @@ -55,6 +55,10 @@ export class PipPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package picker. + return; + } const projects = this.venv.getProjectsByEnvironment(environment); const result = await getWorkspacePackagesToInstall(this.api, options, projects, environment, this.log); if (result) { @@ -86,18 +90,21 @@ export class PipPackageManager implements PackageManager, Disposable { (changes) => { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, + () => this.fetchPackages(environment, !manageOptions.runHeadless), ); } catch (e) { if (e instanceof CancellationError) { throw e; } this.log.error('Error managing packages', e); - setImmediate(async () => { - const result = await window.showErrorMessage('Error managing packages', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + const result = await window.showErrorMessage('Error managing packages', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } throw e; } }, @@ -119,25 +126,31 @@ export class PipPackageManager implements PackageManager, Disposable { this._onDidChangePackages.fire({ environment, manager: this, changes }); }, ); - this.packages.set(environment.envId.id, packages ?? []); + if (packages !== undefined) { + this.packages.set(environment.envId.id, packages); + } }, ); } async getPackages(environment: PythonEnvironment, options?: GetPackagesOptions): Promise { if (options?.skipCache || !this.packages.has(environment.envId.id)) { - const data = await refreshPipPackages(environment, this.log); - if (data === undefined) { - return this.packages.get(environment.envId.id); - } - - const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); - this.packages.set(environment.envId.id, packages); - return packages; + return this.fetchPackages(environment); } return this.packages.get(environment.envId.id); } + private async fetchPackages(environment: PythonEnvironment, showErrors = true): Promise { + const data = await refreshPipPackages(environment, this.log, { showErrors }); + if (data === undefined) { + return this.packages.get(environment.envId.id); + } + + const packages = data.map((pkg) => this.api.createPackageItem(pkg, environment, this)); + this.packages.set(environment.envId.id, packages); + return packages; + } + async getVersion(environment: PythonEnvironment): Promise { try { const useUv = await shouldUseUv(this.log, environment.environmentPath.fsPath); @@ -186,9 +199,9 @@ export class PipPackageManager implements PackageManager, Disposable { return parsePipIndexVersionsJson(output); } - // pip >= 21.2.0 - use `pip index versions --json` to get available versions in a machine readable format. + // pip >= 25.1 - use `pip index versions --json` to get available versions in a machine readable format. const pipVersion = await this.getVersion(environment); - if (pipVersion && compare(pipVersion.public, '21.2.0') >= 0) { + if (pipVersion && compare(pipVersion.public, '25.1') >= 0) { const output = await runPython( python, ['-m', 'pip', 'index', 'versions', packageName, '--json', '--python-version', baseVersion], @@ -198,7 +211,17 @@ export class PipPackageManager implements PackageManager, Disposable { return parsePipIndexVersionsJson(output); } - // pip <= 20.3.4 - version picking is undefined; no reliable machine-readable API exists. + if (pipVersion && compare(pipVersion.public, '21.2') >= 0) { + const output = await runPython( + python, + ['-m', 'pip', 'index', 'versions', packageName, '--python-version', baseVersion], + undefined, + this.log, + ); + return parsePipIndexVersionsText(output); + } + + // pip < 21.2 - version picking is undefined; `pip index versions` is unavailable. } catch { return undefined; } @@ -245,3 +268,17 @@ export function parsePipIndexVersionsJson(output: string): Pep440Version[] | und return undefined; } } + +/** Parses the legacy text output from `pip index versions `. */ +export function parsePipIndexVersionsText(output: string): Pep440Version[] | undefined { + const match = output.match(/^Available versions:\s*(.+)$/im); + if (!match) { + return undefined; + } + const versions = match[1] + .split(',') + .map((version) => parse(version.trim())) + .filter((version): version is Pep440Version => version !== null) + .sort((a, b) => rcompare(a.public, b.public)); + return versions.length > 0 ? versions : undefined; +} diff --git a/src/managers/builtin/utils.ts b/src/managers/builtin/utils.ts index dc44fe759..f6ff2903a 100644 --- a/src/managers/builtin/utils.ts +++ b/src/managers/builtin/utils.ts @@ -218,7 +218,7 @@ async function execPipList(environment: PythonEnvironment, log?: LogOutputChanne export async function refreshPipPackages( environment: PythonEnvironment, log?: LogOutputChannel, - options?: { showProgress: boolean }, + options?: { showProgress?: boolean; showErrors?: boolean }, ): Promise { let data: string; try { @@ -238,7 +238,9 @@ export async function refreshPipPackages( return parsePipListJson(data, log); } catch (e) { log?.error('Error refreshing packages', e); - showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); + if (options?.showErrors !== false) { + showErrorMessageWithLogs(SysManagerStrings.packageRefreshError, log); + } return undefined; } } diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 7af0f450a..6dcda4df8 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -1,14 +1,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; -import { - EventEmitter, - l10n, - LogOutputChannel, - MarkdownString, - ProgressLocation, - ThemeIcon, - Uri, -} from 'vscode'; +import { EventEmitter, l10n, LogOutputChannel, MarkdownString, ProgressLocation, ThemeIcon, Uri } from 'vscode'; import { CreateEnvironmentOptions, CreateEnvironmentScope, @@ -24,6 +16,7 @@ import { PythonProject, QuickCreateConfig, RefreshEnvironmentsScope, + RemoveEnvironmentOptions, ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; @@ -265,11 +258,11 @@ export class VenvManager implements EnvironmentManager { /** * Removes the specified Python environment, updates internal collections, and fires change events as needed. */ - async remove(environment: PythonEnvironment): Promise { + async remove(environment: PythonEnvironment, options?: RemoveEnvironmentOptions): Promise { try { this.skipWatcherRefresh = true; - const isRemoved = await removeVenv(environment, this.log); + const isRemoved = await removeVenv(environment, this.log, options); if (!isRemoved) { return; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index c06146999..2962235e1 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -11,7 +11,13 @@ import { ThemeIcon, Uri, } from 'vscode'; -import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi, PythonEnvironmentInfo } from '../../api'; +import { + EnvironmentManager, + PythonEnvironment, + PythonEnvironmentApi, + PythonEnvironmentInfo, + RemoveEnvironmentOptions, +} from '../../api'; import { ENVS_EXTENSION_ID } from '../../common/constants'; import { Common, VenvManagerStrings } from '../../common/localize'; import { traceInfo, traceVerbose } from '../../common/logging'; @@ -553,7 +559,11 @@ async function validateVenvRemovalPath(envPath: string, log: LogOutputChannel): return undefined; } -export async function removeVenv(environment: PythonEnvironment, log: LogOutputChannel): Promise { +export async function removeVenv( + environment: PythonEnvironment, + log: LogOutputChannel, + options?: RemoveEnvironmentOptions, +): Promise { const pythonPath = os.platform() === 'win32' ? 'python.exe' : 'python'; const envFsPath = path.normalize(environment.environmentPath.fsPath); @@ -568,15 +578,19 @@ export async function removeVenv(environment: PythonEnvironment, log: LogOutputC // Normalize path for UI display - ensure forward slashes on Windows const displayPath = normalizePath(envPath); - const confirm = await showWarningMessage( - l10n.t('Are you sure you want to remove {0}?', displayPath), - { - modal: true, - }, - { title: Common.yes }, - { title: Common.no, isCloseAffordance: true }, - ); - if (confirm?.title === Common.yes) { + const confirmed = + options?.runHeadless === true || + ( + await showWarningMessage( + l10n.t('Are you sure you want to remove {0}?', displayPath), + { + modal: true, + }, + { title: Common.yes }, + { title: Common.no, isCloseAffordance: true }, + ) + )?.title === Common.yes; + if (confirmed) { const result = await withProgress( { location: ProgressLocation.Notification, diff --git a/src/managers/common/packageChanges.ts b/src/managers/common/packageChanges.ts index 3e16ae361..6c484fccd 100644 --- a/src/managers/common/packageChanges.ts +++ b/src/managers/common/packageChanges.ts @@ -9,6 +9,8 @@ import { normalizePackageName } from '../builtin/utils'; */ export type PackageChangesCallback = (changes: { kind: PackageChangeKind; pkg: Package }[]) => void; +type PackageFetcher = () => Promise; + /** * Computes the list of package changes between a before and after snapshot. * @param before - The previous list of packages. @@ -41,19 +43,30 @@ export function getPackageChanges(before: Package[], after: Package[]): { kind: * This function calls {@link PackageManager.getPackages} with `skipCache` to fetch * the latest snapshot. The caller should pass the previously cached packages * so changes can be computed against the pre-refresh state. + * + * @param packageManager The package manager whose packages changed. + * @param environment The environment whose packages should be refreshed. + * @param before The package snapshot from before the operation. + * @param onChanges Callback invoked when package changes are detected. + * @param fetchPackages Optional internal fetcher for operation-specific refresh behavior. */ export async function updatePackagesAndNotify( packageManager: PackageManager, environment: PythonEnvironment, before: Package[] | undefined, onChanges: PackageChangesCallback, + fetchPackages?: PackageFetcher, ): Promise { const [after, afterDirectDependenciesNames] = await Promise.all([ - packageManager.getPackages(environment, { skipCache: true }).then((pkgs) => pkgs ?? []), + fetchPackages?.() ?? packageManager.getPackages(environment, { skipCache: true }), // Handle transitive dependencies (best-effort, don't break package refresh on failure) packageManager.getDirectPackageNames?.(environment).catch(() => undefined), ]); + if (after === undefined) { + return undefined; + } + // Enrich packages with transitive dependency info (best-effort, creates new objects to respect readonly) const enriched = afterDirectDependenciesNames && afterDirectDependenciesNames.size > 0 ? after.map((pkg) => ({ diff --git a/src/managers/conda/condaPackageManager.ts b/src/managers/conda/condaPackageManager.ts index d4fb44be3..d395d0ce6 100644 --- a/src/managers/conda/condaPackageManager.ts +++ b/src/managers/conda/condaPackageManager.ts @@ -54,6 +54,10 @@ export class CondaPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package picker. + return; + } const result = await getCommonCondaPackagesToInstall(environment, options, this.api); if (result) { toInstall = result.install; @@ -91,9 +95,12 @@ export class CondaPackageManager implements PackageManager, Disposable { } this.log.error('Error installing packages', e); - setImmediate(async () => { - await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); - }); + if (!manageOptions.runHeadless) { + setImmediate(async () => { + await showErrorMessageWithLogs(CondaStrings.condaInstallError, this.log); + }); + } + throw e; } }, ); diff --git a/src/managers/poetry/poetryPackageManager.ts b/src/managers/poetry/poetryPackageManager.ts index 9525254cb..e946f0452 100644 --- a/src/managers/poetry/poetryPackageManager.ts +++ b/src/managers/poetry/poetryPackageManager.ts @@ -59,6 +59,10 @@ export class PoetryPackageManager implements PackageManager, Disposable { let toUninstall: string[] = [...(options.uninstall ?? [])]; if (toInstall.length === 0 && toUninstall.length === 0) { + if (options.runHeadless) { + // Headless mode: skip the interactive package input prompt. + return; + } // Show package input UI if no packages are specified const installInput = await showInputBox({ prompt: 'Enter packages to install (comma separated)', @@ -99,12 +103,14 @@ export class PoetryPackageManager implements PackageManager, Disposable { throw e; } this.log.error('Error managing packages with Poetry', e); - setImmediate(async () => { - const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); - if (result === 'View Output') { - this.log.show(); - } - }); + if (!options.runHeadless) { + setImmediate(async () => { + const result = await showErrorMessage('Error managing packages with Poetry', 'View Output'); + if (result === 'View Output') { + this.log.show(); + } + }); + } throw e; } }, diff --git a/src/test/integration/packageManagement.integration.test.ts b/src/test/integration/packageManagement.integration.test.ts index 5998b6a17..7eb2a75c2 100644 --- a/src/test/integration/packageManagement.integration.test.ts +++ b/src/test/integration/packageManagement.integration.test.ts @@ -282,13 +282,13 @@ suite('Integration: Package Management', function () { try { if (wasInstalled) { // Uninstall first - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); packageInstalled = false; await sleep(2000); } // Install package - await api.managePackages(targetEnv, { install: [testPackage] }); + await api.managePackages(targetEnv, { install: [testPackage], runHeadless: true }); packageInstalled = true; // Refresh and verify @@ -299,7 +299,7 @@ suite('Integration: Package Management', function () { assert.ok(isNowInstalled, `${testPackage} should be installed after managePackages install`); // Uninstall - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); packageInstalled = false; // Refresh and verify @@ -312,7 +312,7 @@ suite('Integration: Package Management', function () { // Ensure cleanup even if assertions fail if (packageInstalled) { try { - await api.managePackages(targetEnv, { uninstall: [testPackage] }); + await api.managePackages(targetEnv, { uninstall: [testPackage], runHeadless: true }); } catch { console.log('Cleanup: failed to uninstall test package'); } diff --git a/src/test/integration/packageManager.integration.test.ts b/src/test/integration/packageManager.integration.test.ts new file mode 100644 index 000000000..f42df8539 --- /dev/null +++ b/src/test/integration/packageManager.integration.test.ts @@ -0,0 +1,271 @@ +import * as vscode from 'vscode'; + +import { compare } from '@renovatebot/pep440'; +import assert from 'assert'; +import * as path from 'path'; +import { Package, PythonEnvironment, PythonEnvironmentApi, PythonProject } from '../../api'; +import { CONDA_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, VENV_MANAGER_ID } from '../../common/constants'; +import { PythonProjectSettings } from '../../internal.api'; +import { getConda } from '../../managers/conda/condaUtils'; +import { ENVS_EXTENSION_ID } from '../constants'; +import { waitForCondition } from '../testUtils'; + +type PackageManagerId = `${string}:${string}`; + +interface PackageManagerProfile { + environmentManagerId: string; + name: string; + packageManagerId: PackageManagerId; + projectDirectory: string; + prerequisite(api: PythonEnvironmentApi): Promise; + supportsVersionLookup(packages: Package[]): boolean; +} + +const profiles: PackageManagerProfile[] = [ + { + environmentManagerId: VENV_MANAGER_ID, + name: 'Pip', + packageManagerId: DEFAULT_PACKAGE_MANAGER_ID, + projectDirectory: 'pip', + prerequisite: async (api) => + (await api.getEnvironments('global')).some((environment) => environment.version.startsWith('3.')), + supportsVersionLookup: (packages) => { + const pipVersion = packages.find((pkg) => pkg.name.toLowerCase() === 'pip')?.version; + return pipVersion !== undefined && compare(pipVersion, '21.2') >= 0; + }, + }, + { + environmentManagerId: CONDA_MANAGER_ID, + name: 'Conda', + packageManagerId: CONDA_MANAGER_ID, + projectDirectory: 'conda', + prerequisite: async () => { + try { + await getConda(); + return true; + } catch { + return false; + } + }, + supportsVersionLookup: () => true, + }, +]; + +const deferredPackageManagers: Readonly> = { + 'ms-python.python:poetry': 'Poetry lifecycle coverage requires a controlled Poetry installation.', +}; + +const deferredProfiles = { + pipWithUv: 'uv-backed Pip selection uses a machine-scoped setting and is unstable within one extension host.', +} as const; + +suite('Package Manager profile coverage', function () { + this.timeout(60_000); + + test('covers or explicitly defers every registered package manager', async () => { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, 'Extension not found'); + const api: PythonEnvironmentApi = extension.isActive ? extension.exports : await extension.activate(); + await api.getEnvironments('global'); + + const registeredIds = await vscode.commands.executeCommand( + 'python-envs.test.getPackageManagerIds', + ); + assert.ok(registeredIds, 'Registered package-manager IDs are unavailable'); + + const coveredIds = new Set(profiles.map((profile) => profile.packageManagerId)); + const uncoveredIds = registeredIds.filter( + (managerId) => + !coveredIds.has(managerId as PackageManagerId) && + deferredPackageManagers[managerId as PackageManagerId] === undefined, + ); + assert.deepStrictEqual(uncoveredIds, [], `Package managers lack lifecycle coverage: ${uncoveredIds.join(', ')}`); + + for (const profile of profiles) { + assert.ok( + registeredIds.includes(profile.packageManagerId), + `Profile references an unregistered package manager: ${profile.packageManagerId}`, + ); + } + + for (const [profileName, reason] of Object.entries(deferredProfiles)) { + assert.ok(reason.length > 0, `Deferred profile lacks a reason: ${profileName}`); + } + }); +}); + +for (const profile of profiles) { + suite(`${profile.name} Package Manager`, function () { + this.timeout(300_000); + + let api: PythonEnvironmentApi; + let environment: PythonEnvironment | undefined; + let project: PythonProject | undefined; + let workspaceUri: vscode.Uri; + let previousPythonProjects: PythonProjectSettings[] | undefined; + let pythonProjectsUpdated = false; + suiteSetup(async function () { + if (process.env.VSC_PYTHON_PACKAGE_NETWORK_TEST !== '1') { + this.skip(); + return; + } + + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, 'Extension not found'); + if (!extension.isActive) { + await extension.activate(); + await waitForCondition(() => extension.isActive, 20_000, 'Extension did not activate in time'); + } + api = extension.exports; + assert.ok(api, 'API not available'); + + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspaceFolder, 'Integration test workspace not found'); + workspaceUri = workspaceFolder.uri; + const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); + + if (!(await profile.prerequisite(api))) { + this.skip(); + return; + } + + const projectUri = vscode.Uri.joinPath( + workspaceUri, + `.package-manager-test-${profile.projectDirectory}-${process.pid}`, + ); + await vscode.workspace.fs.createDirectory(projectUri); + project = { + name: `${profile.name} Package Manager Test`, + uri: projectUri, + }; + previousPythonProjects = config.inspect('pythonProjects')?.workspaceFolderValue; + const pythonProjects = config.get('pythonProjects', []); + const projectSetting: PythonProjectSettings = { + path: path.relative(workspaceUri.fsPath, projectUri.fsPath).replace(/\\/g, '/'), + envManager: profile.environmentManagerId, + packageManager: profile.packageManagerId, + workspace: workspaceFolder.name, + }; + await config.update( + 'pythonProjects', + [...pythonProjects, projectSetting], + vscode.ConfigurationTarget.WorkspaceFolder, + ); + pythonProjectsUpdated = true; + await waitForCondition( + () => + api + .getPythonProjects() + .some((registeredProject) => registeredProject.uri.toString() === projectUri.toString()), + 10_000, + `Python project was not registered: ${projectUri.fsPath}`, + ); + + await api.refreshEnvironments(projectUri); + + environment = await api.createEnvironment(projectUri, { quickCreate: true }); + assert.ok(environment, `${profile.name} failed to create an environment after prerequisites passed`); + assert.strictEqual( + environment.envId.managerId, + profile.environmentManagerId, + `Expected an environment created by ${profile.environmentManagerId}`, + ); + }); + + test(`${profile.name} Package Manager should install, list, and uninstall a package`, async () => { + const packageName = 'requests'; + const baseline = await api.getPackages(environment!, { skipCache: true }); + assert.ok(baseline, 'Unable to list packages before installation'); + const wasInstalled = baseline.some((pkg) => pkg.name.toLowerCase() === packageName); + + if (!wasInstalled) { + await api.managePackages(environment!, { install: [packageName], runHeadless: true }); + } + let packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages, 'Unable to list packages after installation'); + assert.ok( + packages.some((pkg) => pkg.name.toLowerCase() === packageName), + 'Package not installed', + ); + + const directPackageNames = await vscode.commands.executeCommand( + 'python-envs.test.getDirectPackageNames', + environment!, + ); + if (directPackageNames !== undefined) { + assert.ok(directPackageNames.includes(packageName), 'Installed package was not reported as direct'); + } + + if (!wasInstalled) { + await api.managePackages(environment!, { uninstall: [packageName], runHeadless: true }); + packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages, 'Unable to list packages after uninstallation'); + assert.ok( + !packages.some((pkg) => pkg.name.toLowerCase() === packageName), + 'Package not uninstalled', + ); + } + }); + + test(`${profile.name} Package Manager should list available package versions`, async function () { + const packages = await api.getPackages(environment!, { skipCache: true }); + assert.ok(packages, 'Unable to list packages before version lookup'); + if (!profile.supportsVersionLookup(packages)) { + this.skip(); + return; + } + + const versions = await api.getPackageAvailableVersions(environment!, 'requests'); + assert.ok(versions, `${profile.name} unexpectedly failed to retrieve package versions`); + assert.ok(versions.length > 0, 'No package versions available'); + }); + + suiteTeardown(async () => { + try { + if (environment) { + const environmentPath = environment.environmentPath; + await api.removeEnvironment(environment, { runHeadless: true }); + await assert.rejects( + async () => vscode.workspace.fs.stat(environmentPath), + (error: unknown) => + error instanceof vscode.FileSystemError && error.code === 'FileNotFound', + `Environment was not removed: ${environmentPath.fsPath}`, + ); + } + } finally { + const config = vscode.workspace.getConfiguration('python-envs', workspaceUri); + if (project) { + try { + await api.setEnvironment(project.uri, undefined); + } finally { + try { + if (pythonProjectsUpdated) { + await config.update( + 'pythonProjects', + previousPythonProjects, + vscode.ConfigurationTarget.WorkspaceFolder, + ); + await waitForCondition( + () => + !api + .getPythonProjects() + .some( + (registeredProject) => + registeredProject.uri.toString() === project!.uri.toString(), + ), + 10_000, + `Python project was not unregistered: ${project.uri.fsPath}`, + ); + } + } finally { + await vscode.workspace.fs.delete(project.uri, { + recursive: true, + useTrash: false, + }); + } + } + } + } + }); + }); +} diff --git a/src/test/managers/builtin/pipPackageManager.unit.test.ts b/src/test/managers/builtin/pipPackageManager.unit.test.ts index 8a64abf1b..549bdd2de 100644 --- a/src/test/managers/builtin/pipPackageManager.unit.test.ts +++ b/src/test/managers/builtin/pipPackageManager.unit.test.ts @@ -40,4 +40,24 @@ suite('PipPackageManager', () => { assert.deepStrictEqual(initial, [cachedPackage]); assert.deepStrictEqual(afterFailedRefresh, [cachedPackage]); }); + + test('preserves undefined when an uncached refresh fails', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + } as PythonEnvironment; + const manager = new PipPackageManager( + { createPackageItem: sinon.stub() } as unknown as PythonEnvironmentApi, + { error: sinon.stub(), info: sinon.stub() } as unknown as LogOutputChannel, + {} as VenvManager, + ); + const refreshPackages = sinon.stub(builtinUtils, 'refreshPipPackages').resolves(undefined); + + const firstResult = await manager.getPackages(environment); + const secondResult = await manager.getPackages(environment); + + assert.strictEqual(firstResult, undefined); + assert.strictEqual(secondResult, undefined); + assert.strictEqual(refreshPackages.callCount, 2, 'A failed refresh should not populate the package cache'); + }); }); diff --git a/src/test/managers/builtin/pipPackageRefresh.unit.test.ts b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts new file mode 100644 index 000000000..dff10003c --- /dev/null +++ b/src/test/managers/builtin/pipPackageRefresh.unit.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { PythonEnvironment } from '../../../api'; +import * as errorUtils from '../../../common/errors/utils'; +import * as helpers from '../../../managers/builtin/helpers'; +import { refreshPipPackages } from '../../../managers/builtin/utils'; + +suite('Pip package refresh', () => { + let environment: PythonEnvironment; + let log: LogOutputChannel; + let showErrorMessageWithLogsStub: sinon.SinonStub; + + setup(() => { + environment = { + environmentPath: Uri.file('.'), + execInfo: { + run: { + executable: 'python', + }, + }, + } as PythonEnvironment; + log = { + error: sinon.stub(), + info: sinon.stub(), + } as unknown as LogOutputChannel; + + sinon.stub(helpers, 'shouldUseUv').resolves(false); + sinon.stub(helpers, 'runPython').rejects(new Error('pip list failed')); + showErrorMessageWithLogsStub = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('shows an error when an interactive refresh fails', async () => { + const result = await refreshPipPackages(environment, log); + + assert.strictEqual(result, undefined); + assert.ok(showErrorMessageWithLogsStub.calledOnce); + }); + + test('does not show an error when a headless refresh fails', async () => { + const result = await refreshPipPackages(environment, log, { showErrors: false }); + + assert.strictEqual(result, undefined); + assert.ok(showErrorMessageWithLogsStub.notCalled); + }); +}); diff --git a/src/test/managers/builtin/pipVersions.unit.test.ts b/src/test/managers/builtin/pipVersions.unit.test.ts index 5c06c394b..b2bd15f6b 100644 --- a/src/test/managers/builtin/pipVersions.unit.test.ts +++ b/src/test/managers/builtin/pipVersions.unit.test.ts @@ -1,13 +1,16 @@ -import assert from 'assert'; import { explain } from '@renovatebot/pep440'; -import { parsePipIndexVersionsJson } from '../../../managers/builtin/pipPackageManager'; +import assert from 'assert'; +import { parsePipIndexVersionsJson, parsePipIndexVersionsText } from '../../../managers/builtin/pipPackageManager'; suite('Pip Version Parsing', () => { suite('parsePipIndexVersionsJson', () => { test('parses valid JSON with versions array', () => { const output = JSON.stringify({ name: 'requests', versions: ['2.31.0', '2.30.0', '2.29.0'] }); const versions = parsePipIndexVersionsJson(output); - assert.deepStrictEqual(versions, ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v))); + assert.deepStrictEqual( + versions, + ['2.31.0', '2.30.0', '2.29.0'].map((v) => explain(v)), + ); }); test('parses output with a single version', () => { @@ -33,5 +36,29 @@ suite('Pip Version Parsing', () => { assert.strictEqual(versions, undefined); }); }); -}); + suite('parsePipIndexVersionsText', () => { + test('parses and sorts the available versions line', () => { + const output = [ + 'requests (2.32.5)', + 'Available versions: 2.31.0, 2.32.5, 2.30.0', + ' INSTALLED: 2.31.0', + ' LATEST: 2.32.5', + ].join('\n'); + const versions = parsePipIndexVersionsText(output); + assert.deepStrictEqual( + versions, + ['2.32.5', '2.31.0', '2.30.0'].map((version) => explain(version)), + ); + }); + + test('returns undefined when the available versions line is missing', () => { + assert.strictEqual(parsePipIndexVersionsText('ERROR: No matching distribution found'), undefined); + }); + + test('ignores invalid versions', () => { + const versions = parsePipIndexVersionsText('Available versions: invalid, 1.2.3'); + assert.deepStrictEqual(versions, [explain('1.2.3')]); + }); + }); +}); diff --git a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts index 7e1e202be..12bf1c7b3 100644 --- a/src/test/managers/builtin/venvManager.createRemove.unit.test.ts +++ b/src/test/managers/builtin/venvManager.createRemove.unit.test.ts @@ -47,12 +47,11 @@ function createManager( const baseManager = { getEnvironments: sinon.stub().resolves(baseEnvironments), } as any as EnvironmentManager; - const manager = new VenvManager( - {} as NativePythonFinder, - api, - baseManager, - { info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any, - ); + const manager = new VenvManager({} as NativePythonFinder, api, baseManager, { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + } as any); (manager as any)._initialized = { completed: true, promise: Promise.resolve() }; (manager as any).collection = []; return manager; @@ -221,6 +220,17 @@ suite('VenvManager.remove - orchestration', () => { assert.strictEqual(events[0][0].environment, env); }); + test('forwards headless removal options to the removal helper', async () => { + const manager = createManager(); + const env = environment(); + removeVenvStub.resolves(true); + + await manager.remove(env, { runHeadless: true }); + + assert.strictEqual(removeVenvStub.firstCall.args[0], env); + assert.deepStrictEqual(removeVenvStub.firstCall.args[2], { runHeadless: true }); + }); + test('does not mutate state when the removal helper returns false', async () => { const manager = createManager(); const env = environment(); diff --git a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts index 068eb5dca..b1fae91bf 100644 --- a/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts +++ b/src/test/managers/builtin/venvUtils.removeVenv.unit.test.ts @@ -1,6 +1,13 @@ import * as assert from 'assert'; +import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; +import * as sinon from 'sinon'; +import * as windowApis from '../../../common/window.apis'; +import * as uvEnvironments from '../../../managers/builtin/uvEnvironments'; +import { removeVenv } from '../../../managers/builtin/venvUtils'; +import { createMockLogOutputChannel } from '../../mocks/helper'; +import { createMockPythonEnvironment } from '../../mocks/pythonEnvironment'; suite('venvUtils Path Validation', () => { suite('isDriveRoot behavior', () => { @@ -146,4 +153,28 @@ suite('venvUtils removeVenv validation integration', () => { 'Should check for pyvenv.cfg in the environment root', ); }); + + test('headless removal skips confirmation and removes the environment', async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'remove-venv-')); + const envPath = path.join(tempRoot, '.venv'); + await fs.outputFile(path.join(envPath, 'pyvenv.cfg'), 'home = base'); + const showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage'); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + sinon.stub(uvEnvironments, 'removeUvEnvironment').resolves(); + + try { + const removed = await removeVenv( + createMockPythonEnvironment({ name: '.venv', envPath }), + createMockLogOutputChannel(), + { runHeadless: true }, + ); + + assert.strictEqual(removed, true); + assert.strictEqual(showWarningMessageStub.callCount, 0); + assert.strictEqual(await fs.pathExists(envPath), false); + } finally { + sinon.restore(); + await fs.remove(tempRoot); + } + }); }); diff --git a/src/test/managers/common/packageChanges.unit.test.ts b/src/test/managers/common/packageChanges.unit.test.ts index 1f65b3c75..8f6f77402 100644 --- a/src/test/managers/common/packageChanges.unit.test.ts +++ b/src/test/managers/common/packageChanges.unit.test.ts @@ -127,6 +127,35 @@ suite('packageChanges', () => { assert.strictEqual(changes[0].kind, PackageChangeKind.add); }); + test('uses an operation-specific package fetcher when provided', async () => { + const fetched = [{ name: 'requests', version: '2.31.0' } as Package]; + const fetchPackages = sinon.stub().resolves(fetched); + const onChanges = sinon.stub(); + + const result = await updatePackagesAndNotify( + packageManager, + environment, + undefined, + onChanges, + fetchPackages, + ); + + assert.deepStrictEqual(result, fetched); + assert.ok(fetchPackages.calledOnce); + assert.ok(getPackagesStub.notCalled); + }); + + test('preserves undefined and does not report removals when fetching fails', async () => { + const before = [{ name: 'requests', version: '2.31.0' } as Package]; + getPackagesStub.resolves(undefined); + const onChanges = sinon.stub(); + + const result = await updatePackagesAndNotify(packageManager, environment, before, onChanges); + + assert.strictEqual(result, undefined); + assert.ok(onChanges.notCalled); + }); + test('does not fire callback when nothing changed', async () => { const pkgs = [{ name: 'requests', version: '2.31.0' } as Package]; getPackagesStub.resolves(pkgs); diff --git a/src/test/managers/conda/condaPackageManager.unit.test.ts b/src/test/managers/conda/condaPackageManager.unit.test.ts new file mode 100644 index 000000000..ea6614daf --- /dev/null +++ b/src/test/managers/conda/condaPackageManager.unit.test.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { LogOutputChannel, Uri } from 'vscode'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as errorUtils from '../../../common/errors/utils'; +import * as windowApis from '../../../common/window.apis'; +import { CondaPackageManager } from '../../../managers/conda/condaPackageManager'; +import * as condaUtils from '../../../managers/conda/condaUtils'; + +suite('CondaPackageManager', () => { + teardown(() => { + sinon.restore(); + }); + + test('headless package failures reject without showing error UI', async () => { + const environment = { + envId: { id: 'test-environment', managerId: 'test-manager' }, + environmentPath: Uri.file('/path/to/environment'), + } as PythonEnvironment; + const logError = sinon.stub(); + const log = { + error: logError, + } as unknown as LogOutputChannel; + const manager = new CondaPackageManager({} as PythonEnvironmentApi, log); + const operationError = new Error('conda install failed'); + sinon.stub(condaUtils, 'managePackages').rejects(operationError); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => task({} as never, {} as never)); + const showErrorMessageWithLogs = sinon.stub(errorUtils, 'showErrorMessageWithLogs').resolves(); + + await assert.rejects( + manager.manage(environment, { install: ['requests'], runHeadless: true }), + (error: unknown) => error === operationError, + ); + + assert.ok(logError.calledOnce); + assert.ok(showErrorMessageWithLogs.notCalled); + }); +}); From 8666b65eec9366f9e1ca36994abad71d9ca8b8f3 Mon Sep 17 00:00:00 2001 From: Lee Murray Date: Tue, 18 Aug 2026 12:07:31 +0100 Subject: [PATCH 2/7] Update logo.svg with new design and optimized dimensions (#1719) Replace the existing activity bar icon with a new design more aligned with the wider codicon design language. ![image.png](https://github.com/user-attachments/assets/65b0c66b-0e1a-4610-89d7-94206fafa044) Co-authored-by: mrleemurray --- files/logo.svg | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/files/logo.svg b/files/logo.svg index a999dbeac..f849d01e2 100644 --- a/files/logo.svg +++ b/files/logo.svg @@ -1,14 +1,12 @@ - - - + + + + + + - - - - - - - - + + + From 07a7d9ef48fbf1552602a462c542a746615ddb2b Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 12:59:13 -0700 Subject: [PATCH 3/7] Add conservative inline script cache clearing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cb82ae9-7424-40a4-9156-8c54ac6e0895 --- package.json | 11 + package.nls.json | 1 + src/extension.ts | 29 +- src/features/envCommands.ts | 50 ++ .../builtin/inlineScript/envManager.ts | 344 +++++++++++- src/managers/builtin/inlineScript/main.ts | 5 +- src/test/features/envCommands.unit.test.ts | 119 +++- src/test/features/envManagers.unit.test.ts | 68 +++ .../inlineScript/envManager.unit.test.ts | 507 +++++++++++++++++- .../builtin/inlineScript/main.unit.test.ts | 18 +- src/test/smoke/registration.smoke.test.ts | 39 +- 11 files changed, 1148 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index dd7cba3cf..fc37a6277 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,13 @@ "category": "Python", "icon": "$(trash)" }, + { + "command": "python-envs.clearInlineScriptCache", + "title": "%python-envs.clearInlineScriptCache.title%", + "category": "Python", + "icon": "$(trash)", + "enablement": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" + }, { "command": "python-envs.runInTerminal", "title": "%python-envs.runInTerminal.title%", @@ -414,6 +421,10 @@ "command": "python-envs.runAsTask", "when": "config.python.useEnvironmentsExtension != false" }, + { + "command": "python-envs.clearInlineScriptCache", + "when": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" + }, { "command": "python-envs.terminal.activate", "when": "pythonTerminalActivation" diff --git a/package.nls.json b/package.nls.json index 483ecfd29..538b5abb7 100644 --- a/package.nls.json +++ b/package.nls.json @@ -35,6 +35,7 @@ "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", + "python-envs.clearInlineScriptCache.title": "Clear Script Environment Cache", "python-envs.runInTerminal.title": "Run in Terminal", "python-envs.createTerminal.title": "Create Python Terminal", "python-envs.runAsTask.title": "Run as Task", diff --git a/src/extension.ts b/src/extension.ts index 46f89009b..4c51caf96 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,7 @@ import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from '. import { ENVS_EXTENSION_ID } from './common/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging'; -import { clearPersistentState, setPersistentState } from './common/persistentState'; +import { setPersistentState } from './common/persistentState'; import { newProjectSelection } from './common/pickers/managers'; import { StopWatch } from './common/stopWatch'; import { EventNames } from './common/telemetry/constants'; @@ -44,6 +44,8 @@ import { NewScriptProject } from './features/creators/newScriptProject'; import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, + clearCacheCommand, + clearInlineScriptCacheCommand, copyPathToClipboard, createAnyEnvironmentCommand, createEnvironmentCommand, @@ -96,6 +98,7 @@ import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; +import type { InlineScriptEnvManager } from './managers/builtin/inlineScript/envManager'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main'; import { registerSystemPythonFeatures } from './managers/builtin/main'; import { SysPythonManager } from './managers/builtin/sysPythonManager'; @@ -191,6 +194,7 @@ export async function activate(context: ExtensionContext): Promise { - await clearPersistentState(); - await envManagers.clearCache(undefined); - await clearShellProfileCache(shellStartupProviders); + await clearCacheCommand(envManagers, () => clearShellProfileCache(shellStartupProviders)); + }), + commands.registerCommand('python-envs.clearInlineScriptCache', async () => { + await clearInlineScriptCacheCommand(() => inlineScriptEnvManager); }), commands.registerCommand('python-envs.runInTerminal', (item) => { return runInTerminalCommand(item, api, terminalManager); @@ -666,13 +671,15 @@ export async function activate(context: ExtensionContext): Promise { + inlineScriptEnvManager = await registerInlineScriptFeatures( + nativeFinder, + context.subscriptions, + outputChannel, + sysMgr, + context.globalStorageUri, + ); + })(), ), safeRegister('shellStartupVars', shellStartupVarsMgr.initialize()), ]); diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 1de8a13a6..d5c0ef657 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -18,7 +18,10 @@ import { PythonProjectCreator, PythonProjectCreatorOptions, } from '../api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants'; import { traceError, traceInfo, traceVerbose } from '../common/logging'; +import { clearPersistentState } from '../common/persistentState'; +import type { InlineScriptEnvManager } from '../managers/builtin/inlineScript/envManager'; import { EnvironmentManagers, InternalEnvironmentManager, @@ -26,6 +29,8 @@ import { ProjectCreators, PythonProjectManager, } from '../internal.api'; +import { isInlineScriptsFeatureEnabled } from '../helpers'; +import { waitForEnvManagerId } from './common/managerReady'; import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers'; import { valid as pep440Valid } from '@renovatebot/pep440'; @@ -50,6 +55,7 @@ import { showInputBox, showOpenDialog, showQuickPick, + showWarningMessage, withProgress, } from '../common/window.apis'; import { runAsTask } from './execution/runAsTask'; @@ -306,6 +312,50 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir } } +export async function clearCacheCommand( + envManagers: EnvironmentManagers, + clearShellProfileCache: () => Promise, +): Promise { + await clearPersistentState(); + await envManagers.clearCache(undefined); + await clearShellProfileCache(); +} + +export async function clearInlineScriptCacheCommand( + getManager: () => InlineScriptEnvManager | undefined | Promise, +): Promise { + if (!isInlineScriptsFeatureEnabled()) { + const message = l10n.t( + 'Script environment cache is unavailable because inline script environments are disabled in this window.', + ); + showErrorMessage(message); + throw new Error(message); + } + + await waitForEnvManagerId([INLINE_SCRIPT_MANAGER_ID]); + const manager = await getManager(); + if (!manager) { + const message = l10n.t( + 'Script environment cache is unavailable because the inline script environment manager is not available in this window.', + ); + showErrorMessage(message); + throw new Error(message); + } + + const clearLabel = l10n.t('Clear Cache'); + const confirm = await showWarningMessage( + l10n.t('Delete cached environments created for inline Python scripts?'), + { modal: true }, + clearLabel, + l10n.t('Cancel'), + ); + if (confirm !== clearLabel) { + return; + } + + await manager.clearScriptCache(); +} + export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) { if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { if (context.pkg.isTransitive) { diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index d68d9dda6..acfb64568 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -24,6 +24,7 @@ import { getErrorMessage } from '../../../common/errors/utils'; import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey'; import { CacheEnvironmentInspection, + INLINE_SCRIPT_CACHE_DIR_NAME, META_SCHEMA_VERSION, getBaseInterpreterStatus, getScriptEnvCacheRoot, @@ -59,8 +60,10 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([ PYENV_MANAGER_ID, ]); -const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000; -const CACHE_LOCK_RETRY_MS = 500; +const CACHE_CLEAR_ROOT_LOCK_TIMEOUT_MS = 1_000; +const CACHE_CLEAR_ROOT_LOCK_RETRY_MS = 50; +const CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS = 1_000; +const CACHE_CREATE_HANDOFF_LOCK_RETRY_MS = 50; const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000; /** Workspace-state key for PEP 723 script path to environment executable associations. */ export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`; @@ -86,6 +89,8 @@ type CacheEntryInspection = | { readonly kind: 'absent' | 'stale' | 'uncertain' } | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; +type CacheLockDisposition = 'retained' | 'active' | 'unknown'; + /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingSetups = new Map>(); @@ -99,6 +104,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly associationRevisions = new Map(); private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); + private activeCreateCount = 0; + private isClearCacheInProgress = false; private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -129,6 +136,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions, ): Promise { + if (this.isClearCacheInProgress) { + throw this.createCacheOperationConflict( + l10n.t( + 'Cannot create an inline script environment while the script environment cache is being cleared. Retry after the cache clear finishes.', + ), + ); + } + this.activeCreateCount += 1; try { const scriptUri = this.getScriptUri(scope); if (!scriptUri) { @@ -167,8 +182,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } } } catch (error) { + if (error instanceof InlineScriptCacheOperationError) { + this.log.warn(error.message); + throw error; + } this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); return undefined; + } finally { + this.activeCreateCount -= 1; } } @@ -247,6 +268,64 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } + async clearScriptCache(): Promise { + if (this.isClearCacheInProgress) { + throw this.createCacheOperationConflict( + l10n.t('Script environment cache clear is already in progress.'), + ); + } + this.isClearCacheInProgress = true; + + try { + if (this.activeCreateCount > 0) { + throw this.createCacheOperationConflict( + l10n.t( + 'Cannot clear the script environment cache while another inline script environment operation may still be using it. Close other VS Code windows or restart VS Code, then retry.', + ), + ); + } + + const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); + let rootLock: AcquiredFileLock | undefined = await this.acquireCacheRootLock(cacheRoot, { + timeoutMs: CACHE_CLEAR_ROOT_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_CLEAR_ROOT_LOCK_RETRY_MS, + }, 'clear'); + try { + const clearableCacheRoot = await this.getClearableCacheRootPath(cacheRoot); + if (clearableCacheRoot) { + await this.assertNoCacheLocks(clearableCacheRoot); + await this.removeClearableCacheRoot(clearableCacheRoot); + } + + let persistError: unknown; + try { + await this.clearPersistedAssociations(); + } catch (error) { + persistError = error; + } + + this.clearKnownAssociations(); + + if (persistError) { + throw persistError; + } + } finally { + const lockToRelease = rootLock; + rootLock = undefined; + await this.releaseCacheRootLockOrThrow(lockToRelease, cacheRoot.fsPath); + } + } catch (error) { + if (error instanceof InlineScriptCacheOperationError) { + this.log.warn(error.message); + } else { + this.log.error(`Failed to clear inline-script cache: ${getErrorMessage(error)}`); + } + throw error; + } finally { + this.isClearCacheInProgress = false; + } + } + private getScriptUri(scope: CreateEnvironmentScope): Uri | undefined { const uri = scope instanceof Uri ? scope : Array.isArray(scope) && scope.length === 1 ? scope[0] : undefined; return uri?.scheme === 'file' ? uri : undefined; @@ -741,6 +820,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }); } + private clearPersistedAssociations(): Promise { + return this.enqueuePersistence((state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + } + private asPersistedAssociations(value: unknown): PersistedInlineScriptEnvironments | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; @@ -769,6 +852,232 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } + private clearKnownAssociations(): void { + const cleared = [...this.fsPathToEnv.entries()].map(([scriptPath, old]) => ({ + uri: Uri.file(scriptPath), + old, + new: undefined as PythonEnvironment | undefined, + })); + const knownScriptPaths = new Set([ + ...this.associationRevisions.keys(), + ...this.pendingRehydrations.keys(), + ...this.fsPathToPersistedEnvPath.keys(), + ...this.fsPathToEnv.keys(), + ]); + for (const scriptPath of knownScriptPaths) { + this.bumpAssociationRevision(scriptPath); + this.pendingRehydrations.delete(scriptPath); + } + this.fsPathToEnv.clear(); + this.fsPathToPersistedEnvPath.clear(); + this.cachedAssociationValidatedAt.clear(); + + cleared.forEach((event) => this._onDidChangeEnvironment.fire(event)); + } + + private async getClearableCacheRootPath(cacheRoot: Uri): Promise { + const globalStoragePath = path.resolve(this.globalStorageUri.fsPath); + let globalStorageStat: fs.Stats; + try { + globalStorageStat = await fs.lstat(globalStoragePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + if (!globalStorageStat.isDirectory() || globalStorageStat.isSymbolicLink()) { + throw this.createUnsafeClearTargetError(globalStoragePath); + } + + const resolvedGlobalStorage = await fs.realpath(globalStoragePath); + if (normalizePath(resolvedGlobalStorage) !== normalizePath(globalStoragePath)) { + throw this.createUnsafeClearTargetError(globalStoragePath); + } + + const cacheRootPath = path.resolve(cacheRoot.fsPath); + try { + const cacheRootStat = await fs.lstat(cacheRootPath); + if (!cacheRootStat.isDirectory() || cacheRootStat.isSymbolicLink()) { + throw this.createUnsafeClearTargetError(cacheRootPath); + } + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + const resolvedCacheRoot = await resolveCacheEntryPath(Uri.file(globalStoragePath), Uri.file(cacheRootPath)); + const expectedCacheRoot = path.join(resolvedGlobalStorage, INLINE_SCRIPT_CACHE_DIR_NAME); + if (!resolvedCacheRoot || normalizePath(resolvedCacheRoot) !== normalizePath(expectedCacheRoot)) { + throw this.createUnsafeClearTargetError(cacheRootPath); + } + + return resolvedCacheRoot; + } + + private async acquireCacheRootLock( + cacheRoot: Uri, + options: { + timeoutMs: number; + retryIntervalMs: number; + }, + operation: 'create' | 'clear', + ): Promise { + await fs.ensureDir(path.dirname(cacheRoot.fsPath)); + const lockPath = this.getLockPath(cacheRoot.fsPath); + try { + return await acquireFileLock(cacheRoot.fsPath, options); + } catch (error) { + if (this.isBusyLockError(error)) { + throw this.createCacheRootBusyError(operation, lockPath); + } + throw error; + } + } + + private async assertNoCacheLocks(cacheRootPath: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(cacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + throw error; + } + + for (const entry of entries.filter((candidate) => candidate.endsWith('.lock'))) { + const lockPath = path.join(cacheRootPath, entry); + const lockDisposition = await this.inspectCacheLock(lockPath); + if (lockDisposition === 'active') { + throw this.createActiveLockError(lockPath); + } + if (lockDisposition === 'unknown') { + throw this.createUnknownLockError(lockPath); + } + } + } + + private removeClearableCacheRoot(cacheRootPath: string): Promise { + return fs.remove(cacheRootPath); + } + + private async inspectCacheLock(lockPath: string): Promise { + try { + const lockStat = await fs.lstat(lockPath); + if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) { + return 'unknown'; + } + } catch { + return 'unknown'; + } + + const retainedPath = path.join(lockPath, 'retained'); + try { + const retainedStat = await fs.lstat(retainedPath); + if (retainedStat.isFile()) { + return 'retained'; + } + return 'unknown'; + } catch (error) { + if (!isFileNotFoundError(error)) { + return 'unknown'; + } + } + + try { + return (await fs.readdir(lockPath)).some((entry) => entry.startsWith('owner-')) ? 'active' : 'unknown'; + } catch { + return 'unknown'; + } + } + + private createUnsafeClearTargetError(targetPath: string): Error { + return new Error( + l10n.t( + 'Cannot clear the script environment cache because the target could not be proven safe: {0}', + targetPath, + ), + ); + } + + private createCacheOperationConflict(message: string): InlineScriptCacheOperationError { + return new InlineScriptCacheOperationError(message); + } + + private createCacheRootBusyError(operation: 'create' | 'clear', lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + operation === 'clear' + ? l10n.t( + 'Cannot clear the script environment cache because the cache root lock at {0} may still be active or may have been left by an interrupted operation. Wait for current work to finish or close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', + lockPath, + ) + : l10n.t( + 'Inline script environment cache is busy because the cache root lock at {0} may still be active or may have been left by an interrupted operation. Wait for current work to finish or close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', + lockPath, + ), + ); + } + + private createActiveLockError(lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + l10n.t( + 'Cannot clear the script environment cache because the owner-only lock at {0} may still be active or may have been left by an interrupted operation. Close other VS Code windows and retry. If it persists after restart, manually remove only this lock path after confirming that no inline script cache operation is using it.', + lockPath, + ), + ); + } + + private createUnknownLockError(lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + l10n.t( + 'Cannot clear the script environment cache because the cache lock at {0} could not be verified as retained. Remove it manually only if you know no inline script environment operation still needs it.', + lockPath, + ), + ); + } + + private createCacheRootReleaseError(lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + l10n.t( + 'Failed to release the script environment cache root lock at {0}. Close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', + lockPath, + ), + ); + } + + private async releaseCacheRootLockOrThrow(lock: AcquiredFileLock, cacheRootPath: string): Promise { + const lockPath = this.getLockPath(cacheRootPath); + try { + await lock.release(); + } catch { + throw this.createCacheRootReleaseError(lockPath); + } + } + + private async releaseCacheLock(lock: AcquiredFileLock, label: string): Promise { + try { + await lock.release(); + } catch (error) { + this.log.warn(`Failed to release ${label} lock: ${getErrorMessage(error)}`); + } + } + + private isBusyLockError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + ['ELOCKED', 'ELOCKRETAINED'].includes((error as NodeJS.ErrnoException).code ?? '') + ); + } + + private getLockPath(targetPath: string): string { + return `${path.resolve(targetPath)}.lock`; + } + private async isCacheEntryBusy(envDirPath: string): Promise { return ( this.pendingCreations.has(path.basename(envDirPath)) || @@ -1060,14 +1369,22 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }: CreateOrReuseEnvironmentOptions): Promise { const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); - await fs.ensureDir(cacheRoot.fsPath); + let rootLock: AcquiredFileLock | undefined; let lock: AcquiredFileLock | undefined; try { + rootLock = await this.acquireCacheRootLock(cacheRoot, { + timeoutMs: CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_CREATE_HANDOFF_LOCK_RETRY_MS, + }, 'create'); + await fs.ensureDir(cacheRoot.fsPath); lock = await acquireFileLock(envDir.fsPath, { - timeoutMs: CACHE_LOCK_TIMEOUT_MS, - retryIntervalMs: CACHE_LOCK_RETRY_MS, + timeoutMs: CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_CREATE_HANDOFF_LOCK_RETRY_MS, }); + const handoffRootLock = rootLock; + rootLock = undefined; + await this.releaseCacheRootLockOrThrow(handoffRootLock, cacheRoot.fsPath); const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase); if (cached.kind === 'reusable') { @@ -1097,15 +1414,20 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } return build.environment; } catch (error) { + if (error instanceof InlineScriptCacheOperationError) { + this.log.warn(error.message); + return undefined; + } this.log.error(`Failed to create or reuse inline-script cache entry: ${getErrorMessage(error)}`); return undefined; } finally { if (lock) { - try { - await lock.release(); - } catch (error) { - this.log.warn(`Failed to release inline-script cache lock: ${getErrorMessage(error)}`); - } + await this.releaseCacheLock(lock, 'inline-script cache entry'); + } + if (rootLock) { + const lockToRelease = rootLock; + rootLock = undefined; + await this.releaseCacheRootLockOrThrow(lockToRelease, cacheRoot.fsPath); } } } @@ -1306,3 +1628,5 @@ interface PendingScriptUpdate extends ScriptReference { readonly needsPersistence: boolean; readonly shouldNotify: boolean; } + +class InlineScriptCacheOperationError extends Error {} diff --git a/src/managers/builtin/inlineScript/main.ts b/src/managers/builtin/inlineScript/main.ts index 8c35fc6ed..94531313f 100644 --- a/src/managers/builtin/inlineScript/main.ts +++ b/src/managers/builtin/inlineScript/main.ts @@ -20,14 +20,15 @@ export async function registerInlineScriptFeatures( log: LogOutputChannel, baseManager: EnvironmentManager, globalStorageUri: Uri, -): Promise { +): Promise { if (!isInlineScriptsFeatureEnabled()) { traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); - return; + return undefined; } const api: PythonEnvironmentApi = await getPythonApi(); const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); disposables.push(mgr, api.registerEnvironmentManager(mgr)); traceInfo('Inline-script env manager: registered (internal flag is on)'); + return mgr; } diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 079ca9d73..6fd6229d6 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -6,8 +6,19 @@ import { PythonEnvironment, PythonProject } from '../../api'; import * as commandApi from '../../common/command.api'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; -import { createAnyEnvironmentCommand, removePythonProject, revealEnvInManagerView } from '../../features/envCommands'; +import * as persistentState from '../../common/persistentState'; +import * as windowApis from '../../common/window.apis'; +import { + clearCacheCommand, + clearInlineScriptCacheCommand, + createAnyEnvironmentCommand, + removePythonProject, + revealEnvInManagerView, +} from '../../features/envCommands'; +import * as managerReady from '../../features/common/managerReady'; import * as settingHelpers from '../../features/settings/settingHelpers'; +import * as helpers from '../../helpers'; +import type { InlineScriptEnvManager } from '../../managers/builtin/inlineScript/envManager'; import { EnvManagerView } from '../../features/views/envManagersView'; import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; import { EnvironmentManagers, InternalEnvironmentManager, PythonProjectManager } from '../../internal.api'; @@ -216,6 +227,112 @@ suite('Remove Python Project Command Tests', () => { }); }); +suite('Clear Cache Command Tests', () => { + teardown(() => { + sinon.restore(); + }); + + test('keeps the broad clear handler on the base path', async () => { + const calls: string[] = []; + const envManagers = { + clearCache: sinon.stub().callsFake(async (scope: unknown) => { + calls.push(`managers:${String(scope)}`); + }), + } as unknown as EnvironmentManagers; + const clearShellProfileCache = sinon.stub().callsFake(async () => { + calls.push('shell'); + }); + sinon.stub(persistentState, 'clearPersistentState').callsFake(async () => { + calls.push('state'); + }); + + await clearCacheCommand(envManagers, clearShellProfileCache); + + assert.deepStrictEqual(calls, ['state', 'managers:undefined', 'shell']); + assert.ok((envManagers.clearCache as sinon.SinonStub).calledOnceWithExactly(undefined)); + assert.ok(clearShellProfileCache.calledOnce); + }); +}); + +suite('Clear Inline Script Environment Cache Command Tests', () => { + let clearScriptCacheStub: sinon.SinonStub; + let getManager: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let showWarningMessageStub: sinon.SinonStub; + let isInlineScriptsFeatureEnabledStub: sinon.SinonStub; + let waitForEnvManagerIdStub: sinon.SinonStub; + + setup(() => { + clearScriptCacheStub = sinon.stub().resolves(); + getManager = sinon + .stub<[], InlineScriptEnvManager | undefined>() + .returns({ clearScriptCache: clearScriptCacheStub } as unknown as InlineScriptEnvManager); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage'); + showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage'); + isInlineScriptsFeatureEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled').returns(true); + waitForEnvManagerIdStub = sinon.stub(managerReady, 'waitForEnvManagerId').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('clears the cache after confirmation', async () => { + showWarningMessageStub.callsFake(async (_message, _options, clearLabel: string) => clearLabel); + + await clearInlineScriptCacheCommand(getManager); + + assert.ok(showWarningMessageStub.calledOnce); + assert.deepStrictEqual(showWarningMessageStub.firstCall.args[1], { modal: true }); + assert.ok(waitForEnvManagerIdStub.calledOnce); + assert.ok(getManager.calledOnce); + assert.ok(clearScriptCacheStub.calledOnce); + assert.strictEqual(showErrorMessageStub.called, false); + }); + + test('does nothing when the confirmation is cancelled', async () => { + showWarningMessageStub.resolves(undefined); + + await clearInlineScriptCacheCommand(getManager); + + assert.ok(showWarningMessageStub.calledOnce); + assert.ok(waitForEnvManagerIdStub.calledOnce); + assert.ok(getManager.calledOnce); + assert.strictEqual(clearScriptCacheStub.called, false); + assert.strictEqual(showErrorMessageStub.called, false); + }); + + test('fails fast when the feature setting is off', async () => { + isInlineScriptsFeatureEnabledStub.returns(false); + showErrorMessageStub.resolves(undefined); + + await assert.rejects( + clearInlineScriptCacheCommand(getManager), + /inline script environments are disabled in this window/i, + ); + + assert.ok(showErrorMessageStub.calledOnce); + assert.strictEqual(waitForEnvManagerIdStub.called, false); + assert.strictEqual(getManager.called, false); + assert.strictEqual(showWarningMessageStub.called, false); + }); + + test('throws a clear error when the manager is unavailable after the readiness wait', async () => { + getManager.returns(undefined); + showErrorMessageStub.resolves(undefined); + + await assert.rejects( + clearInlineScriptCacheCommand(getManager), + /inline script environment manager is not available in this window/i, + ); + + assert.ok(waitForEnvManagerIdStub.calledOnce); + assert.ok(getManager.calledOnce); + assert.ok(showErrorMessageStub.calledOnce); + assert.strictEqual(showWarningMessageStub.called, false); + }); +}); + suite('Reveal Env In Manager View Command Tests', () => { let managerView: typeMoq.IMock; let executeCommandStub: sinon.SinonStub; diff --git a/src/test/features/envManagers.unit.test.ts b/src/test/features/envManagers.unit.test.ts index d642fa948..42a64579e 100644 --- a/src/test/features/envManagers.unit.test.ts +++ b/src/test/features/envManagers.unit.test.ts @@ -7,6 +7,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import { PythonEnvironment } from '../../api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; import * as frameUtils from '../../common/utils/frameUtils'; import * as workspaceApis from '../../common/workspace.apis'; import { PythonEnvironmentManagers } from '../../features/envManagers'; @@ -336,3 +337,70 @@ suite('PythonEnvironmentManagers - refreshEnvironment', () => { await envManagers.refreshEnvironment(Uri.file('/unknown/path')); }); }); + +suite('PythonEnvironmentManagers - clearCache', () => { + let sandbox: sinon.SinonSandbox; + let envManagers: PythonEnvironmentManagers; + let mockProjectManager: sinon.SinonStubbedInstance; + + setup(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(frameUtils, 'getCallingExtension').returns('ms-python.python'); + sandbox.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string, defaultValue?: unknown) => { + if (key === 'defaultEnvManager') { + return 'ms-python.python:system'; + } + if (key === 'pythonProjects') { + return []; + } + return defaultValue; + }, + has: () => false, + inspect: () => undefined, + update: () => Promise.resolve(), + } as any); + + mockProjectManager = { + getProjects: sandbox.stub().returns([]), + get: sandbox.stub().returns(undefined), + } as unknown as sinon.SinonStubbedInstance; + + envManagers = new PythonEnvironmentManagers(mockProjectManager as unknown as PythonProjectManager); + }); + + teardown(() => { + sandbox.restore(); + }); + + function registerFakeManager(managerId: string, clearCache: sinon.SinonStub): void { + envManagers.registerEnvironmentManager( + { + name: managerId.split(':')[1], + displayName: managerId, + preferredPackageManagerId: 'ms-python.python:pip', + clearCache, + get: sandbox.stub().resolves(undefined), + set: sandbox.stub().resolves(), + resolve: sandbox.stub().resolves(undefined), + refresh: sandbox.stub().resolves(), + getEnvironments: sandbox.stub().resolves([]), + onDidChangeEnvironments: sandbox.stub().returns({ dispose: () => {} }), + onDidChangeEnvironment: sandbox.stub().returns({ dispose: () => {} }), + } as any, + { extensionId: 'ms-python.python' }, + ); + } + + test('does not special-case managers during broad cache clears', async () => { + const systemClearCache = sandbox.stub().resolves(); + const inlineClearCache = sandbox.stub().resolves(); + registerFakeManager('ms-python.python:system', systemClearCache); + registerFakeManager(INLINE_SCRIPT_MANAGER_ID, inlineClearCache); + + await envManagers.clearCache(undefined); + + assert.ok(systemClearCache.calledOnce); + assert.ok(inlineClearCache.calledOnce); + }); +}); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 3d0488cae..7508d3739 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -100,6 +100,7 @@ suite('InlineScriptEnvManager', () => { let ensureUvForVersionLookupStub: sinon.SinonStub; let globalStorageUri: Uri; let lockStub: sinon.SinonStub; + let log: LogOutputChannel; let manager: InlineScriptEnvManager; let nativeFinder: NativePythonFinder; let promptInstallPythonViaUvStub: sinon.SinonStub; @@ -144,7 +145,11 @@ suite('InlineScriptEnvManager', () => { persistedAssociations = value; } }), - clear: sinon.stub(), + clear: sinon.stub().callsFake(async (keys?: string[]) => { + if (!keys || keys.includes(INLINE_SCRIPT_ENVS_KEY)) { + persistedAssociations = undefined; + } + }), }; sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(workspaceState); @@ -180,7 +185,8 @@ suite('InlineScriptEnvManager', () => { }); clock = sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }); - manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); + log = makeFakeLog(); + manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); }); teardown(async () => { @@ -197,6 +203,10 @@ suite('InlineScriptEnvManager', () => { return cacheLayout.getScriptEnvDir(globalStorageUri, CACHE_KEY); } + function cacheRoot(): Uri { + return cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + } + function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta): void { inspectMetaStub.resolves({ kind: 'valid', metadata }); } @@ -232,6 +242,7 @@ suite('InlineScriptEnvManager', () => { test('exposes creation but leaves later-phase methods empty', async () => { const asInterface: EnvironmentManager = manager; assert.strictEqual(typeof asInterface.create, 'function'); + assert.strictEqual(asInterface.clearCache, undefined); assert.strictEqual(asInterface.remove, undefined); assert.strictEqual(asInterface.quickCreateConfig, undefined); assert.deepStrictEqual(await manager.getEnvironments('all'), []); @@ -968,16 +979,55 @@ suite('InlineScriptEnvManager', () => { false, 'inline-script cache entries must not be tracked as workspace uv environments', ); - assert.ok(releaseLockStub.calledOnce); - }); + assert.strictEqual(lockStub.callCount, 2); + assert.strictEqual(lockStub.firstCall.args[0], cacheRoot().fsPath); + assert.strictEqual(lockStub.secondCall.args[0], envDir().fsPath); + assert.strictEqual(releaseLockStub.callCount, 2); + }); + + test('acquires the cache root lock before the final cache-entry lock and releases root before build', async () => { + const rootRelease = sinon.stub().resolves(); + const entryRelease = sinon.stub().resolves(); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + return { + retain: sinon.stub().resolves(), + release: rootRelease, + }; + } + return { + retain: sinon.stub().resolves(), + release: entryRelease, + }; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + assert.ok(rootRelease.calledOnce, 'root lock should be released before build starts'); + assert.strictEqual(entryRelease.called, false, 'entry lock should remain held during build'); + const envDir = args[6] as string; + const selectedBase = args[4] as PythonEnvironment; + await fs.outputFile(getVenvPythonPath(envDir), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + selectedBase.version, + getVenvPythonPath(envDir), + envDir, + ), + }; + }); - test('uses a bounded cross-process lock at the final cache path', async () => { await manager.create(scriptUri()); - assert.strictEqual(lockStub.firstCall.args[0], envDir().fsPath); - const options = lockStub.firstCall.args[1]; - assert.ok(options.timeoutMs > 0); - assert.ok(options.retryIntervalMs > 0); + assert.strictEqual(lockStub.firstCall.args[0], cacheRoot().fsPath); + assert.strictEqual(lockStub.secondCall.args[0], envDir().fsPath); + const rootOptions = lockStub.firstCall.args[1]; + const entryOptions = lockStub.secondCall.args[1]; + assert.strictEqual(rootOptions.timeoutMs, 1_000); + assert.strictEqual(rootOptions.retryIntervalMs, 50); + assert.strictEqual(entryOptions.timeoutMs, 1_000); + assert.strictEqual(entryOptions.retryIntervalMs, 50); + assert.ok(rootRelease.calledOnce); + assert.ok(entryRelease.calledOnce); }); test('coalesces simultaneous same-key creation within one extension host', async () => { @@ -1022,14 +1072,110 @@ suite('InlineScriptEnvManager', () => { const [firstResult, secondResult] = await Promise.all([first, second]); assert.strictEqual(firstResult, secondResult); - assert.strictEqual(lockStub.callCount, 1); + assert.strictEqual(lockStub.callCount, 2); assert.strictEqual(createWithProgressStub.callCount, 1); }); - test('returns undefined without building when the cache lock cannot be acquired', async () => { + test('returns undefined without building when the cache root lock cannot be acquired', async () => { + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; lockStub.rejects(Object.assign(new Error('already locked'), { code: 'ELOCKED' })); assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(createWithProgressStub.callCount, 0); + sinon.assert.calledWithMatch( + log.warn as sinon.SinonStub, + sinon.match(new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i')), + ); + }); + + test('aborts before inspect/build when releasing the cache root lock for handoff fails', async () => { + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; + const rootRelease = sinon.stub().callsFake(async () => { + await fs.ensureDir(rootLockPath); + throw new Error('root release failed'); + }); + const entryRelease = sinon.stub().resolves(); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + return { + retain: sinon.stub().resolves(), + release: rootRelease, + }; + } + return { + retain: sinon.stub().resolves(), + release: entryRelease, + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.ok(rootRelease.calledOnce); + assert.ok(entryRelease.calledOnce); + assert.strictEqual(await fs.pathExists(rootLockPath), true); + sinon.assert.calledWithMatch( + log.warn as sinon.SinonStub, + sinon.match(new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i')), + ); + }); + + test('allows different cache entries to build concurrently after the root-to-entry handoff', async () => { + const secondCacheKey = 'fedcba9876543210'; + const secondEnvDir = cacheLayout.getScriptEnvDir(globalStorageUri, secondCacheKey); + computeCacheKeyStub.onFirstCall().returns(CACHE_KEY); + computeCacheKeyStub.onSecondCall().returns(secondCacheKey); + + let releaseFirstBuild: (() => void) | undefined; + const firstBuildGate = new Promise((resolve) => { + releaseFirstBuild = resolve; + }); + const secondBuildStarted = sinon.stub(); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + if (target === envDir().fsPath) { + await firstBuildGate; + } else if (target === secondEnvDir.fsPath) { + secondBuildStarted(); + } + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + }; + }); + + const first = manager.create(scriptUri('first.py')); + let second: Promise | undefined; + try { + await waitForStubCall(createWithProgressStub); + second = manager.create(scriptUri('second.py')); + await waitForStubCall(secondBuildStarted); + assert.ok(secondBuildStarted.calledOnce); + assert.strictEqual(createWithProgressStub.callCount, 2); + } finally { + releaseFirstBuild?.(); + await Promise.allSettled([first, second ?? Promise.resolve(undefined)]); + } + }); + + test('releases the cache root lock when the per-entry lock cannot be acquired', async () => { + const rootRelease = sinon.stub().resolves(); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + return { + retain: sinon.stub().resolves(), + release: rootRelease, + }; + } + throw Object.assign(new Error('entry locked'), { code: 'ELOCKED' }); + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.ok(rootRelease.calledOnce); }); }); @@ -1362,7 +1508,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await fs.pathExists(envDir().fsPath), true); assert.strictEqual(writeMetaStub.callCount, 0); assert.ok(retainLockStub.calledOnce); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('keeps a failed lock-retain transition fail-closed', async () => { @@ -1380,7 +1526,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.ok(retainLockStub.calledOnce); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('removes the partial environment when package installation fails', async () => { @@ -1401,7 +1547,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); assert.strictEqual(writeMetaStub.callCount, 0); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('removes the new environment when sidecar writing fails', async () => { @@ -1409,7 +1555,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('removes a partial environment when createWithProgress throws', async () => { @@ -1420,7 +1566,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('rejects and removes a created environment with a different Python release', async () => { @@ -1465,6 +1611,335 @@ suite('InlineScriptEnvManager', () => { }); }); + suite('clear cache', () => { + test('treats a missing cache root as idempotent and clears persisted associations', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set(uri, environment); + listener.resetHistory(); + await fs.remove(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath); + + await manager.clearScriptCache(); + await manager.clearScriptCache(); + + assert.strictEqual(workspaceState.clear.callCount, 2); + assert.deepStrictEqual(workspaceState.clear.firstCall.args[0], [INLINE_SCRIPT_ENVS_KEY]); + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(uri), undefined); + sinon.assert.calledOnceWithMatch(listener, { old: environment, new: undefined }); + }); + + test('removes the cache root, clears state, and notifies known associations', async () => { + const firstUri = scriptUri('first.py'); + const secondUri = scriptUri('second.py'); + const firstEnvironment = await createOwnedEnvironment(); + const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set([firstUri, secondUri], firstEnvironment); + await manager.set(secondUri, secondEnvironment); + listener.resetHistory(); + + await manager.clearScriptCache(); + + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(firstUri), undefined); + assert.strictEqual(await manager.get(secondUri), undefined); + assert.strictEqual(listener.callCount, 2); + assert.strictEqual(listener.firstCall.args[0].old, firstEnvironment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + assert.strictEqual(listener.secondCall.args[0].old, secondEnvironment); + assert.strictEqual(listener.secondCall.args[0].new, undefined); + }); + + test('refuses to clear while a create is active', async () => { + let releaseMetadata: ((value: metadataReader.InlineScriptMetadata | undefined) => void) | undefined; + readMetadataStub.callsFake( + () => + new Promise((resolve) => { + releaseMetadata = resolve; + }), + ); + + const createPromise = manager.create(scriptUri()); + + await assert.rejects( + manager.clearScriptCache(), + /Close other VS Code windows or restart VS Code, then retry/i, + ); + + releaseMetadata!(VALID_METADATA); + assert.ok(await createPromise); + }); + + test('refuses create requests while a clear is in progress', async () => { + let clearStarted: (() => void) | undefined; + let releaseClear: (() => void) | undefined; + const started = new Promise((resolve) => { + clearStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseClear = resolve; + }); + const clearManager = manager as unknown as { + getClearableCacheRootPath(cacheRoot: Uri): Promise; + }; + sinon.stub(clearManager, 'getClearableCacheRootPath').callsFake(async () => { + clearStarted!(); + await gate; + return undefined; + }); + + const clearPromise = manager.clearScriptCache(); + await started; + + await assert.rejects(manager.create(scriptUri()), /cache is being cleared/i); + + releaseClear!(); + await clearPromise; + }); + + test('refuses to clear when the cache root lock is already held', async () => { + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + throw Object.assign(new Error('already locked'), { code: 'ELOCKED' }); + } + return { release: releaseLockStub, retain: retainLockStub }; + }); + + await assert.rejects( + manager.clearScriptCache(), + new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*remove only this lock path manually`, 'i'), + ); + assert.strictEqual(workspaceState.clear.callCount, 0); + }); + + test('rejects when cache deletion and state clear succeed but root lock release fails', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; + const rootRelease = sinon.stub().callsFake(async () => { + await fs.ensureDir(rootLockPath); + throw new Error('root release failed'); + }); + await manager.set(uri, environment); + lockStub.callsFake(async () => ({ + retain: sinon.stub().resolves(), + release: rootRelease, + })); + + await assert.rejects( + manager.clearScriptCache(), + new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i'), + ); + + assert.strictEqual(await fs.pathExists(cacheRoot().fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + assert.ok(rootRelease.calledOnce); + }); + + test('refuses clear after the root-to-entry handoff because the entry lock is visible on disk', async () => { + const otherManager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); + const entryLockPath = `${path.resolve(envDir().fsPath)}.lock`; + let releaseBuild: (() => void) | undefined; + const buildGate = new Promise((resolve) => { + releaseBuild = resolve; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + const selectedBase = args[4] as PythonEnvironment; + await fs.outputFile(getVenvPythonPath(target), ''); + await buildGate; + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + selectedBase.version, + getVenvPythonPath(target), + target, + ), + }; + }); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === envDir().fsPath) { + await fs.ensureDir(entryLockPath); + await fs.outputFile(path.join(entryLockPath, 'owner-1234'), ''); + return { + retain: sinon.stub().resolves(), + release: sinon.stub().callsFake(async () => { + await fs.remove(entryLockPath); + }), + }; + } + return { + retain: sinon.stub().resolves(), + release: sinon.stub().resolves(), + }; + }); + + const createPromise = manager.create(scriptUri()); + try { + await waitForStubCall(createWithProgressStub); + await assert.rejects(otherManager.clearScriptCache(), /owner-only lock/i); + } finally { + releaseBuild!(); + await createPromise; + otherManager.dispose(); + } + }); + + test('allows retained lock directories to be removed with the cache root', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const lockPath = path.join(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath, `${CACHE_KEY}.lock`); + await fs.ensureDir(lockPath); + await fs.outputFile(path.join(lockPath, 'owner-1234'), ''); + await fs.outputFile(path.join(lockPath, 'retained'), ''); + + await manager.clearScriptCache(); + + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + }); + + test('rejects active owner lock directories', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const lockPath = path.join(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath, `${CACHE_KEY}.lock`); + await fs.ensureDir(lockPath); + await fs.outputFile(path.join(lockPath, 'owner-1234'), ''); + + await assert.rejects( + manager.clearScriptCache(), + new RegExp(`${lockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually remove`, 'i'), + ); + + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + }); + + test('rejects orphaned or malformed lock entries', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const cacheRootPath = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; + const lockPath = path.join(cacheRootPath, `${CACHE_KEY}.lock`); + await manager.set(uri, environment); + + await fs.ensureDir(lockPath); + await assert.rejects(manager.clearScriptCache(), /could not be verified as retained/i); + await fs.remove(lockPath); + + await fs.outputFile(lockPath, 'not a directory'); + await assert.rejects(manager.clearScriptCache(), /could not be verified as retained/i); + + assert.strictEqual(await fs.pathExists(cacheRootPath), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + }); + + test('fails closed when the cache root is redirected through a symlink or junction', async function () { + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + const externalRoot = path.join(tempRoot, 'external-cache-root'); + const markerPath = path.join(externalRoot, 'keep.txt'); + await fs.ensureDir(globalStorageUri.fsPath); + await fs.remove(cacheRoot.fsPath); + await fs.outputFile(markerPath, 'keep'); + try { + await fs.symlink(externalRoot, cacheRoot.fsPath, isWindows() ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + return; + } + throw error; + } + + await assert.rejects(manager.clearScriptCache(), /could not be proven safe/i); + + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual((await fs.lstat(cacheRoot.fsPath)).isSymbolicLink(), true); + }); + + test('surfaces state clear failures after removing the cache root and clearing in-memory state', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set(uri, environment); + listener.resetHistory(); + workspaceState.clear.rejects(new Error('Memento unavailable')); + + await assert.rejects(manager.clearScriptCache(), /Memento unavailable/); + + const clearState = manager as unknown as { + fsPathToEnv: Map; + fsPathToPersistedEnvPath: Map; + }; + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + assert.strictEqual(clearState.fsPathToEnv.size, 0); + assert.strictEqual(clearState.fsPathToPersistedEnvPath.size, 0); + sinon.assert.calledOnceWithMatch(listener, { old: environment, new: undefined }); + }); + + test('surfaces disk deletion failures without clearing state', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const cacheRootPath = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; + const clearManager = manager as unknown as { + removeClearableCacheRoot(cacheRootPath: string): Promise; + }; + sinon.stub(clearManager, 'removeClearableCacheRoot').rejects(new Error('disk busy')); + + await assert.rejects(manager.clearScriptCache(), /disk busy/); + + assert.strictEqual(await fs.pathExists(cacheRootPath), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(uri), environment); + }); + + test('does not let a pending rehydration repopulate after clear', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + + let resolvePending: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolvePending = resolve; + }), + ); + + const pendingGet = manager.get(uri); + await waitForStubCall(resolveVenvStub); + + await manager.clearScriptCache(); + resolvePending!(environment); + + assert.strictEqual(await pendingGet, undefined); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(persistedAssociations, undefined); + }); + }); + suite('events and disposal', () => { test('create does not establish an association or fire later-phase events', async () => { const environmentsListener = sinon.spy(); diff --git a/src/test/managers/builtin/inlineScript/main.unit.test.ts b/src/test/managers/builtin/inlineScript/main.unit.test.ts index d109e318d..1fec3cd12 100644 --- a/src/test/managers/builtin/inlineScript/main.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/main.unit.test.ts @@ -51,23 +51,37 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(false); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); + const result = await registerInlineScriptFeatures( + nativeFinder, + disposables, + makeFakeLog(), + baseManager, + globalStorageUri, + ); assert.strictEqual(disposables.length, 0, 'no disposables should be added when flag is off'); assert.strictEqual(getPythonApiStub.called, false, 'should not even call getPythonApi when gated off'); assert.strictEqual(registerEnvironmentManagerStub.called, false); + assert.strictEqual(result, undefined); }); test('when the feature flag is TRUE: registers the manager and pushes the disposable', async () => { isEnabledStub.returns(true); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); + const result = await registerInlineScriptFeatures( + nativeFinder, + disposables, + makeFakeLog(), + baseManager, + globalStorageUri, + ); assert.strictEqual(getPythonApiStub.callCount, 1); assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); assert.strictEqual(disposables.length, 2, 'expected manager + registration disposable'); const manager = registerEnvironmentManagerStub.firstCall.args[0]; + assert.strictEqual(result, manager); assert.ok(disposables.includes(manager), 'manager itself should be disposed'); assert.ok( disposables.includes(registerEnvironmentManagerStub.firstCall.returnValue), diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index bd8d469e0..176aba815 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -26,9 +26,10 @@ suite('Smoke: Registration Checks', function () { this.timeout(MAX_EXTENSION_ACTIVATION_TIME); let api: PythonEnvironmentApi; + let extension: vscode.Extension; suiteSetup(async function () { - const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID)!; assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); if (!extension.isActive) { @@ -65,6 +66,7 @@ suite('Smoke: Registration Checks', function () { 'python-envs.setPkgManager', 'python-envs.refreshAllManagers', 'python-envs.clearCache', + 'python-envs.clearInlineScriptCache', 'python-envs.searchSettings', // Package management @@ -113,6 +115,41 @@ suite('Smoke: Registration Checks', function () { ); }); + test('Clear cache commands are contributed from package.json', function () { + const clearCacheCommand = extension.packageJSON?.contributes?.commands?.find( + (item: { command: string }) => item.command === 'python-envs.clearCache', + ); + const clearInlineScriptCacheCommand = extension.packageJSON?.contributes?.commands?.find( + (item: { command: string }) => item.command === 'python-envs.clearInlineScriptCache', + ); + const clearInlineScriptCachePaletteEntry = extension.packageJSON?.contributes?.menus?.commandPalette?.find( + (item: { command: string }) => item.command === 'python-envs.clearInlineScriptCache', + ); + + assert.ok(clearCacheCommand, 'python-envs.clearCache should be contributed in package.json'); + assert.strictEqual(clearCacheCommand.category, 'Python'); + assert.strictEqual(clearCacheCommand.title, 'Clear Cache'); + + assert.ok( + clearInlineScriptCacheCommand, + 'python-envs.clearInlineScriptCache should be contributed in package.json', + ); + assert.strictEqual(clearInlineScriptCacheCommand.category, 'Python'); + assert.strictEqual(clearInlineScriptCacheCommand.title, 'Clear Script Environment Cache'); + assert.strictEqual( + clearInlineScriptCacheCommand.enablement, + 'config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true', + ); + assert.ok( + clearInlineScriptCachePaletteEntry, + 'python-envs.clearInlineScriptCache should have a command palette contribution', + ); + assert.strictEqual( + clearInlineScriptCachePaletteEntry.when, + 'config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true', + ); + }); + // ========================================================================= // API METHODS - All API methods must exist and be functions // ========================================================================= From 34f784489aff6d8d496222708960e80800bf3166 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 13:33:55 -0700 Subject: [PATCH 4/7] Restore complete inline script cache lifecycle cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cb82ae9-7424-40a4-9156-8c54ac6e0895 --- package.json | 15 +- package.nls.json | 2 +- src/common/lockfile.apis.ts | 94 +- src/extension.ts | 31 +- src/features/envCommands.ts | 123 +-- src/features/projectManager.ts | 5 +- src/features/settings/settingHelpers.ts | 237 ++++- .../builtin/inlineScript/envManager.ts | 718 ++++++++-------- src/managers/builtin/inlineScript/main.ts | 5 +- src/managers/builtin/venvUtils.ts | 4 +- src/test/common/lockfile.apis.unit.test.ts | 67 +- src/test/features/envCommands.unit.test.ts | 319 +++++-- src/test/features/envManagers.unit.test.ts | 68 -- .../projectManager.initialize.unit.test.ts | 159 ++++ .../settings/settingHelpers.unit.test.ts | 408 ++++++++- .../inlineScript/envManager.unit.test.ts | 810 +++++++----------- .../builtin/inlineScript/main.unit.test.ts | 18 +- src/test/smoke/registration.smoke.test.ts | 40 +- 18 files changed, 1948 insertions(+), 1175 deletions(-) diff --git a/package.json b/package.json index fc37a6277..0a9a6abaf 100644 --- a/package.json +++ b/package.json @@ -246,11 +246,10 @@ "icon": "$(trash)" }, { - "command": "python-envs.clearInlineScriptCache", - "title": "%python-envs.clearInlineScriptCache.title%", + "command": "python-envs.clearScriptEnvCache", + "title": "%python-envs.clearScriptEnvCache.title%", "category": "Python", - "icon": "$(trash)", - "enablement": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" + "icon": "$(trash)" }, { "command": "python-envs.runInTerminal", @@ -421,10 +420,6 @@ "command": "python-envs.runAsTask", "when": "config.python.useEnvironmentsExtension != false" }, - { - "command": "python-envs.clearInlineScriptCache", - "when": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" - }, { "command": "python-envs.terminal.activate", "when": "pythonTerminalActivation" @@ -476,6 +471,10 @@ { "command": "python-envs.reportIssue", "when": "config.python.useEnvironmentsExtension != false" + }, + { + "command": "python-envs.clearScriptEnvCache", + "when": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" } ], "view/item/context": [ diff --git a/package.nls.json b/package.nls.json index 538b5abb7..c128863de 100644 --- a/package.nls.json +++ b/package.nls.json @@ -35,7 +35,7 @@ "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", - "python-envs.clearInlineScriptCache.title": "Clear Script Environment Cache", + "python-envs.clearScriptEnvCache.title": "Clear Script Environment Cache", "python-envs.runInTerminal.title": "Run in Terminal", "python-envs.createTerminal.title": "Create Python Terminal", "python-envs.runAsTask.title": "Run as Task", diff --git a/src/common/lockfile.apis.ts b/src/common/lockfile.apis.ts index 1d8409056..2fbe10352 100644 --- a/src/common/lockfile.apis.ts +++ b/src/common/lockfile.apis.ts @@ -16,13 +16,31 @@ export interface AcquiredFileLock { readonly retain: () => Promise; } +export const FILE_LOCK_DIR_SUFFIX = '.lock'; +export const FILE_LOCK_OWNER_MARKER_PREFIX = 'owner-'; +export const FILE_LOCK_RETAINED_MARKER = 'retained'; + +export type ProcessLiveness = 'live' | 'dead' | 'unavailable'; +export type FileLockState = 'missing' | 'held' | 'retained' | 'stale' | 'orphaned' | 'malformed' | 'unavailable'; + +export interface InspectFileLockOptions { + readonly checkProcessLiveness?: (pid: number) => Promise; +} + type LockState = 'held' | 'released' | 'retained'; +export function getFileLockPath(filePath: string): string { + return `${path.resolve(filePath)}${FILE_LOCK_DIR_SUFFIX}`; +} + /** Acquire an atomic lock released only explicitly; interrupted operations remain locked. */ export async function acquireFileLock(filePath: string, options: AcquireFileLockOptions): Promise { - const lockPath = `${path.resolve(filePath)}.lock`; - const ownerMarker = path.join(lockPath, `owner-${process.pid}-${crypto.randomBytes(16).toString('hex')}`); - const retainedMarker = path.join(lockPath, 'retained'); + const lockPath = getFileLockPath(filePath); + const ownerMarker = path.join( + lockPath, + `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}`, + ); + const retainedMarker = path.join(lockPath, FILE_LOCK_RETAINED_MARKER); const deadline = Date.now() + options.timeoutMs; while (true) { @@ -100,9 +118,68 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock } } +export async function inspectFileLock(filePath: string, options?: InspectFileLockOptions): Promise { + const lockPath = getFileLockPath(filePath); + + let stat; + try { + stat = await fsapi.lstat(lockPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return 'missing'; + } + throw error; + } + + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return 'malformed'; + } + + const entries = await fsapi.readdir(lockPath); + const ownerEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX)); + const retainedEntries = entries.filter((entry) => entry === FILE_LOCK_RETAINED_MARKER); + const unknownEntries = entries.filter( + (entry) => !entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX) && entry !== FILE_LOCK_RETAINED_MARKER, + ); + + if (unknownEntries.length > 0 || ownerEntries.length > 1 || retainedEntries.length > 1) { + return 'malformed'; + } + if (retainedEntries.length === 1) { + return 'retained'; + } + if (ownerEntries.length === 1) { + const ownerPid = parseOwnerPid(ownerEntries[0]); + if (ownerPid === undefined) { + return 'malformed'; + } + const liveness = await (options?.checkProcessLiveness ?? getProcessLiveness)(ownerPid); + if (liveness === 'dead') { + return 'stale'; + } + return liveness === 'live' ? 'held' : 'unavailable'; + } + return 'orphaned'; +} + +export async function getProcessLiveness(pid: number): Promise { + try { + process.kill(pid, 0); + return 'live'; + } catch (error) { + if (hasErrorCode(error, 'ESRCH')) { + return 'dead'; + } + if (hasErrorCode(error, 'EPERM') || hasErrorCode(error, 'EACCES')) { + return 'unavailable'; + } + return 'unavailable'; + } +} + async function isRetainedLock(lockPath: string): Promise { try { - await fsapi.lstat(path.join(lockPath, 'retained')); + await fsapi.lstat(path.join(lockPath, FILE_LOCK_RETAINED_MARKER)); return true; } catch (error) { if (hasErrorCode(error, 'ENOENT')) { @@ -118,6 +195,15 @@ function hasErrorCode(error: unknown, code: string): boolean { ); } +function parseOwnerPid(entry: string): number | undefined { + const match = entry.match(/^owner-(\d+)-/); + if (!match) { + return undefined; + } + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; +} + function createLockError(message: string, code: string, lockPath: string): NodeJS.ErrnoException { return Object.assign(new Error(message), { code, path: lockPath }); } diff --git a/src/extension.ts b/src/extension.ts index 4c51caf96..e3735b4ad 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,7 @@ import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from '. import { ENVS_EXTENSION_ID } from './common/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging'; -import { setPersistentState } from './common/persistentState'; +import { clearPersistentState, setPersistentState } from './common/persistentState'; import { newProjectSelection } from './common/pickers/managers'; import { StopWatch } from './common/stopWatch'; import { EventNames } from './common/telemetry/constants'; @@ -44,9 +44,8 @@ import { NewScriptProject } from './features/creators/newScriptProject'; import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, - clearCacheCommand, - clearInlineScriptCacheCommand, copyPathToClipboard, + clearScriptEnvironmentCacheCommand, createAnyEnvironmentCommand, createEnvironmentCommand, createTerminalCommand, @@ -98,7 +97,6 @@ import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; -import type { InlineScriptEnvManager } from './managers/builtin/inlineScript/envManager'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main'; import { registerSystemPythonFeatures } from './managers/builtin/main'; import { SysPythonManager } from './managers/builtin/sysPythonManager'; @@ -194,7 +192,6 @@ export async function activate(context: ExtensionContext): Promise { - await clearCacheCommand(envManagers, () => clearShellProfileCache(shellStartupProviders)); + await clearPersistentState(); + await envManagers.clearCache(undefined); + await clearShellProfileCache(shellStartupProviders); }), - commands.registerCommand('python-envs.clearInlineScriptCache', async () => { - await clearInlineScriptCacheCommand(() => inlineScriptEnvManager); + commands.registerCommand('python-envs.clearScriptEnvCache', async () => { + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); }), commands.registerCommand('python-envs.runInTerminal', (item) => { return runInTerminalCommand(item, api, terminalManager); @@ -671,15 +670,13 @@ export async function activate(context: ExtensionContext): Promise { - inlineScriptEnvManager = await registerInlineScriptFeatures( - nativeFinder, - context.subscriptions, - outputChannel, - sysMgr, - context.globalStorageUri, - ); - })(), + registerInlineScriptFeatures( + nativeFinder, + context.subscriptions, + outputChannel, + sysMgr, + context.globalStorageUri, + ), ), safeRegister('shellStartupVars', shellStartupVarsMgr.initialize()), ]); diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index d5c0ef657..0136539f1 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -18,10 +18,7 @@ import { PythonProjectCreator, PythonProjectCreatorOptions, } from '../api'; -import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants'; import { traceError, traceInfo, traceVerbose } from '../common/logging'; -import { clearPersistentState } from '../common/persistentState'; -import type { InlineScriptEnvManager } from '../managers/builtin/inlineScript/envManager'; import { EnvironmentManagers, InternalEnvironmentManager, @@ -29,9 +26,12 @@ import { ProjectCreators, PythonProjectManager, } from '../internal.api'; -import { isInlineScriptsFeatureEnabled } from '../helpers'; -import { waitForEnvManagerId } from './common/managerReady'; -import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers'; +import { + getResolvedPythonProjectSettings, + removePythonProjectSetting, + setEnvironmentManager, + setPackageManager, +} from './settings/settingHelpers'; import { valid as pep440Valid } from '@renovatebot/pep440'; import { executeCommand } from '../common/command.api'; @@ -58,6 +58,8 @@ import { showWarningMessage, withProgress, } from '../common/window.apis'; +import { getWorkspaceFolders } from '../common/workspace.apis'; +import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants'; import { runAsTask } from './execution/runAsTask'; import { runInTerminal } from './terminal/runInTerminal'; import { TerminalManager } from './terminal/terminalManager'; @@ -312,50 +314,6 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir } } -export async function clearCacheCommand( - envManagers: EnvironmentManagers, - clearShellProfileCache: () => Promise, -): Promise { - await clearPersistentState(); - await envManagers.clearCache(undefined); - await clearShellProfileCache(); -} - -export async function clearInlineScriptCacheCommand( - getManager: () => InlineScriptEnvManager | undefined | Promise, -): Promise { - if (!isInlineScriptsFeatureEnabled()) { - const message = l10n.t( - 'Script environment cache is unavailable because inline script environments are disabled in this window.', - ); - showErrorMessage(message); - throw new Error(message); - } - - await waitForEnvManagerId([INLINE_SCRIPT_MANAGER_ID]); - const manager = await getManager(); - if (!manager) { - const message = l10n.t( - 'Script environment cache is unavailable because the inline script environment manager is not available in this window.', - ); - showErrorMessage(message); - throw new Error(message); - } - - const clearLabel = l10n.t('Clear Cache'); - const confirm = await showWarningMessage( - l10n.t('Delete cached environments created for inline Python scripts?'), - { modal: true }, - clearLabel, - l10n.t('Cancel'), - ); - if (confirm !== clearLabel) { - return; - } - - await manager.clearScriptCache(); -} - export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) { if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { if (context.pkg.isTransitive) { @@ -712,6 +670,71 @@ export async function removePythonProject( wm.remove(item.project); } +function getInlineScriptProjectEdits(wm: PythonProjectManager) { + const currentProjects = new Map(wm.getProjects().map((project) => [project.uri.toString(), project] as const)); + const edits = new Map(); + for (const workspaceFolder of getWorkspaceFolders() ?? []) { + for (const resolvedSetting of getResolvedPythonProjectSettings(workspaceFolder)) { + if ( + !resolvedSetting.sources.some( + (source) => source.setting.envManager === INLINE_SCRIPT_MANAGER_ID, + ) + ) { + continue; + } + const projectUri = resolvedSetting.uri; + const key = projectUri.toString(); + edits.set(key, { + project: + currentProjects.get(key) ?? + wm.create(path.basename(projectUri.fsPath) || resolvedSetting.effective.setting.path, projectUri), + envManager: INLINE_SCRIPT_MANAGER_ID, + }); + } + } + return { + edits: Array.from(edits.values()), + loadedProjects: currentProjects, + }; +} + +export async function clearScriptEnvironmentCacheCommand( + em: EnvironmentManagers, + wm: PythonProjectManager, +): Promise { + const manager = em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID); + if (!manager || !manager.supportsClearCache()) { + throw new Error( + l10n.t('Inline-script environment cache is unavailable because the inline-script manager is not registered.'), + ); + } + + const clearLabel = l10n.t('Clear Cache'); + const confirmation = await showWarningMessage( + l10n.t( + 'This will delete all cached inline-script environments, forget their script associations, and remove inline-script project entries from settings.', + ), + { modal: true }, + clearLabel, + ); + if (confirmation !== clearLabel) { + return; + } + + const { edits, loadedProjects } = getInlineScriptProjectEdits(wm); + await manager.clearCache(); + if (edits.length === 0) { + return; + } + const removedProjects = await removePythonProjectSetting(edits); + const loadedProjectsToRemove = removedProjects + .map((project) => loadedProjects.get(project.uri.toString())) + .filter((project): project is PythonProject => project !== undefined); + if (loadedProjectsToRemove.length > 0) { + wm.remove(loadedProjectsToRemove); + } +} + export async function getPackageCommandOptions( e: unknown, em: EnvironmentManagers, diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index 9c1cf7bb3..31dd86cd0 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -130,20 +130,17 @@ export class PythonProjectManagerImpl implements PythonProjectManager { // For each override, resolve its path and add as a project if not already present for (const o of overrides) { let uriFromWorkspace: Uri | undefined = undefined; - // if override has a workspace property, resolve the path relative to that workspace if (o.workspace) { - // const workspaceFolder = workspaces.find((ws) => ws.name === o.workspace); if (workspaceFolder) { if (workspaceFolder.uri.toString() !== w.uri.toString()) { - continue; // skip if the workspace is not the same as the current workspace + continue; } uriFromWorkspace = Uri.file(path.resolve(workspaceFolder.uri.fsPath, o.path)); } } const uri = uriFromWorkspace ? uriFromWorkspace : Uri.file(path.resolve(w.uri.fsPath, o.path)); - // Check if the project already exists in the newProjects array if (!newProjects.some((p) => p.uri.toString() === uri.toString())) { newProjects.push(new PythonProjectsImpl(o.path, uri)); } diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 752a1c2c0..25b2eca9d 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -10,6 +10,112 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import * as workspaceApis from '../../common/workspace.apis'; import { PythonProjectManager, PythonProjectSettings } from '../../internal.api'; +export interface ResolvedPythonProjectSettingSource { + readonly setting: PythonProjectSettings; + readonly uri: Uri; + readonly workspaceFolder: WorkspaceFolder; + readonly source: 'workspace' | 'workspaceFolder'; + readonly target: ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder; +} + +export interface ResolvedPythonProjectSetting { + readonly uri: Uri; + readonly workspaceFolder: WorkspaceFolder; + readonly effective: ResolvedPythonProjectSettingSource; + readonly sources: readonly ResolvedPythonProjectSettingSource[]; +} + +function resolvePythonProjectSettingSource( + setting: PythonProjectSettings, + workspaceFolder: WorkspaceFolder, + allWorkspaceFolders: readonly WorkspaceFolder[], + source: 'workspace' | 'workspaceFolder', + target: ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder, +): ResolvedPythonProjectSettingSource | undefined { + const resolvedWorkspaceFolder = setting.workspace + ? allWorkspaceFolders.find((candidate) => candidate.name === setting.workspace) + : workspaceFolder; + if (!resolvedWorkspaceFolder || resolvedWorkspaceFolder.uri.toString() !== workspaceFolder.uri.toString()) { + return undefined; + } + return { + setting, + uri: Uri.file(path.resolve(resolvedWorkspaceFolder.uri.fsPath, setting.path)), + workspaceFolder, + source, + target, + }; +} + +function resolveProjectSettingUri( + setting: PythonProjectSettings, + workspaceFolder: WorkspaceFolder, + allWorkspaceFolders: readonly WorkspaceFolder[] = workspaceApis.getWorkspaceFolders() ?? [workspaceFolder], +): Uri | undefined { + const resolvedWorkspaceFolder = setting.workspace + ? allWorkspaceFolders.find((candidate) => candidate.name === setting.workspace) + : workspaceFolder; + return resolvedWorkspaceFolder + ? Uri.file(path.resolve(resolvedWorkspaceFolder.uri.fsPath, setting.path)) + : undefined; +} + +export function getResolvedPythonProjectSettings( + workspaceFolder: WorkspaceFolder, + config: WorkspaceConfiguration = workspaceApis.getConfiguration('python-envs', workspaceFolder.uri), +): ResolvedPythonProjectSetting[] { + const allWorkspaceFolders = workspaceApis.getWorkspaceFolders() ?? [workspaceFolder]; + const projectsInspect = + typeof config.inspect === 'function' ? config.inspect('pythonProjects') : undefined; + const fallbackSettings = + projectsInspect === undefined ? config.get('pythonProjects', []) : undefined; + const orderedSources: ResolvedPythonProjectSettingSource[] = [ + ...(projectsInspect?.workspaceValue ?? fallbackSettings ?? []) + .map((setting) => + resolvePythonProjectSettingSource( + setting, + workspaceFolder, + allWorkspaceFolders, + 'workspace', + ConfigurationTarget.Workspace, + ), + ) + .filter((setting): setting is ResolvedPythonProjectSettingSource => setting !== undefined), + ...(projectsInspect?.workspaceFolderValue ?? []) + .map((setting) => + resolvePythonProjectSettingSource( + setting, + workspaceFolder, + allWorkspaceFolders, + 'workspaceFolder', + ConfigurationTarget.WorkspaceFolder, + ), + ) + .filter((setting): setting is ResolvedPythonProjectSettingSource => setting !== undefined), + ]; + + const grouped = new Map(); + for (const source of orderedSources) { + const key = source.uri.toString(); + const existing = grouped.get(key); + if (existing) { + grouped.set(key, { + ...existing, + effective: source, + sources: [...existing.sources, source], + }); + } else { + grouped.set(key, { + uri: source.uri, + workspaceFolder, + effective: source, + sources: [source], + }); + } + } + return Array.from(grouped.values()); +} + function getSettings( wm: PythonProjectManager, config: WorkspaceConfiguration, @@ -349,6 +455,46 @@ export interface EditProjectSettings { workspace?: string; } +function matchesProjectSettingEdit( + setting: PythonProjectSettings, + edit: EditProjectSettings, + workspaceFolder: WorkspaceFolder, +): boolean { + const projectPath = normalizePath(edit.project.uri.fsPath); + const settingUri = resolveProjectSettingUri(setting, workspaceFolder); + if (!settingUri || normalizePath(settingUri.fsPath) !== projectPath) { + return false; + } + if (edit.workspace !== undefined && setting.workspace !== edit.workspace) { + return false; + } + if (edit.envManager !== undefined && setting.envManager !== edit.envManager) { + return false; + } + if (edit.packageManager !== undefined && setting.packageManager !== edit.packageManager) { + return false; + } + return true; +} + +function hasProjectSetting( + settings: readonly PythonProjectSettings[], + project: PythonProject, + workspaceFolder: WorkspaceFolder, +): boolean { + const projectPath = normalizePath(project.uri.fsPath); + return settings.some((setting) => { + const settingUri = resolveProjectSettingUri(setting, workspaceFolder); + return settingUri ? normalizePath(settingUri.fsPath) === projectPath : false; + }); +} + +function cloneProjectSettings( + settings: readonly PythonProjectSettings[] | undefined, +): PythonProjectSettings[] | undefined { + return settings?.map((setting) => ({ ...setting })); +} + export async function addPythonProjectSetting(edits: EditProjectSettings[]): Promise { const noWorkspace: EditProjectSettings[] = []; const workspaces = new Map(); @@ -445,7 +591,7 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro await Promise.all(promises); } -export async function removePythonProjectSetting(edits: EditProjectSettings[]): Promise { +export async function removePythonProjectSetting(edits: EditProjectSettings[]): Promise { const noWorkspace: EditProjectSettings[] = []; const workspaces = new Map(); edits.forEach((e) => { @@ -461,24 +607,87 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): traceError(`Unable to find workspace for ${e.project.uri.fsPath}`); }); + const workspaceEntries = Array.from(workspaces.entries()); + if (workspaceEntries.length === 0) { + return []; + } + + const removedProjects = new Map(); + const folderRemainingSettings = new Map(); + const folderExistingSettings = new Map(); const promises: Thenable[] = []; - workspaces.forEach((es, w) => { + let workspaceConfig: WorkspaceConfiguration | undefined; + let workspaceValueOriginal: PythonProjectSettings[] | undefined; + + workspaceEntries.forEach(([w, es]) => { const config = workspaceApis.getConfiguration('python-envs', w.uri); - const overrides = config.get('pythonProjects', []); - es.forEach((e) => { - const pwPath = normalizePath(e.project.uri.fsPath); - const index = overrides.findIndex((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); - if (index >= 0) { - overrides.splice(index, 1); - } - }); - if (overrides.length === 0) { - promises.push(config.update('pythonProjects', undefined, ConfigurationTarget.Workspace)); - } else { - promises.push(config.update('pythonProjects', overrides, ConfigurationTarget.Workspace)); + const projectsInspect = config.inspect('pythonProjects'); + workspaceConfig ??= config; + workspaceValueOriginal ??= cloneProjectSettings(projectsInspect?.workspaceValue); + + const workspaceFolderOriginal = cloneProjectSettings(projectsInspect?.workspaceFolderValue) ?? []; + folderExistingSettings.set(w.uri.toString(), workspaceFolderOriginal); + const workspaceFolderRemaining = workspaceFolderOriginal.filter( + (projectSetting) => !es.some((edit) => matchesProjectSettingEdit(projectSetting, edit, w)), + ); + folderRemainingSettings.set(w.uri.toString(), workspaceFolderRemaining); + + if (workspaceFolderRemaining.length !== workspaceFolderOriginal.length) { + promises.push( + config.update( + 'pythonProjects', + workspaceFolderRemaining.length > 0 ? workspaceFolderRemaining : undefined, + ConfigurationTarget.WorkspaceFolder, + ), + ); } }); + + const aggregatedEdits = workspaceEntries.flatMap(([workspaceFolder, workspaceEdits]) => + workspaceEdits.map((edit) => ({ workspaceFolder, edit })), + ); + const workspaceValueRemaining = + workspaceValueOriginal?.filter( + (projectSetting) => + !aggregatedEdits.some(({ workspaceFolder, edit }) => + matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), + ), + ) ?? []; + + if ( + workspaceConfig && + workspaceValueOriginal !== undefined && + workspaceValueRemaining.length !== workspaceValueOriginal.length + ) { + promises.push( + workspaceConfig.update( + 'pythonProjects', + workspaceValueRemaining.length > 0 ? workspaceValueRemaining : undefined, + ConfigurationTarget.Workspace, + ), + ); + } + + workspaceEntries.forEach(([w, es]) => { + const existingSettings = [ + ...(workspaceValueOriginal ?? []), + ...((folderExistingSettings.get(w.uri.toString()) ?? [])), + ]; + const remainingSettings = [ + ...workspaceValueRemaining, + ...((folderRemainingSettings.get(w.uri.toString()) ?? [])), + ]; + es.filter( + (edit) => + existingSettings.some((projectSetting) => matchesProjectSettingEdit(projectSetting, edit, w)) && + !hasProjectSetting(remainingSettings, edit.project, w), + ).forEach((edit) => { + removedProjects.set(edit.project.uri.toString(), edit.project); + }); + }); + await Promise.all(promises); + return Array.from(removedProjects.values()); } /** diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index acfb64568..68a21d940 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -43,8 +43,15 @@ import { PYENV_MANAGER_ID, SYSTEM_MANAGER_ID, } from '../../../common/constants'; -import { acquireFileLock, AcquiredFileLock } from '../../../common/lockfile.apis'; +import { + acquireFileLock, + AcquiredFileLock, + FILE_LOCK_DIR_SUFFIX, + getFileLockPath, + inspectFileLock, +} from '../../../common/lockfile.apis'; import { getWorkspacePersistentState, PersistentState } from '../../../common/persistentState'; +import { createDeferred, Deferred } from '../../../common/utils/deferred'; import { isFileNotFoundError } from '../../../common/utils/filesystem'; import { normalizePath } from '../../../common/utils/pathUtils'; import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; @@ -52,7 +59,12 @@ import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { NativePythonFinder } from '../../common/nativePythonFinder'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; -import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils'; +import { + createWithProgress, + hasMinimumPathDepth, + isDriveRoot, + resolveVenvPythonEnvironmentPath, +} from '../venvUtils'; const BASE_INTERPRETER_MANAGER_IDS = new Set([ SYSTEM_MANAGER_ID, @@ -60,10 +72,8 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([ PYENV_MANAGER_ID, ]); -const CACHE_CLEAR_ROOT_LOCK_TIMEOUT_MS = 1_000; -const CACHE_CLEAR_ROOT_LOCK_RETRY_MS = 50; -const CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS = 1_000; -const CACHE_CREATE_HANDOFF_LOCK_RETRY_MS = 50; +const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000; +const CACHE_LOCK_RETRY_MS = 500; const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000; /** Workspace-state key for PEP 723 script path to environment executable associations. */ export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`; @@ -89,8 +99,6 @@ type CacheEntryInspection = | { readonly kind: 'absent' | 'stale' | 'uncertain' } | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; -type CacheLockDisposition = 'retained' | 'active' | 'unknown'; - /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingSetups = new Map>(); @@ -104,8 +112,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly associationRevisions = new Map(); private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); - private activeCreateCount = 0; - private isClearCacheInProgress = false; + private cacheMaintenanceQueue: Promise = Promise.resolve(); + private cacheMaintenanceBarrier: Deferred | undefined; + private pendingCacheMaintenances = 0; + private activeCreateOperations = 0; private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -136,60 +146,53 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions, ): Promise { - if (this.isClearCacheInProgress) { - throw this.createCacheOperationConflict( - l10n.t( - 'Cannot create an inline script environment while the script environment cache is being cleared. Retry after the cache clear finishes.', - ), - ); - } - this.activeCreateCount += 1; + this.activeCreateOperations += 1; try { - const scriptUri = this.getScriptUri(scope); - if (!scriptUri) { - this.log.warn('Inline-script environment creation requires exactly one local file URI.'); - return undefined; - } + return await this.waitForCacheMaintenance(async () => { + try { + const scriptUri = this.getScriptUri(scope); + if (!scriptUri) { + this.log.warn('Inline-script environment creation requires exactly one local file URI.'); + return undefined; + } - const metadata = await readInlineScriptMetadataFromFile(scriptUri); - if (!metadata) { - this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`); - return undefined; - } + const metadata = await readInlineScriptMetadataFromFile(scriptUri); + if (!metadata) { + this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`); + return undefined; + } - const packages = [ - ...(metadata.dependencies ?? []), - ...(options?.additionalPackages ?? []), - ].map((value) => value.trim()); - if (packages.some((value) => value.length === 0)) { - this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); - return undefined; - } + const packages = [ + ...(metadata.dependencies ?? []), + ...(options?.additionalPackages ?? []), + ].map((value) => value.trim()); + if (packages.some((value) => value.length === 0)) { + this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); + return undefined; + } - const setupKey = this.getPendingSetupKey(scriptUri, metadata, packages, options); - const pending = this.pendingSetups.get(setupKey); - if (pending) { - return await pending; - } + const setupKey = this.getPendingSetupKey(scriptUri, metadata, packages, options); + const pending = this.pendingSetups.get(setupKey); + if (pending) { + return await pending; + } - const setup = this.createForScript(scriptUri, metadata, packages, options); - this.pendingSetups.set(setupKey, setup); - try { - return await setup; - } finally { - if (this.pendingSetups.get(setupKey) === setup) { - this.pendingSetups.delete(setupKey); + const setup = this.createForScript(scriptUri, metadata, packages, options); + this.pendingSetups.set(setupKey, setup); + try { + return await setup; + } finally { + if (this.pendingSetups.get(setupKey) === setup) { + this.pendingSetups.delete(setupKey); + } + } + } catch (error) { + this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); + return undefined; } - } - } catch (error) { - if (error instanceof InlineScriptCacheOperationError) { - this.log.warn(error.message); - throw error; - } - this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); - return undefined; + }); } finally { - this.activeCreateCount -= 1; + this.activeCreateOperations -= 1; } } @@ -257,73 +260,22 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { - return this.enqueueSelection(() => this.setInternal(scope, environment)); + return this.waitForCacheMaintenance(() => this.enqueueSelection(() => this.setInternal(scope, environment))); } async get(scope: GetEnvironmentScope): Promise { - return this.getInternal(scope); + return this.waitForCacheMaintenance(() => this.getInternal(scope)); } async resolve(_context: ResolveEnvironmentContext): Promise { return undefined; } - async clearScriptCache(): Promise { - if (this.isClearCacheInProgress) { - throw this.createCacheOperationConflict( - l10n.t('Script environment cache clear is already in progress.'), - ); - } - this.isClearCacheInProgress = true; - - try { - if (this.activeCreateCount > 0) { - throw this.createCacheOperationConflict( - l10n.t( - 'Cannot clear the script environment cache while another inline script environment operation may still be using it. Close other VS Code windows or restart VS Code, then retry.', - ), - ); - } - - const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); - let rootLock: AcquiredFileLock | undefined = await this.acquireCacheRootLock(cacheRoot, { - timeoutMs: CACHE_CLEAR_ROOT_LOCK_TIMEOUT_MS, - retryIntervalMs: CACHE_CLEAR_ROOT_LOCK_RETRY_MS, - }, 'clear'); - try { - const clearableCacheRoot = await this.getClearableCacheRootPath(cacheRoot); - if (clearableCacheRoot) { - await this.assertNoCacheLocks(clearableCacheRoot); - await this.removeClearableCacheRoot(clearableCacheRoot); - } - - let persistError: unknown; - try { - await this.clearPersistedAssociations(); - } catch (error) { - persistError = error; - } - - this.clearKnownAssociations(); - - if (persistError) { - throw persistError; - } - } finally { - const lockToRelease = rootLock; - rootLock = undefined; - await this.releaseCacheRootLockOrThrow(lockToRelease, cacheRoot.fsPath); - } - } catch (error) { - if (error instanceof InlineScriptCacheOperationError) { - this.log.warn(error.message); - } else { - this.log.error(`Failed to clear inline-script cache: ${getErrorMessage(error)}`); - } - throw error; - } finally { - this.isClearCacheInProgress = false; - } + async clearCache(): Promise { + const activeCreatesAtStart = this.activeCreateOperations; + return this.enqueueCacheMaintenance(() => + this.enqueueSelection(() => this.clearCacheInternal(activeCreatesAtStart)), + ); } private getScriptUri(scope: CreateEnvironmentScope): Uri | undefined { @@ -820,10 +772,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }); } - private clearPersistedAssociations(): Promise { - return this.enqueuePersistence((state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); - } - private asPersistedAssociations(value: unknown): PersistedInlineScriptEnvironments | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; @@ -843,245 +791,46 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } - private enqueueSelection(operation: () => Promise): Promise { - const run = this.selectionQueue.then(operation); - this.selectionQueue = run.then( - () => undefined, - () => undefined, - ); - return run; - } - - private clearKnownAssociations(): void { - const cleared = [...this.fsPathToEnv.entries()].map(([scriptPath, old]) => ({ - uri: Uri.file(scriptPath), - old, - new: undefined as PythonEnvironment | undefined, - })); - const knownScriptPaths = new Set([ - ...this.associationRevisions.keys(), - ...this.pendingRehydrations.keys(), - ...this.fsPathToPersistedEnvPath.keys(), - ...this.fsPathToEnv.keys(), - ]); - for (const scriptPath of knownScriptPaths) { - this.bumpAssociationRevision(scriptPath); - this.pendingRehydrations.delete(scriptPath); - } - this.fsPathToEnv.clear(); - this.fsPathToPersistedEnvPath.clear(); - this.cachedAssociationValidatedAt.clear(); - - cleared.forEach((event) => this._onDidChangeEnvironment.fire(event)); - } - - private async getClearableCacheRootPath(cacheRoot: Uri): Promise { - const globalStoragePath = path.resolve(this.globalStorageUri.fsPath); - let globalStorageStat: fs.Stats; - try { - globalStorageStat = await fs.lstat(globalStoragePath); - } catch (error) { - if (isFileNotFoundError(error)) { - return undefined; - } - throw error; - } - if (!globalStorageStat.isDirectory() || globalStorageStat.isSymbolicLink()) { - throw this.createUnsafeClearTargetError(globalStoragePath); - } - - const resolvedGlobalStorage = await fs.realpath(globalStoragePath); - if (normalizePath(resolvedGlobalStorage) !== normalizePath(globalStoragePath)) { - throw this.createUnsafeClearTargetError(globalStoragePath); - } - - const cacheRootPath = path.resolve(cacheRoot.fsPath); - try { - const cacheRootStat = await fs.lstat(cacheRootPath); - if (!cacheRootStat.isDirectory() || cacheRootStat.isSymbolicLink()) { - throw this.createUnsafeClearTargetError(cacheRootPath); - } - } catch (error) { - if (isFileNotFoundError(error)) { - return undefined; - } - throw error; - } - - const resolvedCacheRoot = await resolveCacheEntryPath(Uri.file(globalStoragePath), Uri.file(cacheRootPath)); - const expectedCacheRoot = path.join(resolvedGlobalStorage, INLINE_SCRIPT_CACHE_DIR_NAME); - if (!resolvedCacheRoot || normalizePath(resolvedCacheRoot) !== normalizePath(expectedCacheRoot)) { - throw this.createUnsafeClearTargetError(cacheRootPath); - } - - return resolvedCacheRoot; - } - - private async acquireCacheRootLock( - cacheRoot: Uri, - options: { - timeoutMs: number; - retryIntervalMs: number; - }, - operation: 'create' | 'clear', - ): Promise { - await fs.ensureDir(path.dirname(cacheRoot.fsPath)); - const lockPath = this.getLockPath(cacheRoot.fsPath); - try { - return await acquireFileLock(cacheRoot.fsPath, options); - } catch (error) { - if (this.isBusyLockError(error)) { - throw this.createCacheRootBusyError(operation, lockPath); - } - throw error; - } - } - - private async assertNoCacheLocks(cacheRootPath: string): Promise { - let entries: string[]; - try { - entries = await fs.readdir(cacheRootPath); - } catch (error) { - if (isFileNotFoundError(error)) { - return; - } - throw error; - } - - for (const entry of entries.filter((candidate) => candidate.endsWith('.lock'))) { - const lockPath = path.join(cacheRootPath, entry); - const lockDisposition = await this.inspectCacheLock(lockPath); - if (lockDisposition === 'active') { - throw this.createActiveLockError(lockPath); - } - if (lockDisposition === 'unknown') { - throw this.createUnknownLockError(lockPath); - } + private async waitForCacheMaintenance(operation: () => Promise): Promise { + const barrier = this.cacheMaintenanceBarrier; + if (barrier) { + await barrier.promise; } + return operation(); } - private removeClearableCacheRoot(cacheRootPath: string): Promise { - return fs.remove(cacheRootPath); - } - - private async inspectCacheLock(lockPath: string): Promise { - try { - const lockStat = await fs.lstat(lockPath); - if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) { - return 'unknown'; - } - } catch { - return 'unknown'; - } - - const retainedPath = path.join(lockPath, 'retained'); - try { - const retainedStat = await fs.lstat(retainedPath); - if (retainedStat.isFile()) { - return 'retained'; - } - return 'unknown'; - } catch (error) { - if (!isFileNotFoundError(error)) { - return 'unknown'; - } - } - - try { - return (await fs.readdir(lockPath)).some((entry) => entry.startsWith('owner-')) ? 'active' : 'unknown'; - } catch { - return 'unknown'; + private enqueueCacheMaintenance(operation: () => Promise): Promise { + if (!this.cacheMaintenanceBarrier) { + this.cacheMaintenanceBarrier = createDeferred(); } - } - - private createUnsafeClearTargetError(targetPath: string): Error { - return new Error( - l10n.t( - 'Cannot clear the script environment cache because the target could not be proven safe: {0}', - targetPath, - ), - ); - } - - private createCacheOperationConflict(message: string): InlineScriptCacheOperationError { - return new InlineScriptCacheOperationError(message); - } - - private createCacheRootBusyError(operation: 'create' | 'clear', lockPath: string): InlineScriptCacheOperationError { - return this.createCacheOperationConflict( - operation === 'clear' - ? l10n.t( - 'Cannot clear the script environment cache because the cache root lock at {0} may still be active or may have been left by an interrupted operation. Wait for current work to finish or close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', - lockPath, - ) - : l10n.t( - 'Inline script environment cache is busy because the cache root lock at {0} may still be active or may have been left by an interrupted operation. Wait for current work to finish or close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', - lockPath, - ), - ); - } - - private createActiveLockError(lockPath: string): InlineScriptCacheOperationError { - return this.createCacheOperationConflict( - l10n.t( - 'Cannot clear the script environment cache because the owner-only lock at {0} may still be active or may have been left by an interrupted operation. Close other VS Code windows and retry. If it persists after restart, manually remove only this lock path after confirming that no inline script cache operation is using it.', - lockPath, - ), - ); - } - - private createUnknownLockError(lockPath: string): InlineScriptCacheOperationError { - return this.createCacheOperationConflict( - l10n.t( - 'Cannot clear the script environment cache because the cache lock at {0} could not be verified as retained. Remove it manually only if you know no inline script environment operation still needs it.', - lockPath, - ), - ); - } - - private createCacheRootReleaseError(lockPath: string): InlineScriptCacheOperationError { - return this.createCacheOperationConflict( - l10n.t( - 'Failed to release the script environment cache root lock at {0}. Close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', - lockPath, - ), + this.pendingCacheMaintenances += 1; + const run = this.cacheMaintenanceQueue.then(operation); + this.cacheMaintenanceQueue = run.then( + () => undefined, + () => undefined, ); + return run.finally(() => { + this.pendingCacheMaintenances -= 1; + if (this.pendingCacheMaintenances === 0) { + this.cacheMaintenanceBarrier?.resolve(); + this.cacheMaintenanceBarrier = undefined; + } + }); } - private async releaseCacheRootLockOrThrow(lock: AcquiredFileLock, cacheRootPath: string): Promise { - const lockPath = this.getLockPath(cacheRootPath); - try { - await lock.release(); - } catch { - throw this.createCacheRootReleaseError(lockPath); - } - } - - private async releaseCacheLock(lock: AcquiredFileLock, label: string): Promise { - try { - await lock.release(); - } catch (error) { - this.log.warn(`Failed to release ${label} lock: ${getErrorMessage(error)}`); - } - } - - private isBusyLockError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'code' in error && - ['ELOCKED', 'ELOCKRETAINED'].includes((error as NodeJS.ErrnoException).code ?? '') + private enqueueSelection(operation: () => Promise): Promise { + const run = this.selectionQueue.then(operation); + this.selectionQueue = run.then( + () => undefined, + () => undefined, ); - } - - private getLockPath(targetPath: string): string { - return `${path.resolve(targetPath)}.lock`; + return run; } private async isCacheEntryBusy(envDirPath: string): Promise { return ( this.pendingCreations.has(path.basename(envDirPath)) || - (await fs.pathExists(`${path.resolve(envDirPath)}.lock`)) + (await fs.pathExists(getFileLockPath(envDirPath))) ); } @@ -1369,22 +1118,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }: CreateOrReuseEnvironmentOptions): Promise { const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); + await fs.ensureDir(cacheRoot.fsPath); - let rootLock: AcquiredFileLock | undefined; let lock: AcquiredFileLock | undefined; try { - rootLock = await this.acquireCacheRootLock(cacheRoot, { - timeoutMs: CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS, - retryIntervalMs: CACHE_CREATE_HANDOFF_LOCK_RETRY_MS, - }, 'create'); - await fs.ensureDir(cacheRoot.fsPath); lock = await acquireFileLock(envDir.fsPath, { - timeoutMs: CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS, - retryIntervalMs: CACHE_CREATE_HANDOFF_LOCK_RETRY_MS, + timeoutMs: CACHE_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_LOCK_RETRY_MS, }); - const handoffRootLock = rootLock; - rootLock = undefined; - await this.releaseCacheRootLockOrThrow(handoffRootLock, cacheRoot.fsPath); const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase); if (cached.kind === 'reusable') { @@ -1414,20 +1155,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } return build.environment; } catch (error) { - if (error instanceof InlineScriptCacheOperationError) { - this.log.warn(error.message); - return undefined; - } this.log.error(`Failed to create or reuse inline-script cache entry: ${getErrorMessage(error)}`); return undefined; } finally { if (lock) { - await this.releaseCacheLock(lock, 'inline-script cache entry'); - } - if (rootLock) { - const lockToRelease = rootLock; - rootLock = undefined; - await this.releaseCacheRootLockOrThrow(lockToRelease, cacheRoot.fsPath); + try { + await lock.release(); + } catch (error) { + this.log.warn(`Failed to release inline-script cache lock: ${getErrorMessage(error)}`); + } } } } @@ -1573,6 +1309,254 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { environment: result.environment }; } + private async clearCacheInternal(activeCreatesAtStart: number): Promise { + if (activeCreatesAtStart > 0) { + const message = l10n.t( + 'Cannot clear the script environment cache while script environments are being created.', + ); + this.log.error(message); + throw new Error(message); + } + + const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); + const cacheEntryPaths = await this.getClearableCacheEntryPaths(cacheRoot); + const persistedAssociations = await this.getPersistedAssociationSnapshot(); + const scriptPaths = new Set([ + ...Object.keys(persistedAssociations), + ...this.associationRevisions.keys(), + ...this.cachedAssociationValidatedAt.keys(), + ...this.fsPathToEnv.keys(), + ...this.fsPathToPersistedEnvPath.keys(), + ...this.pendingRehydrations.keys(), + ]); + const priorSelections = new Map(); + scriptPaths.forEach((scriptPath) => { + priorSelections.set(scriptPath, this.fsPathToEnv.get(scriptPath)); + }); + + for (const cacheEntryPath of cacheEntryPaths) { + await fs.remove(cacheEntryPath); + } + + let persistenceError: unknown; + try { + const state = await getWorkspacePersistentState(); + await state.clear([INLINE_SCRIPT_ENVS_KEY]); + } catch (error) { + persistenceError = error; + this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); + } + + scriptPaths.forEach((scriptPath) => this.bumpAssociationRevision(scriptPath)); + this.pendingRehydrations.clear(); + this.fsPathToEnv.clear(); + this.fsPathToPersistedEnvPath.clear(); + this.cachedAssociationValidatedAt.clear(); + + priorSelections.forEach((environment, scriptPath) => { + if (!environment) { + return; + } + this._onDidChangeEnvironment.fire({ + uri: Uri.file(scriptPath), + old: environment, + new: undefined, + }); + }); + + if (persistenceError) { + throw persistenceError; + } + } + + private async getClearableCacheEntryPaths(cacheRoot: Uri): Promise { + const resolvedCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); + if (!resolvedCacheRootPath) { + return []; + } + const cacheRootPath = path.resolve(resolvedCacheRootPath); + const physicalCacheRoot = Uri.file(cacheRootPath); + + const entryNames = await fs.readdir(cacheRootPath); + const lockStates = new Map(); + for (const entryName of entryNames.filter((entry) => entry.endsWith(FILE_LOCK_DIR_SUFFIX))) { + const envName = entryName.slice(0, -FILE_LOCK_DIR_SUFFIX.length); + if (envName.length === 0) { + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is malformed.', + ); + this.log.error(`${message} (${path.join(cacheRootPath, entryName)})`); + throw new Error(message); + } + + const envDirPath = path.join(cacheRootPath, envName); + const lockState = await inspectFileLock(envDirPath); + if (lockState === 'retained' || lockState === 'stale') { + lockStates.set(envDirPath, lockState); + continue; + } + if (lockState === 'held') { + const message = l10n.t( + 'Cannot clear the script environment cache while a cached environment is being created.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + if (lockState === 'unavailable') { + const message = l10n.t( + 'Cannot clear the script environment cache because a cached environment lock could not be verified.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is incomplete or malformed.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + + const pathsToRemove: string[] = []; + const scheduledPaths = new Set(); + + for (const envDirPath of lockStates.keys()) { + const cacheEntryPath = await this.getClearableCacheEntryPath(physicalCacheRoot, envDirPath); + if (cacheEntryPath) { + pathsToRemove.push(cacheEntryPath); + scheduledPaths.add(normalizePath(cacheEntryPath)); + } + } + + for (const envDirPath of lockStates.keys()) { + const lockPath = getFileLockPath(envDirPath); + pathsToRemove.push(lockPath); + scheduledPaths.add(normalizePath(lockPath)); + } + + for (const entryName of entryNames.filter((entry) => !entry.endsWith(FILE_LOCK_DIR_SUFFIX))) { + const cacheEntryPath = await this.getClearableCacheEntryPath(physicalCacheRoot, path.join(cacheRootPath, entryName)); + if (cacheEntryPath && !scheduledPaths.has(normalizePath(cacheEntryPath))) { + pathsToRemove.push(cacheEntryPath); + } + } + + return pathsToRemove; + } + + private async getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise { + const globalStoragePath = path.resolve(this.globalStorageUri.fsPath); + const cacheRootPath = path.resolve(cacheRoot.fsPath); + if (path.basename(cacheRootPath) !== INLINE_SCRIPT_CACHE_DIR_NAME || normalizePath(path.dirname(cacheRootPath)) !== normalizePath(globalStoragePath)) { + this.log.error(`Refusing to clear inline-script cache from unsafe root: ${cacheRootPath}`); + throw new Error(l10n.t('Refusing to clear the script environment cache from an unsafe cache root.')); + } + if (isDriveRoot(globalStoragePath) || !hasMinimumPathDepth(cacheRootPath, 3)) { + this.log.error(`Refusing to clear inline-script cache from unsafe root: ${cacheRootPath}`); + throw new Error(l10n.t('Refusing to clear the script environment cache from an unsafe cache root.')); + } + + let globalStorageStat; + try { + globalStorageStat = await fs.lstat(globalStoragePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + if (!globalStorageStat.isDirectory() || globalStorageStat.isSymbolicLink()) { + this.log.error(`Refusing to clear inline-script cache from redirected globalStorage root: ${globalStoragePath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because the global storage root is not a normal directory.'), + ); + } + + let cacheRootStat; + try { + cacheRootStat = await fs.lstat(cacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + if (!cacheRootStat.isDirectory() || cacheRootStat.isSymbolicLink()) { + this.log.error(`Refusing to clear inline-script cache from redirected cache root: ${cacheRootPath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because the cache root is not a normal directory.'), + ); + } + + let resolvedGlobalStoragePath: string; + let resolvedCacheRootPath: string; + try { + [resolvedGlobalStoragePath, resolvedCacheRootPath] = await Promise.all([ + fs.realpath(globalStoragePath), + fs.realpath(cacheRootPath), + ]); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + this.log.error(`Failed to resolve inline-script cache root physically: ${getErrorMessage(error)}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because its physical location could not be verified.'), + ); + } + + const expectedResolvedCacheRootPath = path.join(resolvedGlobalStoragePath, INLINE_SCRIPT_CACHE_DIR_NAME); + if ( + normalizePath(resolvedCacheRootPath) !== normalizePath(expectedResolvedCacheRootPath) || + normalizePath(path.dirname(resolvedCacheRootPath)) !== normalizePath(resolvedGlobalStoragePath) + ) { + this.log.error( + `Refusing to clear inline-script cache from redirected physical root: ${resolvedCacheRootPath}`, + ); + throw new Error( + l10n.t('Refusing to clear the script environment cache because the cache root is redirected.'), + ); + } + return resolvedCacheRootPath; + } + + private async getClearableCacheEntryPath(cacheRoot: Uri, entryPath: string): Promise { + let stat; + try { + stat = await fs.lstat(entryPath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + if (!stat.isDirectory() || stat.isSymbolicLink()) { + this.log.error(`Refusing to clear inline-script cache entry from unsafe path: ${entryPath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because a cache entry is not a normal directory.'), + ); + } + + const resolvedEntryPath = await resolveCacheEntryPath(cacheRoot, Uri.file(entryPath)); + if (!resolvedEntryPath) { + this.log.error(`Refusing to clear inline-script cache entry outside the expected root: ${entryPath}`); + throw new Error( + l10n.t('Refusing to clear the script environment cache because a cache entry is outside the expected root.'), + ); + } + + return resolvedEntryPath; + } + + private async getPersistedAssociationSnapshot(): Promise { + await this.persistenceQueue; + const state = await getWorkspacePersistentState(); + return this.asPersistedAssociations(await state.get(INLINE_SCRIPT_ENVS_KEY)) ?? {}; + } + private async removeCacheEntry(envDir: Uri): Promise { try { await fs.remove(envDir.fsPath); @@ -1628,5 +1612,3 @@ interface PendingScriptUpdate extends ScriptReference { readonly needsPersistence: boolean; readonly shouldNotify: boolean; } - -class InlineScriptCacheOperationError extends Error {} diff --git a/src/managers/builtin/inlineScript/main.ts b/src/managers/builtin/inlineScript/main.ts index 94531313f..8c35fc6ed 100644 --- a/src/managers/builtin/inlineScript/main.ts +++ b/src/managers/builtin/inlineScript/main.ts @@ -20,15 +20,14 @@ export async function registerInlineScriptFeatures( log: LogOutputChannel, baseManager: EnvironmentManager, globalStorageUri: Uri, -): Promise { +): Promise { if (!isInlineScriptsFeatureEnabled()) { traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); - return undefined; + return; } const api: PythonEnvironmentApi = await getPythonApi(); const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); disposables.push(mgr, api.registerEnvironmentManager(mgr)); traceInfo('Inline-script env manager: registered (internal flag is on)'); - return mgr; } diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index 2962235e1..e35825866 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -513,7 +513,7 @@ export async function createPythonVenv( return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options); } -function isDriveRoot(fsPath: string): boolean { +export function isDriveRoot(fsPath: string): boolean { const normalized = path.normalize(fsPath); if (os.platform() === 'win32') { return /^[a-zA-Z]:[\\/]?$/.test(normalized); @@ -521,7 +521,7 @@ function isDriveRoot(fsPath: string): boolean { return normalized === '/'; } -function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean { +export function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean { const normalized = path.normalize(fsPath); const parts = normalized.split(path.sep).filter((p) => p.length > 0 && p !== '.'); diff --git a/src/test/common/lockfile.apis.unit.test.ts b/src/test/common/lockfile.apis.unit.test.ts index a2d343a19..df8c5acc4 100644 --- a/src/test/common/lockfile.apis.unit.test.ts +++ b/src/test/common/lockfile.apis.unit.test.ts @@ -8,7 +8,13 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { acquireFileLock, AcquireFileLockOptions } from '../../common/lockfile.apis'; +import { + acquireFileLock, + AcquireFileLockOptions, + FILE_LOCK_OWNER_MARKER_PREFIX, + getFileLockPath, + inspectFileLock, +} from '../../common/lockfile.apis'; const OPTIONS: AcquireFileLockOptions = { timeoutMs: 40, @@ -209,4 +215,63 @@ suite('lockfile APIs', () => { return error.code === 'ELOCKORPHANED' && error.path === `${path.resolve(targetPath)}.lock`; }); }); + + test('classifies a live owner marker as held using the liveness probe', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-live`), ''); + const checkProcessLiveness = sinon.stub().resolves('live'); + + assert.strictEqual(await inspectFileLock(targetPath, { checkProcessLiveness }), 'held'); + sinon.assert.calledOnceWithExactly(checkProcessLiveness, process.pid); + }); + + test('classifies a retained lock after retain()', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + await lock.retain(); + + assert.strictEqual(await inspectFileLock(targetPath), 'retained'); + }); + + test('classifies a dead owner marker as stale using the liveness probe', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}424242-dead`), ''); + const checkProcessLiveness = sinon.stub().resolves('dead'); + + assert.strictEqual(await inspectFileLock(targetPath, { checkProcessLiveness }), 'stale'); + sinon.assert.calledOnceWithExactly(checkProcessLiveness, 424242); + }); + + test('classifies an unavailable owner probe conservatively', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-busy`), ''); + const checkProcessLiveness = sinon.stub().resolves('unavailable'); + + assert.strictEqual(await inspectFileLock(targetPath, { checkProcessLiveness }), 'unavailable'); + sinon.assert.calledOnceWithExactly(checkProcessLiveness, process.pid); + }); + + test('classifies an owner-less lock directory as orphaned', async () => { + await fs.ensureDir(getFileLockPath(targetPath)); + + assert.strictEqual(await inspectFileLock(targetPath), 'orphaned'); + }); + + test('classifies a malformed owner marker as malformed', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}not-a-pid-live`), ''); + + assert.strictEqual(await inspectFileLock(targetPath), 'malformed'); + }); + + test('classifies a lock directory with unexpected entries as malformed', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, 'unexpected.txt'), ''); + + assert.strictEqual(await inspectFileLock(targetPath), 'malformed'); + }); }); diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 6fd6229d6..e31e98adc 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -1,24 +1,22 @@ import * as assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { Uri } from 'vscode'; +import { Uri, WorkspaceFolder } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../api'; import * as commandApi from '../../common/command.api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; -import * as persistentState from '../../common/persistentState'; import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; import { - clearCacheCommand, - clearInlineScriptCacheCommand, + clearScriptEnvironmentCacheCommand, createAnyEnvironmentCommand, removePythonProject, revealEnvInManagerView, } from '../../features/envCommands'; -import * as managerReady from '../../features/common/managerReady'; import * as settingHelpers from '../../features/settings/settingHelpers'; -import * as helpers from '../../helpers'; -import type { InlineScriptEnvManager } from '../../managers/builtin/inlineScript/envManager'; import { EnvManagerView } from '../../features/views/envManagersView'; import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; import { EnvironmentManagers, InternalEnvironmentManager, PythonProjectManager } from '../../internal.api'; @@ -215,6 +213,7 @@ suite('Remove Python Project Command Tests', () => { } as unknown as PythonProjectManager; sinon.stub(settingHelpers, 'removePythonProjectSetting').callsFake(async () => { calls.push('removeSetting'); + return []; }); await removePythonProject(item, projectManager, envManagers); @@ -227,109 +226,241 @@ suite('Remove Python Project Command Tests', () => { }); }); -suite('Clear Cache Command Tests', () => { +suite('Clear Script Environment Cache Command Tests', () => { + const workspacePath = process.platform === 'win32' ? 'C:\\workspace' : '/workspace'; + const workspaceFolder: WorkspaceFolder = { + uri: Uri.file(workspacePath), + name: 'workspace', + index: 0, + }; + teardown(() => { sinon.restore(); }); - test('keeps the broad clear handler on the base path', async () => { - const calls: string[] = []; + test('cancels without clearing the cache or touching project settings', async () => { + const clearCache = sinon.stub().resolves(); const envManagers = { - clearCache: sinon.stub().callsFake(async (scope: unknown) => { - calls.push(`managers:${String(scope)}`); + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, }), } as unknown as EnvironmentManagers; - const clearShellProfileCache = sinon.stub().callsFake(async () => { - calls.push('shell'); - }); - sinon.stub(persistentState, 'clearPersistentState').callsFake(async () => { - calls.push('state'); - }); - - await clearCacheCommand(envManagers, clearShellProfileCache); - - assert.deepStrictEqual(calls, ['state', 'managers:undefined', 'shell']); - assert.ok((envManagers.clearCache as sinon.SinonStub).calledOnceWithExactly(undefined)); - assert.ok(clearShellProfileCache.calledOnce); - }); -}); - -suite('Clear Inline Script Environment Cache Command Tests', () => { - let clearScriptCacheStub: sinon.SinonStub; - let getManager: sinon.SinonStub; - let showErrorMessageStub: sinon.SinonStub; - let showWarningMessageStub: sinon.SinonStub; - let isInlineScriptsFeatureEnabledStub: sinon.SinonStub; - let waitForEnvManagerIdStub: sinon.SinonStub; - - setup(() => { - clearScriptCacheStub = sinon.stub().resolves(); - getManager = sinon - .stub<[], InlineScriptEnvManager | undefined>() - .returns({ clearScriptCache: clearScriptCacheStub } as unknown as InlineScriptEnvManager); - showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage'); - showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage'); - isInlineScriptsFeatureEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled').returns(true); - waitForEnvManagerIdStub = sinon.stub(managerReady, 'waitForEnvManagerId').resolves(); - }); - - teardown(() => { - sinon.restore(); - }); - - test('clears the cache after confirmation', async () => { - showWarningMessageStub.callsFake(async (_message, _options, clearLabel: string) => clearLabel); + const projectManager = { + getProjects: sinon.stub().returns([]), + create: sinon.stub(), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves(undefined); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([]); + const removeSettings = sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves([]); - await clearInlineScriptCacheCommand(getManager); + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); - assert.ok(showWarningMessageStub.calledOnce); - assert.deepStrictEqual(showWarningMessageStub.firstCall.args[1], { modal: true }); - assert.ok(waitForEnvManagerIdStub.calledOnce); - assert.ok(getManager.calledOnce); - assert.ok(clearScriptCacheStub.calledOnce); - assert.strictEqual(showErrorMessageStub.called, false); + sinon.assert.notCalled(clearCache); + sinon.assert.notCalled(removeSettings); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); }); - test('does nothing when the confirmation is cancelled', async () => { - showWarningMessageStub.resolves(undefined); - - await clearInlineScriptCacheCommand(getManager); - - assert.ok(showWarningMessageStub.calledOnce); - assert.ok(waitForEnvManagerIdStub.calledOnce); - assert.ok(getManager.calledOnce); - assert.strictEqual(clearScriptCacheStub.called, false); - assert.strictEqual(showErrorMessageStub.called, false); + test('clears the cache and removes only inline-script projects returned by the settings cleanup', async () => { + const inlineProject: PythonProject = { + uri: Uri.file(path.join(workspacePath, 'script.py')), + name: 'script.py', + }; + const clearCache = sinon.stub().resolves(); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([inlineProject]), + create: sinon.stub().callsFake((name: string, uri: Uri) => ({ name, uri })), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string) => + key === 'pythonProjects' + ? [ + { + path: 'script.py', + envManager: INLINE_SCRIPT_MANAGER_ID, + packageManager: 'ms-python.python:pip', + }, + { + path: 'other.py', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ] + : [], + inspect: (key: string) => + key === 'pythonProjects' + ? { + workspaceValue: [ + { + path: 'script.py', + envManager: INLINE_SCRIPT_MANAGER_ID, + packageManager: 'ms-python.python:pip', + }, + { + path: 'other.py', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ], + workspaceFolderValue: undefined, + } + : undefined, + } as never); + const removeSettings = sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves([inlineProject]); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.calledOnce(clearCache); + sinon.assert.calledOnceWithExactly(removeSettings, [ + { + project: inlineProject, + envManager: INLINE_SCRIPT_MANAGER_ID, + }, + ]); + sinon.assert.calledOnceWithExactly(projectManager.remove as sinon.SinonStub, [inlineProject]); }); - test('fails fast when the feature setting is off', async () => { - isInlineScriptsFeatureEnabledStub.returns(false); - showErrorMessageStub.resolves(undefined); - - await assert.rejects( - clearInlineScriptCacheCommand(getManager), - /inline script environments are disabled in this window/i, + test('includes inline-script projects without a .py extension', async () => { + const inlineProjectUri = Uri.file(path.join(workspacePath, 'runner')); + const clearCache = sinon.stub().resolves(); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([]), + create: sinon.stub().callsFake((name: string, uri: Uri) => ({ name, uri })), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string) => + key === 'pythonProjects' + ? [ + { + path: 'runner', + envManager: INLINE_SCRIPT_MANAGER_ID, + packageManager: 'ms-python.python:pip', + }, + { + path: 'unrelated', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ] + : [], + inspect: (key: string) => + key === 'pythonProjects' + ? { + workspaceValue: [ + { + path: 'runner', + envManager: INLINE_SCRIPT_MANAGER_ID, + packageManager: 'ms-python.python:pip', + }, + { + path: 'unrelated', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ], + workspaceFolderValue: undefined, + } + : undefined, + } as never); + const removeSettings = sinon + .stub(settingHelpers, 'removePythonProjectSetting') + .callsFake(async (edits) => [edits[0].project]); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + assert.strictEqual(removeSettings.callCount, 1); + assert.strictEqual(removeSettings.firstCall.args[0].length, 1); + assert.strictEqual(removeSettings.firstCall.args[0][0].envManager, INLINE_SCRIPT_MANAGER_ID); + assert.strictEqual(removeSettings.firstCall.args[0][0].project.uri.fsPath, inlineProjectUri.fsPath); + assert.strictEqual((projectManager.create as sinon.SinonStub).callCount, 1); + assert.strictEqual((projectManager.create as sinon.SinonStub).firstCall.args[0], 'runner'); + assert.strictEqual( + (projectManager.create as sinon.SinonStub).firstCall.args[1].fsPath.toLowerCase(), + inlineProjectUri.fsPath.toLowerCase(), ); - - assert.ok(showErrorMessageStub.calledOnce); - assert.strictEqual(waitForEnvManagerIdStub.called, false); - assert.strictEqual(getManager.called, false); - assert.strictEqual(showWarningMessageStub.called, false); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); }); - test('throws a clear error when the manager is unavailable after the readiness wait', async () => { - getManager.returns(undefined); - showErrorMessageStub.resolves(undefined); - - await assert.rejects( - clearInlineScriptCacheCommand(getManager), - /inline script environment manager is not available in this window/i, - ); - - assert.ok(waitForEnvManagerIdStub.calledOnce); - assert.ok(getManager.calledOnce); - assert.ok(showErrorMessageStub.calledOnce); - assert.strictEqual(showWarningMessageStub.called, false); + test('removes a hidden inline workspace entry when a folder override exists for the same URI', async () => { + const projectUri = Uri.file(path.join(workspacePath, 'script.py')); + const visibleProject: PythonProject = { + uri: projectUri, + name: 'script.py', + }; + const clearCache = sinon.stub().resolves(); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([visibleProject]), + create: sinon.stub(), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); + sinon.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string) => + key === 'pythonProjects' + ? [ + { + path: 'script.py', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ] + : [], + inspect: (key: string) => + key === 'pythonProjects' + ? { + workspaceValue: [ + { + path: 'script.py', + envManager: INLINE_SCRIPT_MANAGER_ID, + packageManager: 'ms-python.python:pip', + }, + ], + workspaceFolderValue: [ + { + path: 'script.py', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ], + } + : undefined, + } as never); + const removeSettings = sinon + .stub(settingHelpers, 'removePythonProjectSetting') + .callsFake(async (edits) => [edits[0].project]); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + assert.strictEqual(removeSettings.callCount, 1); + assert.strictEqual(removeSettings.firstCall.args[0].length, 1); + assert.strictEqual(removeSettings.firstCall.args[0][0].project.uri.fsPath, visibleProject.uri.fsPath); + assert.strictEqual(removeSettings.firstCall.args[0][0].envManager, INLINE_SCRIPT_MANAGER_ID); }); }); diff --git a/src/test/features/envManagers.unit.test.ts b/src/test/features/envManagers.unit.test.ts index 42a64579e..d642fa948 100644 --- a/src/test/features/envManagers.unit.test.ts +++ b/src/test/features/envManagers.unit.test.ts @@ -7,7 +7,6 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import { PythonEnvironment } from '../../api'; -import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; import * as frameUtils from '../../common/utils/frameUtils'; import * as workspaceApis from '../../common/workspace.apis'; import { PythonEnvironmentManagers } from '../../features/envManagers'; @@ -337,70 +336,3 @@ suite('PythonEnvironmentManagers - refreshEnvironment', () => { await envManagers.refreshEnvironment(Uri.file('/unknown/path')); }); }); - -suite('PythonEnvironmentManagers - clearCache', () => { - let sandbox: sinon.SinonSandbox; - let envManagers: PythonEnvironmentManagers; - let mockProjectManager: sinon.SinonStubbedInstance; - - setup(() => { - sandbox = sinon.createSandbox(); - sandbox.stub(frameUtils, 'getCallingExtension').returns('ms-python.python'); - sandbox.stub(workspaceApis, 'getConfiguration').returns({ - get: (key: string, defaultValue?: unknown) => { - if (key === 'defaultEnvManager') { - return 'ms-python.python:system'; - } - if (key === 'pythonProjects') { - return []; - } - return defaultValue; - }, - has: () => false, - inspect: () => undefined, - update: () => Promise.resolve(), - } as any); - - mockProjectManager = { - getProjects: sandbox.stub().returns([]), - get: sandbox.stub().returns(undefined), - } as unknown as sinon.SinonStubbedInstance; - - envManagers = new PythonEnvironmentManagers(mockProjectManager as unknown as PythonProjectManager); - }); - - teardown(() => { - sandbox.restore(); - }); - - function registerFakeManager(managerId: string, clearCache: sinon.SinonStub): void { - envManagers.registerEnvironmentManager( - { - name: managerId.split(':')[1], - displayName: managerId, - preferredPackageManagerId: 'ms-python.python:pip', - clearCache, - get: sandbox.stub().resolves(undefined), - set: sandbox.stub().resolves(), - resolve: sandbox.stub().resolves(undefined), - refresh: sandbox.stub().resolves(), - getEnvironments: sandbox.stub().resolves([]), - onDidChangeEnvironments: sandbox.stub().returns({ dispose: () => {} }), - onDidChangeEnvironment: sandbox.stub().returns({ dispose: () => {} }), - } as any, - { extensionId: 'ms-python.python' }, - ); - } - - test('does not special-case managers during broad cache clears', async () => { - const systemClearCache = sandbox.stub().resolves(); - const inlineClearCache = sandbox.stub().resolves(); - registerFakeManager('ms-python.python:system', systemClearCache); - registerFakeManager(INLINE_SCRIPT_MANAGER_ID, inlineClearCache); - - await envManagers.clearCache(undefined); - - assert.ok(systemClearCache.calledOnce); - assert.ok(inlineClearCache.calledOnce); - }); -}); diff --git a/src/test/features/projectManager.initialize.unit.test.ts b/src/test/features/projectManager.initialize.unit.test.ts index 84e0c9fc7..f89a325ac 100644 --- a/src/test/features/projectManager.initialize.unit.test.ts +++ b/src/test/features/projectManager.initialize.unit.test.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as assert from 'assert'; +import * as path from 'path'; import * as sinon from 'sinon'; import { Disposable, EventEmitter, Uri, WorkspaceFolder } from 'vscode'; import * as workspaceApis from '../../common/workspace.apis'; +import { normalizePath } from '../../common/utils/pathUtils'; import { PythonProjectManagerImpl } from '../../features/projectManager'; import * as settingHelpers from '../../features/settings/settingHelpers'; import { PythonProjectSettings } from '../../internal.api'; @@ -304,6 +306,163 @@ suite('Project Manager Initialization - Settings Preservation', () => { pm.dispose(); }); + + test('config refresh drops only the project removed from workspaceValue and preserves workspaceFolder entries', async () => { + let workspaceValueProjects: PythonProjectSettings[] = [ + { + path: 'script.py', + envManager: 'ms-python.python:inline-script', + packageManager: 'ms-python.python:pip', + workspace: workspaceFolder.name, + }, + ]; + let workspaceFolderProjects: PythonProjectSettings[] = [ + { + path: 'keep.py', + envManager: 'ms-python.python:venv', + packageManager: 'ms-python.python:pip', + }, + ]; + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return [...workspaceValueProjects, ...workspaceFolderProjects] as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + assert.ok( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'script.py')), + ), + 'workspaceValue project should be loaded initially', + ); + assert.ok( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'keep.py')), + ), + 'workspaceFolder project should be loaded initially', + ); + + workspaceValueProjects = []; + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => section === 'python-envs.pythonProjects', + }); + await clock.tickAsync(150); + + assert.strictEqual( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'script.py')), + ), + false, + 'workspaceValue project should be removed after config refresh', + ); + assert.ok( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'keep.py')), + ), + 'workspaceFolder project should remain after config refresh', + ); + assertNoSettingsWritten('Config refresh after project removal'); + + pm.dispose(); + }); + + test('shared workspaceValue removals do not resurrect projects after a multi-root refresh', async () => { + const secondWorkspacePath = process.platform === 'win32' ? 'C:\\workspace2' : '/workspace2'; + const secondWorkspaceFolder: WorkspaceFolder = { + uri: Uri.file(secondWorkspacePath), + name: 'workspace2', + index: 1, + }; + let sharedWorkspaceProjects: PythonProjectSettings[] = [ + { + path: 'first', + envManager: 'ms-python.python:inline-script', + packageManager: 'ms-python.python:pip', + workspace: workspaceFolder.name, + }, + { + path: 'second', + envManager: 'ms-python.python:inline-script', + packageManager: 'ms-python.python:pip', + workspace: secondWorkspaceFolder.name, + }, + ]; + (workspaceApis.getWorkspaceFolders as sinon.SinonStub).returns([workspaceFolder, secondWorkspaceFolder]); + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => { + if (key === 'pythonProjects') { + return sharedWorkspaceProjects as unknown as T; + } + if (key === 'defaultEnvManager') { + return 'ms-python.python:venv' as T; + } + if (key === 'defaultPackageManager') { + return 'ms-python.python:pip' as T; + } + return defaultValue; + }; + mockConfig.update = () => Promise.resolve(); + sinon.stub(workspaceApis, 'getConfiguration').returns(mockConfig); + + const pm = new PythonProjectManagerImpl(); + pm.initialize(); + await clock.tickAsync(150); + + assert.ok( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'first')), + ), + 'first shared workspace project should be loaded initially', + ); + assert.ok( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(secondWorkspacePath, 'second')), + ), + 'second shared workspace project should be loaded initially', + ); + + sharedWorkspaceProjects = []; + configChangeEmitter.fire({ + affectsConfiguration: (section: string) => section === 'python-envs.pythonProjects', + }); + await clock.tickAsync(150); + + assert.strictEqual( + pm.getProjects().some( + (project) => normalizePath(project.uri.fsPath) === normalizePath(path.join(workspacePath, 'first')), + ), + false, + 'first shared workspace project should stay removed after refresh', + ); + assert.strictEqual( + pm.getProjects().some( + (project) => + normalizePath(project.uri.fsPath) === normalizePath(path.join(secondWorkspacePath, 'second')), + ), + false, + 'second shared workspace project should stay removed after refresh', + ); + assertNoSettingsWritten('Shared workspace refresh'); + + pm.dispose(); + }); }); suite('Workspace Folder Changes - No Settings Writes', () => { diff --git a/src/test/features/settings/settingHelpers.unit.test.ts b/src/test/features/settings/settingHelpers.unit.test.ts index ef195addd..09835fab7 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -9,12 +9,14 @@ import * as sender from '../../../common/telemetry/sender'; import * as workspaceApis from '../../../common/workspace.apis'; import { addPythonProjectSetting, + getResolvedPythonProjectSettings, migrateGlobalDefaultEnvManagerSetting, + removePythonProjectSetting, setAllManagerSettings, setEnvironmentManager, setPackageManager, } from '../../../features/settings/settingHelpers'; -import { PythonProjectsImpl } from '../../../internal.api'; +import { PythonProjectSettings, PythonProjectsImpl } from '../../../internal.api'; import { MockWorkspaceConfiguration } from '../../mocks/mockWorkspaceConfig'; /** @@ -617,6 +619,410 @@ suite('Setting Helpers - Empty Path Migration', () => { }); }); +suite('Setting Helpers - Exact Project Removal', () => { + const INLINE_MANAGER_ID = 'ms-python.python:inline-script'; + const VENV_MANAGER_ID = 'ms-python.python:venv'; + const PIP_MANAGER_ID = 'ms-python.python:pip'; + const firstWorkspacePath = getTestWorkspacePath(); + const firstWorkspaceUri = Uri.file(firstWorkspacePath); + const firstWorkspace: WorkspaceFolder = { + uri: firstWorkspaceUri, + name: 'workspace', + index: 0, + }; + const secondWorkspaceUri = Uri.file(process.platform === 'win32' ? 'C:\\workspace2' : '/workspace2'); + const secondWorkspace: WorkspaceFolder = { + uri: secondWorkspaceUri, + name: 'workspace2', + index: 1, + }; + + let updateCalls: Array<{ + workspace: string; + key: string; + value: unknown; + target: boolean | ConfigurationTarget | undefined; + }>; + + setup(() => { + updateCalls = []; + }); + + teardown(() => { + sinon.restore(); + }); + + function createProjectConfig(options: { + workspaceName: string; + workspaceValue?: PythonProjectSettings[]; + workspaceFolderValue?: PythonProjectSettings[]; + }): MockWorkspaceConfiguration { + const mockConfig = new MockWorkspaceConfiguration(); + const mergedProjects = [...(options.workspaceValue ?? []), ...(options.workspaceFolderValue ?? [])]; + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => + key === 'pythonProjects' ? (mergedProjects as unknown as T) : defaultValue; + (mockConfig as any).inspect = (key: string) => + key === 'pythonProjects' + ? { + workspaceValue: options.workspaceValue, + workspaceFolderValue: options.workspaceFolderValue, + } + : undefined; + mockConfig.update = ( + section: string, + value: unknown, + configurationTarget?: boolean | ConfigurationTarget, + ): Promise => { + updateCalls.push({ + workspace: options.workspaceName, + key: section, + value, + target: configurationTarget, + }); + return Promise.resolve(); + }; + return mockConfig; + } + + function cloneSettings(settings: PythonProjectSettings[] | undefined): PythonProjectSettings[] { + return (settings ?? []).map((setting) => ({ ...setting })); + } + + function createSharedWorkspaceConfigs(options: { + workspaceValue: PythonProjectSettings[]; + firstWorkspaceFolderValue?: PythonProjectSettings[]; + secondWorkspaceFolderValue?: PythonProjectSettings[]; + }): { firstConfig: MockWorkspaceConfiguration; secondConfig: MockWorkspaceConfiguration; getWorkspaceValue: () => PythonProjectSettings[] } { + let sharedWorkspaceValue = cloneSettings(options.workspaceValue); + const workspaceFolderValues = new Map([ + [firstWorkspace.name, cloneSettings(options.firstWorkspaceFolderValue)], + [secondWorkspace.name, cloneSettings(options.secondWorkspaceFolderValue)], + ]); + + function createConfigForWorkspace(workspace: WorkspaceFolder): MockWorkspaceConfiguration { + const mockConfig = new MockWorkspaceConfiguration(); + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => + key === 'pythonProjects' + ? ([...sharedWorkspaceValue, ...workspaceFolderValues.get(workspace.name)!] as unknown as T) + : defaultValue; + (mockConfig as any).inspect = (key: string) => + key === 'pythonProjects' + ? { + workspaceValue: cloneSettings(sharedWorkspaceValue), + workspaceFolderValue: cloneSettings(workspaceFolderValues.get(workspace.name)), + } + : undefined; + mockConfig.update = ( + section: string, + value: unknown, + configurationTarget?: boolean | ConfigurationTarget, + ): Promise => { + updateCalls.push({ + workspace: workspace.name, + key: section, + value, + target: configurationTarget, + }); + const updatedSettings = cloneSettings(value as PythonProjectSettings[] | undefined); + if (configurationTarget === ConfigurationTarget.Workspace) { + sharedWorkspaceValue = updatedSettings; + } else if (configurationTarget === ConfigurationTarget.WorkspaceFolder) { + workspaceFolderValues.set(workspace.name, updatedSettings); + } + return Promise.resolve(); + }; + return mockConfig; + } + + return { + firstConfig: createConfigForWorkspace(firstWorkspace), + secondConfig: createConfigForWorkspace(secondWorkspace), + getWorkspaceValue: () => cloneSettings(sharedWorkspaceValue), + }; + } + + test('removes only the matching inline-script entry and preserves unrelated duplicates', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removePythonProjectSetting([{ project, envManager: INLINE_MANAGER_ID }]); + + assert.deepStrictEqual(removedProjects, [], 'Project should stay because another entry still targets the same path'); + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); + assert.strictEqual(updateCalls[0].key, 'pythonProjects'); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + }); + + test('dedupes duplicate project URIs with workspaceFolder precedence while keeping both sources visible', () => { + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + + const resolved = getResolvedPythonProjectSettings(firstWorkspace, config); + + assert.strictEqual(resolved.length, 1); + assert.strictEqual(resolved[0].effective.source, 'workspaceFolder'); + assert.strictEqual(resolved[0].effective.setting.envManager, VENV_MANAGER_ID); + assert.deepStrictEqual( + resolved[0].sources.map((source) => source.setting.envManager), + [INLINE_MANAGER_ID, VENV_MANAGER_ID], + ); + }); + + test('removes only one of two roots that share the same relative path', async () => { + const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const secondProject = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removePythonProjectSetting([{ project: firstProject, envManager: INLINE_MANAGER_ID }]); + + assert.deepStrictEqual(removedProjects.map((project) => project.uri.fsPath), [firstProject.uri.fsPath]); + assert.strictEqual(updateCalls.length, 1, 'Only the matching workspace folder should be updated'); + assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.WorkspaceFolder); + assert.strictEqual(updateCalls[0].value, undefined); + assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'script.py')); + }); + + test('removes a hidden shared inline entry while preserving a folder override for the same URI', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { + path: 'script.py', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + ], + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removePythonProjectSetting([{ project, envManager: INLINE_MANAGER_ID }]); + + assert.deepStrictEqual(removedProjects, [], 'Folder override should keep the project configured'); + const workspaceUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.Workspace); + const folderUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.WorkspaceFolder); + assert.ok(workspaceUpdate, 'WorkspaceValue source should be updated'); + assert.strictEqual(workspaceUpdate!.value, undefined); + assert.strictEqual(folderUpdate, undefined, 'Folder override should not be rewritten'); + }); + + test('aggregates shared workspaceValue removals across folders into one update', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl('second', Uri.file(path.join(secondWorkspaceUri.fsPath, 'second'))); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removePythonProjectSetting([ + { project: firstProject, envManager: INLINE_MANAGER_ID }, + { project: secondProject, envManager: INLINE_MANAGER_ID }, + ]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), []); + }); + + test('removes a subset from the shared workspace array without resurrecting siblings', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl('second', Uri.file(path.join(secondWorkspaceUri.fsPath, 'second'))); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + { + path: 'keep', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removePythonProjectSetting([{ project: firstProject, envManager: INLINE_MANAGER_ID }]); + + assert.deepStrictEqual(removedProjects.map((project) => project.uri.fsPath), [firstProject.uri.fsPath]); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should still be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), [ + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + { + path: 'keep', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ]); + assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'second')); + }); + + test('removes matching inline-script projects independently in a multi-root workspace', async () => { + const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const secondProject = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'keep-folder.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removePythonProjectSetting([ + { project: firstProject, envManager: INLINE_MANAGER_ID }, + { project: secondProject, envManager: INLINE_MANAGER_ID }, + ]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual(updateCalls.length, 2, 'Should update each workspace independently'); + const firstWorkspaceUpdate = updateCalls.find((call) => call.workspace === firstWorkspace.name); + const secondWorkspaceUpdate = updateCalls.find((call) => call.workspace === secondWorkspace.name); + assert.ok(firstWorkspaceUpdate, 'First workspace should receive an update'); + assert.ok(secondWorkspaceUpdate, 'Second workspace should receive an update'); + assert.strictEqual(firstWorkspaceUpdate!.value, undefined); + assert.deepStrictEqual(secondWorkspaceUpdate!.value, [ + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + assert.ok( + updateCalls.some((call) => call.workspace === firstWorkspace.name && call.target === ConfigurationTarget.Workspace) && + updateCalls.some( + (call) => + call.workspace === secondWorkspace.name && + call.target === ConfigurationTarget.WorkspaceFolder, + ), + 'Should update the same configuration scope that originally contained each project entry', + ); + }); +}); + suite('Setting Helpers - migrateGlobalDefaultEnvManagerSetting', () => { const SYSTEM_MANAGER_ID = 'ms-python.python:system'; const VENV_MANAGER_ID = 'ms-python.python:venv'; diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 7508d3739..1e5811a55 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -100,7 +100,6 @@ suite('InlineScriptEnvManager', () => { let ensureUvForVersionLookupStub: sinon.SinonStub; let globalStorageUri: Uri; let lockStub: sinon.SinonStub; - let log: LogOutputChannel; let manager: InlineScriptEnvManager; let nativeFinder: NativePythonFinder; let promptInstallPythonViaUvStub: sinon.SinonStub; @@ -185,8 +184,7 @@ suite('InlineScriptEnvManager', () => { }); clock = sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }); - log = makeFakeLog(); - manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); + manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); }); teardown(async () => { @@ -203,10 +201,6 @@ suite('InlineScriptEnvManager', () => { return cacheLayout.getScriptEnvDir(globalStorageUri, CACHE_KEY); } - function cacheRoot(): Uri { - return cacheLayout.getScriptEnvCacheRoot(globalStorageUri); - } - function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta): void { inspectMetaStub.resolves({ kind: 'valid', metadata }); } @@ -242,7 +236,6 @@ suite('InlineScriptEnvManager', () => { test('exposes creation but leaves later-phase methods empty', async () => { const asInterface: EnvironmentManager = manager; assert.strictEqual(typeof asInterface.create, 'function'); - assert.strictEqual(asInterface.clearCache, undefined); assert.strictEqual(asInterface.remove, undefined); assert.strictEqual(asInterface.quickCreateConfig, undefined); assert.deepStrictEqual(await manager.getEnvironments('all'), []); @@ -979,55 +972,16 @@ suite('InlineScriptEnvManager', () => { false, 'inline-script cache entries must not be tracked as workspace uv environments', ); - assert.strictEqual(lockStub.callCount, 2); - assert.strictEqual(lockStub.firstCall.args[0], cacheRoot().fsPath); - assert.strictEqual(lockStub.secondCall.args[0], envDir().fsPath); - assert.strictEqual(releaseLockStub.callCount, 2); - }); - - test('acquires the cache root lock before the final cache-entry lock and releases root before build', async () => { - const rootRelease = sinon.stub().resolves(); - const entryRelease = sinon.stub().resolves(); - lockStub.callsFake(async (lockPath: string) => { - if (lockPath === cacheRoot().fsPath) { - return { - retain: sinon.stub().resolves(), - release: rootRelease, - }; - } - return { - retain: sinon.stub().resolves(), - release: entryRelease, - }; - }); - createWithProgressStub.callsFake(async (...args: unknown[]) => { - assert.ok(rootRelease.calledOnce, 'root lock should be released before build starts'); - assert.strictEqual(entryRelease.called, false, 'entry lock should remain held during build'); - const envDir = args[6] as string; - const selectedBase = args[4] as PythonEnvironment; - await fs.outputFile(getVenvPythonPath(envDir), ''); - return { - environment: makeEnvironment( - 'ms-python.python:inline-script', - selectedBase.version, - getVenvPythonPath(envDir), - envDir, - ), - }; - }); + assert.ok(releaseLockStub.calledOnce); + }); + test('uses a bounded cross-process lock at the final cache path', async () => { await manager.create(scriptUri()); - assert.strictEqual(lockStub.firstCall.args[0], cacheRoot().fsPath); - assert.strictEqual(lockStub.secondCall.args[0], envDir().fsPath); - const rootOptions = lockStub.firstCall.args[1]; - const entryOptions = lockStub.secondCall.args[1]; - assert.strictEqual(rootOptions.timeoutMs, 1_000); - assert.strictEqual(rootOptions.retryIntervalMs, 50); - assert.strictEqual(entryOptions.timeoutMs, 1_000); - assert.strictEqual(entryOptions.retryIntervalMs, 50); - assert.ok(rootRelease.calledOnce); - assert.ok(entryRelease.calledOnce); + assert.strictEqual(lockStub.firstCall.args[0], envDir().fsPath); + const options = lockStub.firstCall.args[1]; + assert.ok(options.timeoutMs > 0); + assert.ok(options.retryIntervalMs > 0); }); test('coalesces simultaneous same-key creation within one extension host', async () => { @@ -1072,110 +1026,14 @@ suite('InlineScriptEnvManager', () => { const [firstResult, secondResult] = await Promise.all([first, second]); assert.strictEqual(firstResult, secondResult); - assert.strictEqual(lockStub.callCount, 2); + assert.strictEqual(lockStub.callCount, 1); assert.strictEqual(createWithProgressStub.callCount, 1); }); - test('returns undefined without building when the cache root lock cannot be acquired', async () => { - const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; + test('returns undefined without building when the cache lock cannot be acquired', async () => { lockStub.rejects(Object.assign(new Error('already locked'), { code: 'ELOCKED' })); assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(createWithProgressStub.callCount, 0); - sinon.assert.calledWithMatch( - log.warn as sinon.SinonStub, - sinon.match(new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i')), - ); - }); - - test('aborts before inspect/build when releasing the cache root lock for handoff fails', async () => { - const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; - const rootRelease = sinon.stub().callsFake(async () => { - await fs.ensureDir(rootLockPath); - throw new Error('root release failed'); - }); - const entryRelease = sinon.stub().resolves(); - lockStub.callsFake(async (lockPath: string) => { - if (lockPath === cacheRoot().fsPath) { - return { - retain: sinon.stub().resolves(), - release: rootRelease, - }; - } - return { - retain: sinon.stub().resolves(), - release: entryRelease, - }; - }); - - assert.strictEqual(await manager.create(scriptUri()), undefined); - assert.strictEqual(createWithProgressStub.callCount, 0); - assert.ok(rootRelease.calledOnce); - assert.ok(entryRelease.calledOnce); - assert.strictEqual(await fs.pathExists(rootLockPath), true); - sinon.assert.calledWithMatch( - log.warn as sinon.SinonStub, - sinon.match(new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i')), - ); - }); - - test('allows different cache entries to build concurrently after the root-to-entry handoff', async () => { - const secondCacheKey = 'fedcba9876543210'; - const secondEnvDir = cacheLayout.getScriptEnvDir(globalStorageUri, secondCacheKey); - computeCacheKeyStub.onFirstCall().returns(CACHE_KEY); - computeCacheKeyStub.onSecondCall().returns(secondCacheKey); - - let releaseFirstBuild: (() => void) | undefined; - const firstBuildGate = new Promise((resolve) => { - releaseFirstBuild = resolve; - }); - const secondBuildStarted = sinon.stub(); - createWithProgressStub.callsFake(async (...args: unknown[]) => { - const target = args[6] as string; - await fs.outputFile(venvPythonPath(target), ''); - if (target === envDir().fsPath) { - await firstBuildGate; - } else if (target === secondEnvDir.fsPath) { - secondBuildStarted(); - } - return { - environment: makeEnvironment( - 'ms-python.python:inline-script', - '3.12.4', - venvPythonPath(target), - target, - ), - }; - }); - - const first = manager.create(scriptUri('first.py')); - let second: Promise | undefined; - try { - await waitForStubCall(createWithProgressStub); - second = manager.create(scriptUri('second.py')); - await waitForStubCall(secondBuildStarted); - assert.ok(secondBuildStarted.calledOnce); - assert.strictEqual(createWithProgressStub.callCount, 2); - } finally { - releaseFirstBuild?.(); - await Promise.allSettled([first, second ?? Promise.resolve(undefined)]); - } - }); - - test('releases the cache root lock when the per-entry lock cannot be acquired', async () => { - const rootRelease = sinon.stub().resolves(); - lockStub.callsFake(async (lockPath: string) => { - if (lockPath === cacheRoot().fsPath) { - return { - retain: sinon.stub().resolves(), - release: rootRelease, - }; - } - throw Object.assign(new Error('entry locked'), { code: 'ELOCKED' }); - }); - - assert.strictEqual(await manager.create(scriptUri()), undefined); - assert.strictEqual(createWithProgressStub.callCount, 0); - assert.ok(rootRelease.calledOnce); }); }); @@ -1508,7 +1366,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await fs.pathExists(envDir().fsPath), true); assert.strictEqual(writeMetaStub.callCount, 0); assert.ok(retainLockStub.calledOnce); - assert.strictEqual(releaseLockStub.callCount, 2); + assert.ok(releaseLockStub.calledOnce); }); test('keeps a failed lock-retain transition fail-closed', async () => { @@ -1526,7 +1384,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.ok(retainLockStub.calledOnce); - assert.strictEqual(releaseLockStub.callCount, 2); + assert.ok(releaseLockStub.calledOnce); }); test('removes the partial environment when package installation fails', async () => { @@ -1547,7 +1405,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); assert.strictEqual(writeMetaStub.callCount, 0); - assert.strictEqual(releaseLockStub.callCount, 2); + assert.ok(releaseLockStub.calledOnce); }); test('removes the new environment when sidecar writing fails', async () => { @@ -1555,7 +1413,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); - assert.strictEqual(releaseLockStub.callCount, 2); + assert.ok(releaseLockStub.calledOnce); }); test('removes a partial environment when createWithProgress throws', async () => { @@ -1566,7 +1424,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); - assert.strictEqual(releaseLockStub.callCount, 2); + assert.ok(releaseLockStub.calledOnce); }); test('rejects and removes a created environment with a different Python release', async () => { @@ -1611,335 +1469,6 @@ suite('InlineScriptEnvManager', () => { }); }); - suite('clear cache', () => { - test('treats a missing cache root as idempotent and clears persisted associations', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - - await manager.set(uri, environment); - listener.resetHistory(); - await fs.remove(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath); - - await manager.clearScriptCache(); - await manager.clearScriptCache(); - - assert.strictEqual(workspaceState.clear.callCount, 2); - assert.deepStrictEqual(workspaceState.clear.firstCall.args[0], [INLINE_SCRIPT_ENVS_KEY]); - assert.strictEqual(persistedAssociations, undefined); - assert.strictEqual(await manager.get(uri), undefined); - sinon.assert.calledOnceWithMatch(listener, { old: environment, new: undefined }); - }); - - test('removes the cache root, clears state, and notifies known associations', async () => { - const firstUri = scriptUri('first.py'); - const secondUri = scriptUri('second.py'); - const firstEnvironment = await createOwnedEnvironment(); - const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - - await manager.set([firstUri, secondUri], firstEnvironment); - await manager.set(secondUri, secondEnvironment); - listener.resetHistory(); - - await manager.clearScriptCache(); - - assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); - assert.strictEqual(persistedAssociations, undefined); - assert.strictEqual(await manager.get(firstUri), undefined); - assert.strictEqual(await manager.get(secondUri), undefined); - assert.strictEqual(listener.callCount, 2); - assert.strictEqual(listener.firstCall.args[0].old, firstEnvironment); - assert.strictEqual(listener.firstCall.args[0].new, undefined); - assert.strictEqual(listener.secondCall.args[0].old, secondEnvironment); - assert.strictEqual(listener.secondCall.args[0].new, undefined); - }); - - test('refuses to clear while a create is active', async () => { - let releaseMetadata: ((value: metadataReader.InlineScriptMetadata | undefined) => void) | undefined; - readMetadataStub.callsFake( - () => - new Promise((resolve) => { - releaseMetadata = resolve; - }), - ); - - const createPromise = manager.create(scriptUri()); - - await assert.rejects( - manager.clearScriptCache(), - /Close other VS Code windows or restart VS Code, then retry/i, - ); - - releaseMetadata!(VALID_METADATA); - assert.ok(await createPromise); - }); - - test('refuses create requests while a clear is in progress', async () => { - let clearStarted: (() => void) | undefined; - let releaseClear: (() => void) | undefined; - const started = new Promise((resolve) => { - clearStarted = resolve; - }); - const gate = new Promise((resolve) => { - releaseClear = resolve; - }); - const clearManager = manager as unknown as { - getClearableCacheRootPath(cacheRoot: Uri): Promise; - }; - sinon.stub(clearManager, 'getClearableCacheRootPath').callsFake(async () => { - clearStarted!(); - await gate; - return undefined; - }); - - const clearPromise = manager.clearScriptCache(); - await started; - - await assert.rejects(manager.create(scriptUri()), /cache is being cleared/i); - - releaseClear!(); - await clearPromise; - }); - - test('refuses to clear when the cache root lock is already held', async () => { - const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; - lockStub.callsFake(async (lockPath: string) => { - if (lockPath === cacheRoot().fsPath) { - throw Object.assign(new Error('already locked'), { code: 'ELOCKED' }); - } - return { release: releaseLockStub, retain: retainLockStub }; - }); - - await assert.rejects( - manager.clearScriptCache(), - new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*remove only this lock path manually`, 'i'), - ); - assert.strictEqual(workspaceState.clear.callCount, 0); - }); - - test('rejects when cache deletion and state clear succeed but root lock release fails', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; - const rootRelease = sinon.stub().callsFake(async () => { - await fs.ensureDir(rootLockPath); - throw new Error('root release failed'); - }); - await manager.set(uri, environment); - lockStub.callsFake(async () => ({ - retain: sinon.stub().resolves(), - release: rootRelease, - })); - - await assert.rejects( - manager.clearScriptCache(), - new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i'), - ); - - assert.strictEqual(await fs.pathExists(cacheRoot().fsPath), false); - assert.strictEqual(persistedAssociations, undefined); - assert.ok(rootRelease.calledOnce); - }); - - test('refuses clear after the root-to-entry handoff because the entry lock is visible on disk', async () => { - const otherManager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); - const entryLockPath = `${path.resolve(envDir().fsPath)}.lock`; - let releaseBuild: (() => void) | undefined; - const buildGate = new Promise((resolve) => { - releaseBuild = resolve; - }); - createWithProgressStub.callsFake(async (...args: unknown[]) => { - const target = args[6] as string; - const selectedBase = args[4] as PythonEnvironment; - await fs.outputFile(getVenvPythonPath(target), ''); - await buildGate; - return { - environment: makeEnvironment( - 'ms-python.python:inline-script', - selectedBase.version, - getVenvPythonPath(target), - target, - ), - }; - }); - lockStub.callsFake(async (lockPath: string) => { - if (lockPath === envDir().fsPath) { - await fs.ensureDir(entryLockPath); - await fs.outputFile(path.join(entryLockPath, 'owner-1234'), ''); - return { - retain: sinon.stub().resolves(), - release: sinon.stub().callsFake(async () => { - await fs.remove(entryLockPath); - }), - }; - } - return { - retain: sinon.stub().resolves(), - release: sinon.stub().resolves(), - }; - }); - - const createPromise = manager.create(scriptUri()); - try { - await waitForStubCall(createWithProgressStub); - await assert.rejects(otherManager.clearScriptCache(), /owner-only lock/i); - } finally { - releaseBuild!(); - await createPromise; - otherManager.dispose(); - } - }); - - test('allows retained lock directories to be removed with the cache root', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - await manager.set(uri, environment); - const lockPath = path.join(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath, `${CACHE_KEY}.lock`); - await fs.ensureDir(lockPath); - await fs.outputFile(path.join(lockPath, 'owner-1234'), ''); - await fs.outputFile(path.join(lockPath, 'retained'), ''); - - await manager.clearScriptCache(); - - assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); - assert.strictEqual(persistedAssociations, undefined); - }); - - test('rejects active owner lock directories', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - await manager.set(uri, environment); - const lockPath = path.join(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath, `${CACHE_KEY}.lock`); - await fs.ensureDir(lockPath); - await fs.outputFile(path.join(lockPath, 'owner-1234'), ''); - - await assert.rejects( - manager.clearScriptCache(), - new RegExp(`${lockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually remove`, 'i'), - ); - - assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), true); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, - }); - }); - - test('rejects orphaned or malformed lock entries', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - const cacheRootPath = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; - const lockPath = path.join(cacheRootPath, `${CACHE_KEY}.lock`); - await manager.set(uri, environment); - - await fs.ensureDir(lockPath); - await assert.rejects(manager.clearScriptCache(), /could not be verified as retained/i); - await fs.remove(lockPath); - - await fs.outputFile(lockPath, 'not a directory'); - await assert.rejects(manager.clearScriptCache(), /could not be verified as retained/i); - - assert.strictEqual(await fs.pathExists(cacheRootPath), true); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, - }); - }); - - test('fails closed when the cache root is redirected through a symlink or junction', async function () { - const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri); - const externalRoot = path.join(tempRoot, 'external-cache-root'); - const markerPath = path.join(externalRoot, 'keep.txt'); - await fs.ensureDir(globalStorageUri.fsPath); - await fs.remove(cacheRoot.fsPath); - await fs.outputFile(markerPath, 'keep'); - try { - await fs.symlink(externalRoot, cacheRoot.fsPath, isWindows() ? 'junction' : 'dir'); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'EPERM' || code === 'EACCES') { - this.skip(); - return; - } - throw error; - } - - await assert.rejects(manager.clearScriptCache(), /could not be proven safe/i); - - assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); - assert.strictEqual((await fs.lstat(cacheRoot.fsPath)).isSymbolicLink(), true); - }); - - test('surfaces state clear failures after removing the cache root and clearing in-memory state', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - - await manager.set(uri, environment); - listener.resetHistory(); - workspaceState.clear.rejects(new Error('Memento unavailable')); - - await assert.rejects(manager.clearScriptCache(), /Memento unavailable/); - - const clearState = manager as unknown as { - fsPathToEnv: Map; - fsPathToPersistedEnvPath: Map; - }; - assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, - }); - assert.strictEqual(clearState.fsPathToEnv.size, 0); - assert.strictEqual(clearState.fsPathToPersistedEnvPath.size, 0); - sinon.assert.calledOnceWithMatch(listener, { old: environment, new: undefined }); - }); - - test('surfaces disk deletion failures without clearing state', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - await manager.set(uri, environment); - const cacheRootPath = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; - const clearManager = manager as unknown as { - removeClearableCacheRoot(cacheRootPath: string): Promise; - }; - sinon.stub(clearManager, 'removeClearableCacheRoot').rejects(new Error('disk busy')); - - await assert.rejects(manager.clearScriptCache(), /disk busy/); - - assert.strictEqual(await fs.pathExists(cacheRootPath), true); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, - }); - assert.strictEqual(await manager.get(uri), environment); - }); - - test('does not let a pending rehydration repopulate after clear', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; - - let resolvePending: ((value: PythonEnvironment | undefined) => void) | undefined; - resolveVenvStub.callsFake( - () => - new Promise((resolve) => { - resolvePending = resolve; - }), - ); - - const pendingGet = manager.get(uri); - await waitForStubCall(resolveVenvStub); - - await manager.clearScriptCache(); - resolvePending!(environment); - - assert.strictEqual(await pendingGet, undefined); - assert.strictEqual(await manager.get(uri), undefined); - assert.strictEqual(persistedAssociations, undefined); - }); - }); - suite('events and disposal', () => { test('create does not establish an association or fire later-phase events', async () => { const environmentsListener = sinon.spy(); @@ -2634,4 +2163,313 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(Uri.parse('untitled:script.py')), undefined); }); }); + + suite('clear cache', () => { + test('clears cached environments, persisted associations, and in-memory selections', async () => { + const first = scriptUri('first.py'); + const second = scriptUri('second.py'); + const environment = await createOwnedEnvironment(); + await manager.set([first, second], environment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(first), undefined); + assert.strictEqual(await manager.get(second), undefined); + assert.deepStrictEqual( + listener.getCalls().map((call) => normalizePath(call.args[0].uri.fsPath)).sort(), + [first.fsPath, second.fsPath].map((value) => normalizePath(value)).sort(), + ); + assert.deepStrictEqual( + listener.getCalls().map((call) => call.args[0].old), + [environment, environment], + ); + assert.ok(listener.getCalls().every((call) => call.args[0].new === undefined)); + }); + + test('clears associations even when the cache directory is already missing', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + await fs.remove(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath); + + await manager.clearCache(); + + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(listener.callCount, 1); + assert.strictEqual(normalizePath(listener.firstCall.args[0].uri.fsPath), normalizePath(uri.fsPath)); + assert.strictEqual(listener.firstCall.args[0].old, environment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + }); + + test('is idempotent when the cache and associations are already absent', async () => { + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.clearCache(); + await manager.clearCache(); + + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(listener.callCount, 0); + }); + + test('refuses to clear from an unsafe cache root', async function () { + if (isWindows() && !process.env.SystemDrive) { + this.skip(); + } + const unsafeManager = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + Uri.file(process.platform === 'win32' ? `${process.env.SystemDrive ?? 'C:'}\\` : '/'), + makeFakeLog(), + ); + + await assert.rejects( + unsafeManager.clearCache(), + /unsafe cache root/, + ); + + unsafeManager.dispose(); + }); + + test('refuses to clear a symlinked cache root', async function () { + const symlinkStorageUri = Uri.file(path.join(tempRoot, 'symlink-storage')); + const symlinkManager = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + symlinkStorageUri, + makeFakeLog(), + ); + const realCacheRoot = cacheLayout.getScriptEnvCacheRoot(symlinkStorageUri).fsPath; + const externalCacheRoot = path.join(tempRoot, 'external-cache-root'); + await fs.ensureDir(symlinkStorageUri.fsPath); + await fs.ensureDir(externalCacheRoot); + try { + await fs.symlink(externalCacheRoot, realCacheRoot, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + } + throw error; + } + + await assert.rejects( + symlinkManager.clearCache(), + /not a normal directory/, + ); + + symlinkManager.dispose(); + }); + + test('refuses to clear when globalStorage is redirected through a symlink or junction', async function () { + const physicalStoragePath = path.join(tempRoot, 'physical-storage'); + const redirectedStoragePath = path.join(tempRoot, 'redirected-storage'); + await fs.ensureDir(physicalStoragePath); + await fs.ensureDir(redirectedStoragePath); + const redirectedManager = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + Uri.file(redirectedStoragePath), + makeFakeLog(), + ); + try { + await fs.remove(redirectedStoragePath); + await fs.symlink( + physicalStoragePath, + redirectedStoragePath, + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + } + throw error; + } + + await assert.rejects(redirectedManager.clearCache(), /global storage root is not a normal directory/); + + redirectedManager.dispose(); + }); + + test('fails closed when physical cache verification reports a redirected root', async () => { + const internalManager = manager as unknown as { + getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise; + }; + const original = internalManager.getPhysicalOwnedCacheRootPath.bind(manager); + internalManager.getPhysicalOwnedCacheRootPath = async () => { + throw new Error('Refusing to clear the script environment cache because the cache root is redirected.'); + }; + try { + await assert.rejects(manager.clearCache(), /cache root is redirected/); + } finally { + internalManager.getPhysicalOwnedCacheRootPath = original; + } + }); + + test('refuses to clear while a cached environment is locked', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `owner-${process.pid}-test`), ''); + + await assert.rejects(manager.clearCache(), /being created/); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(uri), environment); + }); + + test('clears a retained lock and its corresponding cache entry', async () => { + const retainedCacheDir = envDir().fsPath; + const retainedLockPath = lockfileApis.getFileLockPath(retainedCacheDir); + await fs.outputFile(venvPythonPath(retainedCacheDir), ''); + await fs.ensureDir(retainedLockPath); + await fs.writeFile(path.join(retainedLockPath, 'retained'), ''); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(retainedCacheDir), false); + assert.strictEqual(await fs.pathExists(retainedLockPath), false); + }); + + test('clears a stale owner lock and its corresponding cache entry', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const staleLockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + await fs.ensureDir(staleLockPath); + await fs.writeFile(path.join(staleLockPath, 'owner-424242-dead'), ''); + const originalInspectFileLock = lockfileApis.inspectFileLock; + sinon.stub(lockfileApis, 'inspectFileLock').callsFake(async (filePath, options) => { + if (normalizePath(filePath) === normalizePath(environment.sysPrefix)) { + return 'stale'; + } + return originalInspectFileLock(filePath, options); + }); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), false); + assert.strictEqual(await fs.pathExists(staleLockPath), false); + assert.strictEqual(await manager.get(uri), undefined); + }); + + test('rejects an orphaned lock directory conservatively', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + await fs.ensureDir(lockfileApis.getFileLockPath(environment.sysPrefix)); + + await assert.rejects(manager.clearCache(), /incomplete or malformed/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + assert.strictEqual(await manager.get(uri), environment); + }); + + test('surfaces a persistence failure after clearing disk and memory state', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + workspaceState.clear.onFirstCall().rejects(new Error('Memento unavailable')); + + await assert.rejects(manager.clearCache(), /Memento unavailable/); + + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(listener.callCount, 1); + assert.strictEqual(normalizePath(listener.firstCall.args[0].uri.fsPath), normalizePath(uri.fsPath)); + assert.strictEqual(listener.firstCall.args[0].old, environment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + }); + + test('does not let a pending rehydration restore an association after clear cache', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + let resolveRehydration: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolveRehydration = resolve; + }), + ); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + const pendingGet = manager.get(uri); + await waitForStubCall(resolveVenvStub); + await manager.clearCache(); + resolveRehydration!(environment); + + assert.strictEqual(await pendingGet, undefined); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(listener.callCount, 0); + }); + + test('rejects clear when creation started before the clear request', async () => { + const uri = scriptUri(); + let resolveMetadata: ((value: metadataReader.InlineScriptMetadata | undefined) => void) | undefined; + readMetadataStub.callsFake( + () => + new Promise((resolve) => { + resolveMetadata = resolve; + }), + ); + + const createPromise = manager.create(uri); + + await assert.rejects(manager.clearCache(), /being created/); + resolveMetadata!(VALID_METADATA); + assert.ok(await createPromise); + assert.strictEqual(await fs.pathExists(envDir().fsPath), true); + }); + + test('queues create behind a clear request that started first', async () => { + const uri = scriptUri(); + let releaseClear: (() => void) | undefined; + let signalClearStarted: (() => void) | undefined; + const clearStarted = new Promise((resolve) => { + signalClearStarted = resolve; + }); + workspaceState.clear.callsFake( + async (keys?: string[]) => + new Promise((resolve) => { + signalClearStarted!(); + releaseClear = () => { + if (!keys || keys.includes(INLINE_SCRIPT_ENVS_KEY)) { + persistedAssociations = undefined; + } + resolve(); + }; + }), + ); + + const clearPromise = manager.clearCache(); + const createPromise = manager.create(uri); + + await clearStarted; + assert.strictEqual(readMetadataStub.callCount, 0); + releaseClear!(); + await clearPromise; + + assert.ok(await createPromise); + assert.ok(readMetadataStub.calledOnce); + }); + }); }); diff --git a/src/test/managers/builtin/inlineScript/main.unit.test.ts b/src/test/managers/builtin/inlineScript/main.unit.test.ts index 1fec3cd12..d109e318d 100644 --- a/src/test/managers/builtin/inlineScript/main.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/main.unit.test.ts @@ -51,37 +51,23 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(false); const disposables: Disposable[] = []; - const result = await registerInlineScriptFeatures( - nativeFinder, - disposables, - makeFakeLog(), - baseManager, - globalStorageUri, - ); + await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); assert.strictEqual(disposables.length, 0, 'no disposables should be added when flag is off'); assert.strictEqual(getPythonApiStub.called, false, 'should not even call getPythonApi when gated off'); assert.strictEqual(registerEnvironmentManagerStub.called, false); - assert.strictEqual(result, undefined); }); test('when the feature flag is TRUE: registers the manager and pushes the disposable', async () => { isEnabledStub.returns(true); const disposables: Disposable[] = []; - const result = await registerInlineScriptFeatures( - nativeFinder, - disposables, - makeFakeLog(), - baseManager, - globalStorageUri, - ); + await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); assert.strictEqual(getPythonApiStub.callCount, 1); assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); assert.strictEqual(disposables.length, 2, 'expected manager + registration disposable'); const manager = registerEnvironmentManagerStub.firstCall.args[0]; - assert.strictEqual(result, manager); assert.ok(disposables.includes(manager), 'manager itself should be disposed'); assert.ok( disposables.includes(registerEnvironmentManagerStub.firstCall.returnValue), diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index 176aba815..727bf2bc0 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -26,10 +26,9 @@ suite('Smoke: Registration Checks', function () { this.timeout(MAX_EXTENSION_ACTIVATION_TIME); let api: PythonEnvironmentApi; - let extension: vscode.Extension; suiteSetup(async function () { - extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID)!; + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); if (!extension.isActive) { @@ -66,7 +65,7 @@ suite('Smoke: Registration Checks', function () { 'python-envs.setPkgManager', 'python-envs.refreshAllManagers', 'python-envs.clearCache', - 'python-envs.clearInlineScriptCache', + 'python-envs.clearScriptEnvCache', 'python-envs.searchSettings', // Package management @@ -115,41 +114,6 @@ suite('Smoke: Registration Checks', function () { ); }); - test('Clear cache commands are contributed from package.json', function () { - const clearCacheCommand = extension.packageJSON?.contributes?.commands?.find( - (item: { command: string }) => item.command === 'python-envs.clearCache', - ); - const clearInlineScriptCacheCommand = extension.packageJSON?.contributes?.commands?.find( - (item: { command: string }) => item.command === 'python-envs.clearInlineScriptCache', - ); - const clearInlineScriptCachePaletteEntry = extension.packageJSON?.contributes?.menus?.commandPalette?.find( - (item: { command: string }) => item.command === 'python-envs.clearInlineScriptCache', - ); - - assert.ok(clearCacheCommand, 'python-envs.clearCache should be contributed in package.json'); - assert.strictEqual(clearCacheCommand.category, 'Python'); - assert.strictEqual(clearCacheCommand.title, 'Clear Cache'); - - assert.ok( - clearInlineScriptCacheCommand, - 'python-envs.clearInlineScriptCache should be contributed in package.json', - ); - assert.strictEqual(clearInlineScriptCacheCommand.category, 'Python'); - assert.strictEqual(clearInlineScriptCacheCommand.title, 'Clear Script Environment Cache'); - assert.strictEqual( - clearInlineScriptCacheCommand.enablement, - 'config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true', - ); - assert.ok( - clearInlineScriptCachePaletteEntry, - 'python-envs.clearInlineScriptCache should have a command palette contribution', - ); - assert.strictEqual( - clearInlineScriptCachePaletteEntry.when, - 'config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true', - ); - }); - // ========================================================================= // API METHODS - All API methods must exist and be functions // ========================================================================= From 4eb06100ed97e1f728328371ada094f4115b7628 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 14:20:53 -0700 Subject: [PATCH 5/7] Hide inline script cleanup while preview is disabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cb82ae9-7424-40a4-9156-8c54ac6e0895 --- package.json | 10 - package.nls.json | 1 - src/extension.ts | 17 +- src/features/envCommands.ts | 40 +- src/features/settings/settingHelpers.ts | 213 ++++--- src/test/features/envCommands.unit.test.ts | 214 ++----- .../settings/settingHelpers.unit.test.ts | 545 ++++++++++-------- src/test/smoke/registration.smoke.test.ts | 33 +- 8 files changed, 510 insertions(+), 563 deletions(-) diff --git a/package.json b/package.json index 0a9a6abaf..dd7cba3cf 100644 --- a/package.json +++ b/package.json @@ -245,12 +245,6 @@ "category": "Python", "icon": "$(trash)" }, - { - "command": "python-envs.clearScriptEnvCache", - "title": "%python-envs.clearScriptEnvCache.title%", - "category": "Python", - "icon": "$(trash)" - }, { "command": "python-envs.runInTerminal", "title": "%python-envs.runInTerminal.title%", @@ -471,10 +465,6 @@ { "command": "python-envs.reportIssue", "when": "config.python.useEnvironmentsExtension != false" - }, - { - "command": "python-envs.clearScriptEnvCache", - "when": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" } ], "view/item/context": [ diff --git a/package.nls.json b/package.nls.json index c128863de..483ecfd29 100644 --- a/package.nls.json +++ b/package.nls.json @@ -35,7 +35,6 @@ "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", - "python-envs.clearScriptEnvCache.title": "Clear Script Environment Cache", "python-envs.runInTerminal.title": "Run in Terminal", "python-envs.createTerminal.title": "Create Python Terminal", "python-envs.runAsTask.title": "Run as Task", diff --git a/src/extension.ts b/src/extension.ts index e3735b4ad..f45d46aa2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -95,7 +95,12 @@ import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; import { updateViewsAndStatus } from './features/views/revealHandler'; import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; -import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; +import { + collectEnvironmentInfo, + getEnvManagerAndPackageManagerConfigLevels, + isInlineScriptsFeatureEnabled, + runPetInTerminalImpl, +} from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main'; import { registerSystemPythonFeatures } from './managers/builtin/main'; @@ -387,9 +392,13 @@ export async function activate(context: ExtensionContext): Promise { - await clearScriptEnvironmentCacheCommand(envManagers, projectManager); - }), + ...(isInlineScriptsFeatureEnabled() + ? [ + commands.registerCommand('python-envs.clearScriptEnvCache', async () => { + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + }), + ] + : []), commands.registerCommand('python-envs.runInTerminal', (item) => { return runInTerminalCommand(item, api, terminalManager); }), diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 0136539f1..fafb6fbcd 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -27,7 +27,7 @@ import { PythonProjectManager, } from '../internal.api'; import { - getResolvedPythonProjectSettings, + removeInlineScriptPythonProjectSettings, removePythonProjectSetting, setEnvironmentManager, setPackageManager, @@ -58,7 +58,6 @@ import { showWarningMessage, withProgress, } from '../common/window.apis'; -import { getWorkspaceFolders } from '../common/workspace.apis'; import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants'; import { runAsTask } from './execution/runAsTask'; import { runInTerminal } from './terminal/runInTerminal'; @@ -670,34 +669,6 @@ export async function removePythonProject( wm.remove(item.project); } -function getInlineScriptProjectEdits(wm: PythonProjectManager) { - const currentProjects = new Map(wm.getProjects().map((project) => [project.uri.toString(), project] as const)); - const edits = new Map(); - for (const workspaceFolder of getWorkspaceFolders() ?? []) { - for (const resolvedSetting of getResolvedPythonProjectSettings(workspaceFolder)) { - if ( - !resolvedSetting.sources.some( - (source) => source.setting.envManager === INLINE_SCRIPT_MANAGER_ID, - ) - ) { - continue; - } - const projectUri = resolvedSetting.uri; - const key = projectUri.toString(); - edits.set(key, { - project: - currentProjects.get(key) ?? - wm.create(path.basename(projectUri.fsPath) || resolvedSetting.effective.setting.path, projectUri), - envManager: INLINE_SCRIPT_MANAGER_ID, - }); - } - } - return { - edits: Array.from(edits.values()), - loadedProjects: currentProjects, - }; -} - export async function clearScriptEnvironmentCacheCommand( em: EnvironmentManagers, wm: PythonProjectManager, @@ -721,15 +692,8 @@ export async function clearScriptEnvironmentCacheCommand( return; } - const { edits, loadedProjects } = getInlineScriptProjectEdits(wm); await manager.clearCache(); - if (edits.length === 0) { - return; - } - const removedProjects = await removePythonProjectSetting(edits); - const loadedProjectsToRemove = removedProjects - .map((project) => loadedProjects.get(project.uri.toString())) - .filter((project): project is PythonProject => project !== undefined); + const loadedProjectsToRemove = await removeInlineScriptPythonProjectSettings(wm.getProjects()); if (loadedProjectsToRemove.length > 0) { wm.remove(loadedProjectsToRemove); } diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 25b2eca9d..4d84e4321 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -1,7 +1,12 @@ import * as path from 'path'; import { ConfigurationScope, ConfigurationTarget, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { PythonProject } from '../../api'; -import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../common/constants'; +import { + DEFAULT_ENV_MANAGER_ID, + DEFAULT_PACKAGE_MANAGER_ID, + INLINE_SCRIPT_MANAGER_ID, + SYSTEM_MANAGER_ID, +} from '../../common/constants'; import { traceError, traceInfo, traceVerbose, traceWarn } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; import { normalizePath } from '../../common/utils/pathUtils'; @@ -10,7 +15,7 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import * as workspaceApis from '../../common/workspace.apis'; import { PythonProjectManager, PythonProjectSettings } from '../../internal.api'; -export interface ResolvedPythonProjectSettingSource { +interface ResolvedPythonProjectSettingSource { readonly setting: PythonProjectSettings; readonly uri: Uri; readonly workspaceFolder: WorkspaceFolder; @@ -18,7 +23,7 @@ export interface ResolvedPythonProjectSettingSource { readonly target: ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder; } -export interface ResolvedPythonProjectSetting { +interface ResolvedPythonProjectSetting { readonly uri: Uri; readonly workspaceFolder: WorkspaceFolder; readonly effective: ResolvedPythonProjectSettingSource; @@ -60,7 +65,7 @@ function resolveProjectSettingUri( : undefined; } -export function getResolvedPythonProjectSettings( +function getResolvedPythonProjectSettings( workspaceFolder: WorkspaceFolder, config: WorkspaceConfiguration = workspaceApis.getConfiguration('python-envs', workspaceFolder.uri), ): ResolvedPythonProjectSetting[] { @@ -495,6 +500,115 @@ function cloneProjectSettings( return settings?.map((setting) => ({ ...setting })); } +export async function removeInlineScriptPythonProjectSettings( + currentProjects: readonly PythonProject[], +): Promise { + const currentProjectsByUri = new Map(currentProjects.map((project) => [project.uri.toString(), project] as const)); + const workspaceEntries: Array = []; + for (const workspaceFolder of workspaceApis.getWorkspaceFolders() ?? []) { + const edits: EditProjectSettings[] = getResolvedPythonProjectSettings(workspaceFolder) + .filter((resolvedSetting) => + resolvedSetting.sources.some((source) => source.setting.envManager === INLINE_SCRIPT_MANAGER_ID), + ) + .map((resolvedSetting) => ({ + project: + currentProjectsByUri.get(resolvedSetting.uri.toString()) ?? { + name: path.basename(resolvedSetting.uri.fsPath) || resolvedSetting.effective.setting.path, + uri: resolvedSetting.uri, + }, + envManager: INLINE_SCRIPT_MANAGER_ID, + })); + + if (edits.length > 0) { + workspaceEntries.push([workspaceFolder, edits]); + } + } + + if (workspaceEntries.length === 0) { + return []; + } + + const removedProjects = new Map(); + const folderRemainingSettings = new Map(); + const folderExistingSettings = new Map(); + const promises: Thenable[] = []; + let workspaceConfig: WorkspaceConfiguration | undefined; + let workspaceValueOriginal: PythonProjectSettings[] | undefined; + + workspaceEntries.forEach(([workspaceFolder, edits]) => { + const config = workspaceApis.getConfiguration('python-envs', workspaceFolder.uri); + const projectsInspect = config.inspect('pythonProjects'); + workspaceConfig ??= config; + workspaceValueOriginal ??= cloneProjectSettings(projectsInspect?.workspaceValue); + + const workspaceFolderOriginal = cloneProjectSettings(projectsInspect?.workspaceFolderValue) ?? []; + folderExistingSettings.set(workspaceFolder.uri.toString(), workspaceFolderOriginal); + const workspaceFolderRemaining = workspaceFolderOriginal.filter( + (projectSetting) => !edits.some((edit) => matchesProjectSettingEdit(projectSetting, edit, workspaceFolder)), + ); + folderRemainingSettings.set(workspaceFolder.uri.toString(), workspaceFolderRemaining); + + if (workspaceFolderRemaining.length !== workspaceFolderOriginal.length) { + promises.push( + config.update( + 'pythonProjects', + workspaceFolderRemaining.length > 0 ? workspaceFolderRemaining : undefined, + ConfigurationTarget.WorkspaceFolder, + ), + ); + } + }); + + const aggregatedEdits = workspaceEntries.flatMap(([workspaceFolder, edits]) => + edits.map((edit) => ({ workspaceFolder, edit })), + ); + const workspaceValueRemaining = + workspaceValueOriginal?.filter( + (projectSetting) => + !aggregatedEdits.some(({ workspaceFolder, edit }) => + matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), + ), + ) ?? []; + + if ( + workspaceConfig && + workspaceValueOriginal !== undefined && + workspaceValueRemaining.length !== workspaceValueOriginal.length + ) { + promises.push( + workspaceConfig.update( + 'pythonProjects', + workspaceValueRemaining.length > 0 ? workspaceValueRemaining : undefined, + ConfigurationTarget.Workspace, + ), + ); + } + + workspaceEntries.forEach(([workspaceFolder, edits]) => { + const existingSettings = [ + ...(workspaceValueOriginal ?? []), + ...((folderExistingSettings.get(workspaceFolder.uri.toString()) ?? [])), + ]; + const remainingSettings = [ + ...workspaceValueRemaining, + ...((folderRemainingSettings.get(workspaceFolder.uri.toString()) ?? [])), + ]; + edits.filter( + (edit) => + existingSettings.some((projectSetting) => matchesProjectSettingEdit(projectSetting, edit, workspaceFolder)) && + !hasProjectSetting(remainingSettings, edit.project, workspaceFolder), + ).forEach((edit) => { + removedProjects.set(edit.project.uri.toString(), edit.project); + }); + }); + + await Promise.all(promises); + + return Array.from(removedProjects.values()) + .map((project) => currentProjectsByUri.get(project.uri.toString())) + .filter((project): project is PythonProject => project !== undefined); +} + export async function addPythonProjectSetting(edits: EditProjectSettings[]): Promise { const noWorkspace: EditProjectSettings[] = []; const workspaces = new Map(); @@ -591,7 +705,7 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro await Promise.all(promises); } -export async function removePythonProjectSetting(edits: EditProjectSettings[]): Promise { +export async function removePythonProjectSetting(edits: EditProjectSettings[]): Promise { const noWorkspace: EditProjectSettings[] = []; const workspaces = new Map(); edits.forEach((e) => { @@ -607,87 +721,24 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): traceError(`Unable to find workspace for ${e.project.uri.fsPath}`); }); - const workspaceEntries = Array.from(workspaces.entries()); - if (workspaceEntries.length === 0) { - return []; - } - - const removedProjects = new Map(); - const folderRemainingSettings = new Map(); - const folderExistingSettings = new Map(); const promises: Thenable[] = []; - let workspaceConfig: WorkspaceConfiguration | undefined; - let workspaceValueOriginal: PythonProjectSettings[] | undefined; - - workspaceEntries.forEach(([w, es]) => { + workspaces.forEach((es, w) => { const config = workspaceApis.getConfiguration('python-envs', w.uri); - const projectsInspect = config.inspect('pythonProjects'); - workspaceConfig ??= config; - workspaceValueOriginal ??= cloneProjectSettings(projectsInspect?.workspaceValue); - - const workspaceFolderOriginal = cloneProjectSettings(projectsInspect?.workspaceFolderValue) ?? []; - folderExistingSettings.set(w.uri.toString(), workspaceFolderOriginal); - const workspaceFolderRemaining = workspaceFolderOriginal.filter( - (projectSetting) => !es.some((edit) => matchesProjectSettingEdit(projectSetting, edit, w)), - ); - folderRemainingSettings.set(w.uri.toString(), workspaceFolderRemaining); - - if (workspaceFolderRemaining.length !== workspaceFolderOriginal.length) { - promises.push( - config.update( - 'pythonProjects', - workspaceFolderRemaining.length > 0 ? workspaceFolderRemaining : undefined, - ConfigurationTarget.WorkspaceFolder, - ), - ); - } - }); - - const aggregatedEdits = workspaceEntries.flatMap(([workspaceFolder, workspaceEdits]) => - workspaceEdits.map((edit) => ({ workspaceFolder, edit })), - ); - const workspaceValueRemaining = - workspaceValueOriginal?.filter( - (projectSetting) => - !aggregatedEdits.some(({ workspaceFolder, edit }) => - matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), - ), - ) ?? []; - - if ( - workspaceConfig && - workspaceValueOriginal !== undefined && - workspaceValueRemaining.length !== workspaceValueOriginal.length - ) { - promises.push( - workspaceConfig.update( - 'pythonProjects', - workspaceValueRemaining.length > 0 ? workspaceValueRemaining : undefined, - ConfigurationTarget.Workspace, - ), - ); - } - - workspaceEntries.forEach(([w, es]) => { - const existingSettings = [ - ...(workspaceValueOriginal ?? []), - ...((folderExistingSettings.get(w.uri.toString()) ?? [])), - ]; - const remainingSettings = [ - ...workspaceValueRemaining, - ...((folderRemainingSettings.get(w.uri.toString()) ?? [])), - ]; - es.filter( - (edit) => - existingSettings.some((projectSetting) => matchesProjectSettingEdit(projectSetting, edit, w)) && - !hasProjectSetting(remainingSettings, edit.project, w), - ).forEach((edit) => { - removedProjects.set(edit.project.uri.toString(), edit.project); + const overrides = config.get('pythonProjects', []); + es.forEach((e) => { + const pwPath = normalizePath(e.project.uri.fsPath); + const index = overrides.findIndex((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); + if (index >= 0) { + overrides.splice(index, 1); + } }); + if (overrides.length === 0) { + promises.push(config.update('pythonProjects', undefined, ConfigurationTarget.Workspace)); + } else { + promises.push(config.update('pythonProjects', overrides, ConfigurationTarget.Workspace)); + } }); - await Promise.all(promises); - return Array.from(removedProjects.values()); } /** diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index e31e98adc..f47ceb15a 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -1,15 +1,13 @@ import * as assert from 'assert'; -import * as path from 'path'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { Uri, WorkspaceFolder } from 'vscode'; +import { Uri } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../api'; import * as commandApi from '../../common/command.api'; import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; import * as windowApis from '../../common/window.apis'; -import * as workspaceApis from '../../common/workspace.apis'; import { clearScriptEnvironmentCacheCommand, createAnyEnvironmentCommand, @@ -213,7 +211,6 @@ suite('Remove Python Project Command Tests', () => { } as unknown as PythonProjectManager; sinon.stub(settingHelpers, 'removePythonProjectSetting').callsFake(async () => { calls.push('removeSetting'); - return []; }); await removePythonProject(item, projectManager, envManagers); @@ -227,13 +224,6 @@ suite('Remove Python Project Command Tests', () => { }); suite('Clear Script Environment Cache Command Tests', () => { - const workspacePath = process.platform === 'win32' ? 'C:\\workspace' : '/workspace'; - const workspaceFolder: WorkspaceFolder = { - uri: Uri.file(workspacePath), - name: 'workspace', - index: 0, - }; - teardown(() => { sinon.restore(); }); @@ -248,26 +238,27 @@ suite('Clear Script Environment Cache Command Tests', () => { } as unknown as EnvironmentManagers; const projectManager = { getProjects: sinon.stub().returns([]), - create: sinon.stub(), remove: sinon.stub(), } as unknown as PythonProjectManager; sinon.stub(windowApis, 'showWarningMessage').resolves(undefined); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([]); - const removeSettings = sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves([]); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); await clearScriptEnvironmentCacheCommand(envManagers, projectManager); sinon.assert.notCalled(clearCache); - sinon.assert.notCalled(removeSettings); + sinon.assert.notCalled(removeInlineSettings); sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); }); - test('clears the cache and removes only inline-script projects returned by the settings cleanup', async () => { + test('clears cache before inline settings cleanup and unloads removed projects', async () => { + const calls: string[] = []; const inlineProject: PythonProject = { - uri: Uri.file(path.join(workspacePath, 'script.py')), + uri: Uri.file('/workspace/script.py'), name: 'script.py', }; - const clearCache = sinon.stub().resolves(); + const clearCache = sinon.stub().callsFake(async () => { + calls.push('clearCache'); + }); const envManagers = { getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ supportsClearCache: () => true, @@ -275,136 +266,35 @@ suite('Clear Script Environment Cache Command Tests', () => { }), } as unknown as EnvironmentManagers; const projectManager = { - getProjects: sinon.stub().returns([inlineProject]), - create: sinon.stub().callsFake((name: string, uri: Uri) => ({ name, uri })), - remove: sinon.stub(), + getProjects: sinon.stub().callsFake(() => { + calls.push('getProjects'); + return [inlineProject]; + }), + remove: sinon.stub().callsFake(() => { + calls.push('removeProjects'); + }), } as unknown as PythonProjectManager; sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); - sinon.stub(workspaceApis, 'getConfiguration').returns({ - get: (key: string) => - key === 'pythonProjects' - ? [ - { - path: 'script.py', - envManager: INLINE_SCRIPT_MANAGER_ID, - packageManager: 'ms-python.python:pip', - }, - { - path: 'other.py', - envManager: 'ms-python.python:venv', - packageManager: 'ms-python.python:pip', - }, - ] - : [], - inspect: (key: string) => - key === 'pythonProjects' - ? { - workspaceValue: [ - { - path: 'script.py', - envManager: INLINE_SCRIPT_MANAGER_ID, - packageManager: 'ms-python.python:pip', - }, - { - path: 'other.py', - envManager: 'ms-python.python:venv', - packageManager: 'ms-python.python:pip', - }, - ], - workspaceFolderValue: undefined, - } - : undefined, - } as never); - const removeSettings = sinon.stub(settingHelpers, 'removePythonProjectSetting').resolves([inlineProject]); + const removeInlineSettings = sinon + .stub(settingHelpers, 'removeInlineScriptPythonProjectSettings') + .callsFake(async (projects) => { + calls.push('removeInlineSettings'); + assert.deepStrictEqual(projects, [inlineProject]); + return [inlineProject]; + }); await clearScriptEnvironmentCacheCommand(envManagers, projectManager); sinon.assert.calledOnce(clearCache); - sinon.assert.calledOnceWithExactly(removeSettings, [ - { - project: inlineProject, - envManager: INLINE_SCRIPT_MANAGER_ID, - }, - ]); + sinon.assert.calledOnce(removeInlineSettings); sinon.assert.calledOnceWithExactly(projectManager.remove as sinon.SinonStub, [inlineProject]); + assert.deepStrictEqual(calls, ['clearCache', 'getProjects', 'removeInlineSettings', 'removeProjects']); }); - test('includes inline-script projects without a .py extension', async () => { - const inlineProjectUri = Uri.file(path.join(workspacePath, 'runner')); - const clearCache = sinon.stub().resolves(); - const envManagers = { - getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ - supportsClearCache: () => true, - clearCache, - }), - } as unknown as EnvironmentManagers; - const projectManager = { - getProjects: sinon.stub().returns([]), - create: sinon.stub().callsFake((name: string, uri: Uri) => ({ name, uri })), - remove: sinon.stub(), - } as unknown as PythonProjectManager; - sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); - sinon.stub(workspaceApis, 'getConfiguration').returns({ - get: (key: string) => - key === 'pythonProjects' - ? [ - { - path: 'runner', - envManager: INLINE_SCRIPT_MANAGER_ID, - packageManager: 'ms-python.python:pip', - }, - { - path: 'unrelated', - envManager: 'ms-python.python:venv', - packageManager: 'ms-python.python:pip', - }, - ] - : [], - inspect: (key: string) => - key === 'pythonProjects' - ? { - workspaceValue: [ - { - path: 'runner', - envManager: INLINE_SCRIPT_MANAGER_ID, - packageManager: 'ms-python.python:pip', - }, - { - path: 'unrelated', - envManager: 'ms-python.python:venv', - packageManager: 'ms-python.python:pip', - }, - ], - workspaceFolderValue: undefined, - } - : undefined, - } as never); - const removeSettings = sinon - .stub(settingHelpers, 'removePythonProjectSetting') - .callsFake(async (edits) => [edits[0].project]); - - await clearScriptEnvironmentCacheCommand(envManagers, projectManager); - - assert.strictEqual(removeSettings.callCount, 1); - assert.strictEqual(removeSettings.firstCall.args[0].length, 1); - assert.strictEqual(removeSettings.firstCall.args[0][0].envManager, INLINE_SCRIPT_MANAGER_ID); - assert.strictEqual(removeSettings.firstCall.args[0][0].project.uri.fsPath, inlineProjectUri.fsPath); - assert.strictEqual((projectManager.create as sinon.SinonStub).callCount, 1); - assert.strictEqual((projectManager.create as sinon.SinonStub).firstCall.args[0], 'runner'); - assert.strictEqual( - (projectManager.create as sinon.SinonStub).firstCall.args[1].fsPath.toLowerCase(), - inlineProjectUri.fsPath.toLowerCase(), - ); - sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); - }); - - test('removes a hidden inline workspace entry when a folder override exists for the same URI', async () => { - const projectUri = Uri.file(path.join(workspacePath, 'script.py')); - const visibleProject: PythonProject = { - uri: projectUri, - name: 'script.py', + test('keeps loaded projects when inline settings cleanup leaves them configured', async () => { + const inlineProject: PythonProject = { + uri: Uri.file('/workspace/runner'), + name: 'runner', }; const clearCache = sinon.stub().resolves(); const envManagers = { @@ -414,53 +304,17 @@ suite('Clear Script Environment Cache Command Tests', () => { }), } as unknown as EnvironmentManagers; const projectManager = { - getProjects: sinon.stub().returns([visibleProject]), - create: sinon.stub(), + getProjects: sinon.stub().returns([inlineProject]), remove: sinon.stub(), } as unknown as PythonProjectManager; sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([workspaceFolder]); - sinon.stub(workspaceApis, 'getConfiguration').returns({ - get: (key: string) => - key === 'pythonProjects' - ? [ - { - path: 'script.py', - envManager: 'ms-python.python:venv', - packageManager: 'ms-python.python:pip', - }, - ] - : [], - inspect: (key: string) => - key === 'pythonProjects' - ? { - workspaceValue: [ - { - path: 'script.py', - envManager: INLINE_SCRIPT_MANAGER_ID, - packageManager: 'ms-python.python:pip', - }, - ], - workspaceFolderValue: [ - { - path: 'script.py', - envManager: 'ms-python.python:venv', - packageManager: 'ms-python.python:pip', - }, - ], - } - : undefined, - } as never); - const removeSettings = sinon - .stub(settingHelpers, 'removePythonProjectSetting') - .callsFake(async (edits) => [edits[0].project]); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); await clearScriptEnvironmentCacheCommand(envManagers, projectManager); - assert.strictEqual(removeSettings.callCount, 1); - assert.strictEqual(removeSettings.firstCall.args[0].length, 1); - assert.strictEqual(removeSettings.firstCall.args[0][0].project.uri.fsPath, visibleProject.uri.fsPath); - assert.strictEqual(removeSettings.firstCall.args[0][0].envManager, INLINE_SCRIPT_MANAGER_ID); + sinon.assert.calledOnce(clearCache); + sinon.assert.calledOnceWithExactly(removeInlineSettings, [inlineProject]); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); }); }); diff --git a/src/test/features/settings/settingHelpers.unit.test.ts b/src/test/features/settings/settingHelpers.unit.test.ts index 09835fab7..35c63f330 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -9,8 +9,8 @@ import * as sender from '../../../common/telemetry/sender'; import * as workspaceApis from '../../../common/workspace.apis'; import { addPythonProjectSetting, - getResolvedPythonProjectSettings, migrateGlobalDefaultEnvManagerSetting, + removeInlineScriptPythonProjectSettings, removePythonProjectSetting, setAllManagerSettings, setEnvironmentManager, @@ -619,7 +619,7 @@ suite('Setting Helpers - Empty Path Migration', () => { }); }); -suite('Setting Helpers - Exact Project Removal', () => { +suite('Setting Helpers - Project Removal', () => { const INLINE_MANAGER_ID = 'ms-python.python:inline-script'; const VENV_MANAGER_ID = 'ms-python.python:venv'; const PIP_MANAGER_ID = 'ms-python.python:pip'; @@ -741,285 +741,334 @@ suite('Setting Helpers - Exact Project Removal', () => { }; } - test('removes only the matching inline-script entry and preserves unrelated duplicates', async () => { - const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); - const config = createProjectConfig({ - workspaceName: firstWorkspace.name, - workspaceValue: [ - { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + suite('removePythonProjectSetting (bde7cf8-equivalent generic behavior)', () => { + test('rewrites the merged effective array back to workspace scope', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0], { + workspace: firstWorkspace.name, + key: 'pythonProjects', + value: [{ path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }], + target: ConfigurationTarget.Workspace, + }); + }); + + test('ignores envManager metadata and removes the first same-path entry', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project, envManager: VENV_MANAGER_ID }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [ { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], + ]); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); }); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); - sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); - sinon.stub(workspaceApis, 'getConfiguration').returns(config); - - const removedProjects = await removePythonProjectSetting([{ project, envManager: INLINE_MANAGER_ID }]); - - assert.deepStrictEqual(removedProjects, [], 'Project should stay because another entry still targets the same path'); - assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); - assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); - assert.strictEqual(updateCalls[0].key, 'pythonProjects'); - assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); - assert.deepStrictEqual(updateCalls[0].value, [ - { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ]); }); - test('dedupes duplicate project URIs with workspaceFolder precedence while keeping both sources visible', () => { - const config = createProjectConfig({ - workspaceName: firstWorkspace.name, - workspaceValue: [ - { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], - workspaceFolderValue: [ + suite('removeInlineScriptPythonProjectSettings', () => { + test('removes all inline-script entries while preserving non-inline duplicates', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const otherProject = new PythonProjectsImpl('other.py', Uri.file(path.join(firstWorkspacePath, 'other.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removeInlineScriptPythonProjectSettings([project, otherProject]); + + assert.deepStrictEqual( + removedProjects.map((entry) => entry.uri.fsPath), + [otherProject.uri.fsPath], + 'Only projects left without any non-inline setting should be removed from memory', + ); + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); + assert.strictEqual(updateCalls[0].key, 'pythonProjects'); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + assert.deepStrictEqual(updateCalls[0].value, [ { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], + ]); }); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); - const resolved = getResolvedPythonProjectSettings(firstWorkspace, config); + test('removes inline-script settings even when the project is not loaded', async () => { + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'runner', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); - assert.strictEqual(resolved.length, 1); - assert.strictEqual(resolved[0].effective.source, 'workspaceFolder'); - assert.strictEqual(resolved[0].effective.setting.envManager, VENV_MANAGER_ID); - assert.deepStrictEqual( - resolved[0].sources.map((source) => source.setting.envManager), - [INLINE_MANAGER_ID, VENV_MANAGER_ID], - ); - }); + const removedProjects = await removeInlineScriptPythonProjectSettings([]); - test('removes only one of two roots that share the same relative path', async () => { - const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); - const secondProject = new PythonProjectsImpl( - 'script.py', - Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), - ); - const firstConfig = createProjectConfig({ - workspaceName: firstWorkspace.name, - workspaceFolderValue: [ - { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], - }); - const secondConfig = createProjectConfig({ - workspaceName: secondWorkspace.name, - workspaceFolderValue: [ - { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], - }); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); - sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => - uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, - ); - sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { - const uri = scope as Uri; - return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + assert.deepStrictEqual(removedProjects, [], 'No loaded project should be returned for memory cleanup'); + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'keep', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); }); - const removedProjects = await removePythonProjectSetting([{ project: firstProject, envManager: INLINE_MANAGER_ID }]); + test('removes only one of two roots that share the same relative path', async () => { + const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const secondProject = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); - assert.deepStrictEqual(removedProjects.map((project) => project.uri.fsPath), [firstProject.uri.fsPath]); - assert.strictEqual(updateCalls.length, 1, 'Only the matching workspace folder should be updated'); - assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); - assert.strictEqual(updateCalls[0].target, ConfigurationTarget.WorkspaceFolder); - assert.strictEqual(updateCalls[0].value, undefined); - assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'script.py')); - }); + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); - test('removes a hidden shared inline entry while preserving a folder override for the same URI', async () => { - const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); - const config = createProjectConfig({ - workspaceName: firstWorkspace.name, - workspaceValue: [ - { - path: 'script.py', - envManager: INLINE_MANAGER_ID, - packageManager: PIP_MANAGER_ID, - workspace: firstWorkspace.name, - }, - ], - workspaceFolderValue: [ - { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], + assert.deepStrictEqual(removedProjects.map((project) => project.uri.fsPath), [firstProject.uri.fsPath]); + assert.strictEqual(updateCalls.length, 1, 'Only the matching workspace folder should be updated'); + assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.WorkspaceFolder); + assert.strictEqual(updateCalls[0].value, undefined); + assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'script.py')); }); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); - sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); - sinon.stub(workspaceApis, 'getConfiguration').returns(config); - - const removedProjects = await removePythonProjectSetting([{ project, envManager: INLINE_MANAGER_ID }]); - - assert.deepStrictEqual(removedProjects, [], 'Folder override should keep the project configured'); - const workspaceUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.Workspace); - const folderUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.WorkspaceFolder); - assert.ok(workspaceUpdate, 'WorkspaceValue source should be updated'); - assert.strictEqual(workspaceUpdate!.value, undefined); - assert.strictEqual(folderUpdate, undefined, 'Folder override should not be rewritten'); - }); - test('aggregates shared workspaceValue removals across folders into one update', async () => { - const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); - const secondProject = new PythonProjectsImpl('second', Uri.file(path.join(secondWorkspaceUri.fsPath, 'second'))); - const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ - workspaceValue: [ - { - path: 'first', - envManager: INLINE_MANAGER_ID, - packageManager: PIP_MANAGER_ID, - workspace: firstWorkspace.name, - }, - { - path: 'second', - envManager: INLINE_MANAGER_ID, - packageManager: PIP_MANAGER_ID, - workspace: secondWorkspace.name, - }, - ], + test('removes a hidden shared inline entry while preserving a folder override for the same URI', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { + path: 'script.py', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + ], + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removeInlineScriptPythonProjectSettings([project]); + + assert.deepStrictEqual(removedProjects, [], 'Folder override should keep the project configured'); + const workspaceUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.Workspace); + const folderUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.WorkspaceFolder); + assert.ok(workspaceUpdate, 'WorkspaceValue source should be updated'); + assert.strictEqual(workspaceUpdate!.value, undefined); + assert.strictEqual(folderUpdate, undefined, 'Folder override should not be rewritten'); }); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); - sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => - uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, - ); - sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { - const uri = scope as Uri; - return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + + test('aggregates shared workspaceValue removals across folders into one update', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl('second', Uri.file(path.join(secondWorkspaceUri.fsPath, 'second'))); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), []); }); - const removedProjects = await removePythonProjectSetting([ - { project: firstProject, envManager: INLINE_MANAGER_ID }, - { project: secondProject, envManager: INLINE_MANAGER_ID }, - ]); + test('removes every inline shared entry without resurrecting non-inline siblings', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl( + 'second', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'second')), + ); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + { + path: 'keep', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); - assert.deepStrictEqual( - removedProjects.map((project) => project.uri.fsPath).sort(), - [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), - ); - assert.strictEqual( - updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, - 1, - 'Shared workspaceValue should be written once', - ); - assert.deepStrictEqual(getWorkspaceValue(), []); - }); + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); - test('removes a subset from the shared workspace array without resurrecting siblings', async () => { - const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); - const secondProject = new PythonProjectsImpl('second', Uri.file(path.join(secondWorkspaceUri.fsPath, 'second'))); - const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ - workspaceValue: [ - { - path: 'first', - envManager: INLINE_MANAGER_ID, - packageManager: PIP_MANAGER_ID, - workspace: firstWorkspace.name, - }, - { - path: 'second', - envManager: INLINE_MANAGER_ID, - packageManager: PIP_MANAGER_ID, - workspace: secondWorkspace.name, - }, + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should still be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), [ { path: 'keep', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID, workspace: secondWorkspace.name, }, - ], - }); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); - sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => - uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, - ); - sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { - const uri = scope as Uri; - return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + ]); + assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'second')); }); - const removedProjects = await removePythonProjectSetting([{ project: firstProject, envManager: INLINE_MANAGER_ID }]); + test('removes matching inline-script projects independently in a multi-root workspace', async () => { + const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const secondProject = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'keep-folder.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); - assert.deepStrictEqual(removedProjects.map((project) => project.uri.fsPath), [firstProject.uri.fsPath]); - assert.strictEqual( - updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, - 1, - 'Shared workspaceValue should still be written once', - ); - assert.deepStrictEqual(getWorkspaceValue(), [ - { - path: 'second', - envManager: INLINE_MANAGER_ID, - packageManager: PIP_MANAGER_ID, - workspace: secondWorkspace.name, - }, - { - path: 'keep', - envManager: VENV_MANAGER_ID, - packageManager: PIP_MANAGER_ID, - workspace: secondWorkspace.name, - }, - ]); - assert.strictEqual(secondProject.uri.fsPath, path.join(secondWorkspaceUri.fsPath, 'second')); - }); + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); - test('removes matching inline-script projects independently in a multi-root workspace', async () => { - const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); - const secondProject = new PythonProjectsImpl( - 'script.py', - Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), - ); - const firstConfig = createProjectConfig({ - workspaceName: firstWorkspace.name, - workspaceValue: [ - { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], - workspaceFolderValue: [ - { path: 'keep-folder.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], - }); - const secondConfig = createProjectConfig({ - workspaceName: secondWorkspace.name, - workspaceFolderValue: [ - { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual(updateCalls.length, 2, 'Should update each workspace independently'); + const firstWorkspaceUpdate = updateCalls.find((call) => call.workspace === firstWorkspace.name); + const secondWorkspaceUpdate = updateCalls.find((call) => call.workspace === secondWorkspace.name); + assert.ok(firstWorkspaceUpdate, 'First workspace should receive an update'); + assert.ok(secondWorkspaceUpdate, 'Second workspace should receive an update'); + assert.strictEqual(firstWorkspaceUpdate!.value, undefined); + assert.deepStrictEqual(secondWorkspaceUpdate!.value, [ { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ], - }); - sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); - sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => - uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, - ); - sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { - const uri = scope as Uri; - return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + ]); + assert.ok( + updateCalls.some((call) => call.workspace === firstWorkspace.name && call.target === ConfigurationTarget.Workspace) && + updateCalls.some( + (call) => + call.workspace === secondWorkspace.name && + call.target === ConfigurationTarget.WorkspaceFolder, + ), + 'Should update the same configuration scope that originally contained each project entry', + ); }); - - const removedProjects = await removePythonProjectSetting([ - { project: firstProject, envManager: INLINE_MANAGER_ID }, - { project: secondProject, envManager: INLINE_MANAGER_ID }, - ]); - - assert.deepStrictEqual( - removedProjects.map((project) => project.uri.fsPath).sort(), - [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), - ); - assert.strictEqual(updateCalls.length, 2, 'Should update each workspace independently'); - const firstWorkspaceUpdate = updateCalls.find((call) => call.workspace === firstWorkspace.name); - const secondWorkspaceUpdate = updateCalls.find((call) => call.workspace === secondWorkspace.name); - assert.ok(firstWorkspaceUpdate, 'First workspace should receive an update'); - assert.ok(secondWorkspaceUpdate, 'Second workspace should receive an update'); - assert.strictEqual(firstWorkspaceUpdate!.value, undefined); - assert.deepStrictEqual(secondWorkspaceUpdate!.value, [ - { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, - ]); - assert.ok( - updateCalls.some((call) => call.workspace === firstWorkspace.name && call.target === ConfigurationTarget.Workspace) && - updateCalls.some( - (call) => - call.workspace === secondWorkspace.name && - call.target === ConfigurationTarget.WorkspaceFolder, - ), - 'Should update the same configuration scope that originally contained each project entry', - ); }); }); diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index 727bf2bc0..56d0b2944 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -65,7 +65,6 @@ suite('Smoke: Registration Checks', function () { 'python-envs.setPkgManager', 'python-envs.refreshAllManagers', 'python-envs.clearCache', - 'python-envs.clearScriptEnvCache', 'python-envs.searchSettings', // Package management @@ -114,6 +113,38 @@ suite('Smoke: Registration Checks', function () { ); }); + test('Internal inline clear command is not publicly contributed', function () { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + const contributedCommands = (extension.packageJSON?.contributes?.commands ?? []) as Array<{ command: string }>; + const commandPaletteEntries = (extension.packageJSON?.contributes?.menus?.commandPalette ?? []) as Array<{ + command: string; + }>; + + assert.ok( + !contributedCommands.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not be publicly contributed before rollout', + ); + assert.ok( + !commandPaletteEntries.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not appear in contributed menus before rollout', + ); + }); + + test('Internal inline clear command is not registered while the feature flag is off', async function () { + const allCommands = await vscode.commands.getCommands(true); + + assert.ok( + !allCommands.includes('python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not be registered by default', + ); + await assert.rejects( + () => Promise.resolve(vscode.commands.executeCommand('python-envs.clearScriptEnvCache')), + /not found/i, + ); + }); + // ========================================================================= // API METHODS - All API methods must exist and be functions // ========================================================================= From 9cf19380f6ca1e7f32cb9f501bbee1067e8b0c9b Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 17:22:21 -0700 Subject: [PATCH 6/7] Harden inline script cache cleanup Coordinate per-entry deletion locks, keep partial failures consistent, and clean inline project settings safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b12d843-8011-4bfc-9ba9-f75761eadee2 --- src/common/lockfile.apis.ts | 31 +- src/features/envManagers.ts | 8 +- src/features/settings/settingHelpers.ts | 42 ++- .../builtin/inlineScript/envManager.ts | 331 +++++++++++++----- src/test/features/envCommands.unit.test.ts | 22 ++ src/test/features/envManagers.unit.test.ts | 58 +++ .../settings/settingHelpers.unit.test.ts | 89 ++++- .../inlineScript/envManager.unit.test.ts | 95 +++++ 8 files changed, 583 insertions(+), 93 deletions(-) diff --git a/src/common/lockfile.apis.ts b/src/common/lockfile.apis.ts index 2fbe10352..62dc6aaeb 100644 --- a/src/common/lockfile.apis.ts +++ b/src/common/lockfile.apis.ts @@ -162,6 +162,31 @@ export async function inspectFileLock(filePath: string, options?: InspectFileLoc return 'orphaned'; } +/** + * Move a stale or retained lock out of the lock name before a replacement owner is acquired. + * The rename prevents a newly-created lock from being removed based on an earlier inspection. + */ +export async function reclaimFileLock(filePath: string): Promise { + const lockPath = getFileLockPath(filePath); + const state = await inspectFileLock(filePath); + if (state !== 'stale' && state !== 'retained') { + return false; + } + + const quarantinedLockPath = `${lockPath}.reclaimed-${process.pid}-${crypto.randomBytes(16).toString('hex')}`; + try { + await fsapi.rename(lockPath, quarantinedLockPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return false; + } + throw error; + } + + await fsapi.remove(quarantinedLockPath); + return true; +} + export async function getProcessLiveness(pid: number): Promise { try { process.kill(pid, 0); @@ -196,7 +221,7 @@ function hasErrorCode(error: unknown, code: string): boolean { } function parseOwnerPid(entry: string): number | undefined { - const match = entry.match(/^owner-(\d+)-/); + const match = entry.match(new RegExp(`^${escapeRegExp(FILE_LOCK_OWNER_MARKER_PREFIX)}(\\d+)-`)); if (!match) { return undefined; } @@ -204,6 +229,10 @@ function parseOwnerPid(entry: string): number | undefined { return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function createLockError(message: string, code: string, lockPath: string): NodeJS.ErrnoException { return Object.assign(new Error(message), { code, path: lockPath }); } diff --git a/src/features/envManagers.ts b/src/features/envManagers.ts index 9fa545d3b..d68973c9c 100644 --- a/src/features/envManagers.ts +++ b/src/features/envManagers.ts @@ -320,12 +320,16 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { public async clearCache(scope: EnvironmentManagerScope): Promise { if (scope === undefined) { - await Promise.all(this.managers.map((m) => m.clearCache())); + await Promise.all( + this.managers + .filter((manager) => manager.id !== INLINE_SCRIPT_MANAGER_ID) + .map((manager) => manager.clearCache()), + ); return; } const manager = this.getEnvironmentManager(scope); - if (manager) { + if (manager && manager.id !== INLINE_SCRIPT_MANAGER_ID) { await manager.clearCache(); } } diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 4d84e4321..24189aacb 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -19,8 +19,8 @@ interface ResolvedPythonProjectSettingSource { readonly setting: PythonProjectSettings; readonly uri: Uri; readonly workspaceFolder: WorkspaceFolder; - readonly source: 'workspace' | 'workspaceFolder'; - readonly target: ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder; + readonly source: 'global' | 'workspace' | 'workspaceFolder'; + readonly target: ConfigurationTarget.Global | ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder; } interface ResolvedPythonProjectSetting { @@ -34,8 +34,8 @@ function resolvePythonProjectSettingSource( setting: PythonProjectSettings, workspaceFolder: WorkspaceFolder, allWorkspaceFolders: readonly WorkspaceFolder[], - source: 'workspace' | 'workspaceFolder', - target: ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder, + source: 'global' | 'workspace' | 'workspaceFolder', + target: ConfigurationTarget.Global | ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder, ): ResolvedPythonProjectSettingSource | undefined { const resolvedWorkspaceFolder = setting.workspace ? allWorkspaceFolders.find((candidate) => candidate.name === setting.workspace) @@ -75,6 +75,17 @@ function getResolvedPythonProjectSettings( const fallbackSettings = projectsInspect === undefined ? config.get('pythonProjects', []) : undefined; const orderedSources: ResolvedPythonProjectSettingSource[] = [ + ...(projectsInspect?.globalValue ?? []) + .map((setting) => + resolvePythonProjectSettingSource( + setting, + workspaceFolder, + allWorkspaceFolders, + 'global', + ConfigurationTarget.Global, + ), + ) + .filter((setting): setting is ResolvedPythonProjectSettingSource => setting !== undefined), ...(projectsInspect?.workspaceValue ?? fallbackSettings ?? []) .map((setting) => resolvePythonProjectSettingSource( @@ -532,12 +543,16 @@ export async function removeInlineScriptPythonProjectSettings( const folderRemainingSettings = new Map(); const folderExistingSettings = new Map(); const promises: Thenable[] = []; + let globalConfig: WorkspaceConfiguration | undefined; + let globalValueOriginal: PythonProjectSettings[] | undefined; let workspaceConfig: WorkspaceConfiguration | undefined; let workspaceValueOriginal: PythonProjectSettings[] | undefined; workspaceEntries.forEach(([workspaceFolder, edits]) => { const config = workspaceApis.getConfiguration('python-envs', workspaceFolder.uri); const projectsInspect = config.inspect('pythonProjects'); + globalConfig ??= config; + globalValueOriginal ??= cloneProjectSettings(projectsInspect?.globalValue); workspaceConfig ??= config; workspaceValueOriginal ??= cloneProjectSettings(projectsInspect?.workspaceValue); @@ -562,6 +577,13 @@ export async function removeInlineScriptPythonProjectSettings( const aggregatedEdits = workspaceEntries.flatMap(([workspaceFolder, edits]) => edits.map((edit) => ({ workspaceFolder, edit })), ); + const globalValueRemaining = + globalValueOriginal?.filter( + (projectSetting) => + !aggregatedEdits.some(({ workspaceFolder, edit }) => + matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), + ), + ) ?? []; const workspaceValueRemaining = workspaceValueOriginal?.filter( (projectSetting) => @@ -570,6 +592,16 @@ export async function removeInlineScriptPythonProjectSettings( ), ) ?? []; + if (globalConfig && globalValueOriginal !== undefined && globalValueRemaining.length !== globalValueOriginal.length) { + promises.push( + globalConfig.update( + 'pythonProjects', + globalValueRemaining.length > 0 ? globalValueRemaining : undefined, + ConfigurationTarget.Global, + ), + ); + } + if ( workspaceConfig && workspaceValueOriginal !== undefined && @@ -586,10 +618,12 @@ export async function removeInlineScriptPythonProjectSettings( workspaceEntries.forEach(([workspaceFolder, edits]) => { const existingSettings = [ + ...(globalValueOriginal ?? []), ...(workspaceValueOriginal ?? []), ...((folderExistingSettings.get(workspaceFolder.uri.toString()) ?? [])), ]; const remainingSettings = [ + ...globalValueRemaining, ...workspaceValueRemaining, ...((folderRemainingSettings.get(workspaceFolder.uri.toString()) ?? [])), ]; diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 68a21d940..e230ce7e4 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -49,6 +49,7 @@ import { FILE_LOCK_DIR_SUFFIX, getFileLockPath, inspectFileLock, + reclaimFileLock, } from '../../../common/lockfile.apis'; import { getWorkspacePersistentState, PersistentState } from '../../../common/persistentState'; import { createDeferred, Deferred } from '../../../common/utils/deferred'; @@ -1319,7 +1320,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); - const cacheEntryPaths = await this.getClearableCacheEntryPaths(cacheRoot); + const physicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); const persistedAssociations = await this.getPersistedAssociationSnapshot(); const scriptPaths = new Set([ ...Object.keys(persistedAssociations), @@ -1334,114 +1335,171 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { priorSelections.set(scriptPath, this.fsPathToEnv.get(scriptPath)); }); - for (const cacheEntryPath of cacheEntryPaths) { - await fs.remove(cacheEntryPath); - } - - let persistenceError: unknown; - try { - const state = await getWorkspacePersistentState(); - await state.clear([INLINE_SCRIPT_ENVS_KEY]); - } catch (error) { - persistenceError = error; - this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); - } + const removedCacheEntries = new Set(); + const deletionErrors: unknown[] = []; + if (physicalCacheRootPath) { + let entryNames: string[]; + try { + entryNames = await fs.readdir(physicalCacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + entryNames = []; + } else { + throw error; + } + } - scriptPaths.forEach((scriptPath) => this.bumpAssociationRevision(scriptPath)); - this.pendingRehydrations.clear(); - this.fsPathToEnv.clear(); - this.fsPathToPersistedEnvPath.clear(); - this.cachedAssociationValidatedAt.clear(); + const cacheEntryNames = new Set(); + for (const entryName of entryNames) { + if (entryName.endsWith(FILE_LOCK_DIR_SUFFIX)) { + const envName = entryName.slice(0, -FILE_LOCK_DIR_SUFFIX.length); + if (envName.length === 0) { + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is malformed.', + ); + this.log.error(`${message} (${path.join(physicalCacheRootPath, entryName)})`); + throw new Error(message); + } + cacheEntryNames.add(envName); + } else { + cacheEntryNames.add(entryName); + } + } - priorSelections.forEach((environment, scriptPath) => { - if (!environment) { - return; + for (const entryName of cacheEntryNames) { + try { + const removed = await this.removeCacheEntryForClear( + cacheRoot, + physicalCacheRootPath, + entryName, + ); + if (removed) { + removedCacheEntries.add(normalizePath(removed)); + } + } catch (error) { + deletionErrors.push(error); + this.log.error( + `Failed to remove inline-script cache entry ${path.join(physicalCacheRootPath, entryName)}: ${getErrorMessage(error)}`, + ); + } } - this._onDidChangeEnvironment.fire({ - uri: Uri.file(scriptPath), - old: environment, - new: undefined, - }); - }); + } + const invalidatedScriptPaths = await this.getInvalidatedAssociationPaths( + scriptPaths, + persistedAssociations, + removedCacheEntries, + ); + const persistenceError = await this.clearInvalidatedAssociations( + invalidatedScriptPaths, + persistedAssociations, + priorSelections, + ); if (persistenceError) { - throw persistenceError; + deletionErrors.push(persistenceError); } - } - - private async getClearableCacheEntryPaths(cacheRoot: Uri): Promise { - const resolvedCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); - if (!resolvedCacheRootPath) { - return []; + if (deletionErrors.length > 0) { + throw new Error( + `Failed to completely clear the inline-script environment cache: ${deletionErrors + .map((error) => getErrorMessage(error)) + .join('; ')}`, + ); } - const cacheRootPath = path.resolve(resolvedCacheRootPath); - const physicalCacheRoot = Uri.file(cacheRootPath); - - const entryNames = await fs.readdir(cacheRootPath); - const lockStates = new Map(); - for (const entryName of entryNames.filter((entry) => entry.endsWith(FILE_LOCK_DIR_SUFFIX))) { - const envName = entryName.slice(0, -FILE_LOCK_DIR_SUFFIX.length); - if (envName.length === 0) { - const message = l10n.t( - 'Refusing to clear the script environment cache because a lock entry is malformed.', - ); - this.log.error(`${message} (${path.join(cacheRootPath, entryName)})`); - throw new Error(message); - } + } - const envDirPath = path.join(cacheRootPath, envName); - const lockState = await inspectFileLock(envDirPath); - if (lockState === 'retained' || lockState === 'stale') { - lockStates.set(envDirPath, lockState); - continue; + private async removeCacheEntryForClear( + cacheRoot: Uri, + originalPhysicalCacheRootPath: string, + entryName: string, + ): Promise { + const envDirPath = path.join(originalPhysicalCacheRootPath, entryName); + let lock: AcquiredFileLock | undefined; + try { + lock = await this.acquireCacheEntryLockForClear(envDirPath); + const currentPhysicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); + if (!currentPhysicalCacheRootPath) { + return undefined; } - if (lockState === 'held') { + if ( + normalizePath(currentPhysicalCacheRootPath) !== normalizePath(originalPhysicalCacheRootPath) + ) { const message = l10n.t( - 'Cannot clear the script environment cache while a cached environment is being created.', + 'Refusing to clear the script environment cache because its physical root changed during cleanup.', ); - this.log.error(`${message} (${getFileLockPath(envDirPath)})`); - throw new Error(message); - } - if (lockState === 'unavailable') { - const message = l10n.t( - 'Cannot clear the script environment cache because a cached environment lock could not be verified.', + this.log.error( + `${message} (${originalPhysicalCacheRootPath} -> ${currentPhysicalCacheRootPath})`, ); - this.log.error(`${message} (${getFileLockPath(envDirPath)})`); throw new Error(message); } - const message = l10n.t( - 'Refusing to clear the script environment cache because a lock entry is incomplete or malformed.', + const entryPath = await this.getClearableCacheEntryPath( + Uri.file(currentPhysicalCacheRootPath), + path.join(currentPhysicalCacheRootPath, entryName), ); - this.log.error(`${message} (${getFileLockPath(envDirPath)})`); - throw new Error(message); + if (!entryPath) { + return undefined; + } + await this.deleteCacheEntryForClear(entryPath); + return entryPath; + } finally { + if (lock) { + await lock.release(); + } } + } - const pathsToRemove: string[] = []; - const scheduledPaths = new Set(); - - for (const envDirPath of lockStates.keys()) { - const cacheEntryPath = await this.getClearableCacheEntryPath(physicalCacheRoot, envDirPath); - if (cacheEntryPath) { - pathsToRemove.push(cacheEntryPath); - scheduledPaths.add(normalizePath(cacheEntryPath)); + private async acquireCacheEntryLockForClear(envDirPath: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await acquireFileLock(envDirPath, { timeoutMs: 0, retryIntervalMs: CACHE_LOCK_RETRY_MS }); + } catch (error) { + if (!this.isLockContentionError(error)) { + throw error; + } + const lockState = await inspectFileLock(envDirPath); + if (lockState === 'stale' || lockState === 'retained') { + await reclaimFileLock(envDirPath); + continue; + } + if (lockState === 'missing') { + continue; + } + this.throwClearCacheLockError(envDirPath, lockState); } } - for (const envDirPath of lockStates.keys()) { - const lockPath = getFileLockPath(envDirPath); - pathsToRemove.push(lockPath); - scheduledPaths.add(normalizePath(lockPath)); - } + const lockState = await inspectFileLock(envDirPath); + this.throwClearCacheLockError(envDirPath, lockState); + } - for (const entryName of entryNames.filter((entry) => !entry.endsWith(FILE_LOCK_DIR_SUFFIX))) { - const cacheEntryPath = await this.getClearableCacheEntryPath(physicalCacheRoot, path.join(cacheRootPath, entryName)); - if (cacheEntryPath && !scheduledPaths.has(normalizePath(cacheEntryPath))) { - pathsToRemove.push(cacheEntryPath); - } + private isLockContentionError(error: unknown): boolean { + const code = typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + return code === 'ELOCKED' || code === 'ELOCKRETAINED'; + } + + private throwClearCacheLockError(envDirPath: string, lockState: string): never { + if (lockState === 'held') { + const message = l10n.t( + 'Cannot clear the script environment cache while a cached environment is being created.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + if (lockState === 'unavailable') { + const message = l10n.t( + 'Cannot clear the script environment cache because a cached environment lock could not be verified.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); } - return pathsToRemove; + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is incomplete or malformed.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); } private async getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise { @@ -1551,6 +1609,109 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return resolvedEntryPath; } + private deleteCacheEntryForClear(entryPath: string): Promise { + return fs.remove(entryPath); + } + + private async getInvalidatedAssociationPaths( + scriptPaths: ReadonlySet, + persistedAssociations: PersistedInlineScriptEnvironments, + removedCacheEntries: ReadonlySet, + ): Promise> { + const invalidatedScriptPaths = new Set(); + for (const scriptPath of scriptPaths) { + const environmentPaths = [ + persistedAssociations[scriptPath], + this.fsPathToPersistedEnvPath.get(scriptPath), + this.fsPathToEnv.get(scriptPath)?.environmentPath.fsPath, + ].filter((value): value is string => value !== undefined); + const states = await Promise.all( + environmentPaths.map((environmentPath) => + this.isRemovedOrMissingCacheAssociation(environmentPath, removedCacheEntries), + ), + ); + if (states.some((state) => state)) { + invalidatedScriptPaths.add(scriptPath); + } + } + return invalidatedScriptPaths; + } + + private async isRemovedOrMissingCacheAssociation( + environmentPath: string, + removedCacheEntries: ReadonlySet, + ): Promise { + const envDirPath = path.dirname(path.dirname(environmentPath)); + if (removedCacheEntries.has(normalizePath(envDirPath))) { + return true; + } + try { + return !(await fs.pathExists(environmentPath)); + } catch (error) { + this.log.warn( + `Unable to verify inline-script environment association ${environmentPath}: ${getErrorMessage(error)}`, + ); + return false; + } + } + + private async clearInvalidatedAssociations( + invalidatedScriptPaths: ReadonlySet, + persistedAssociations: PersistedInlineScriptEnvironments, + priorSelections: ReadonlyMap, + ): Promise { + if (invalidatedScriptPaths.size === 0) { + if (Object.keys(persistedAssociations).length > 0) { + return undefined; + } + try { + await this.enqueuePersistence(async (state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + return undefined; + } catch (error) { + this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); + return error; + } + } + + let persistenceError: unknown; + const persistedPathsToClear = Array.from(invalidatedScriptPaths).filter( + (scriptPath) => persistedAssociations[scriptPath] !== undefined, + ); + try { + if (persistedPathsToClear.length === Object.keys(persistedAssociations).length) { + await this.enqueuePersistence(async (state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + } else if (persistedPathsToClear.length > 0) { + await this.updatePersistedAssociations( + persistedPathsToClear.map((scriptPath) => ({ + scriptPath, + expectedEnvironmentPath: persistedAssociations[scriptPath], + })), + ); + } + } catch (error) { + persistenceError = error; + this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); + } + + for (const scriptPath of invalidatedScriptPaths) { + this.bumpAssociationRevision(scriptPath); + this.pendingRehydrations.delete(scriptPath); + this.fsPathToEnv.delete(scriptPath); + this.fsPathToPersistedEnvPath.delete(scriptPath); + this.cachedAssociationValidatedAt.delete(scriptPath); + + const environment = priorSelections.get(scriptPath); + if (environment) { + this._onDidChangeEnvironment.fire({ + uri: Uri.file(scriptPath), + old: environment, + new: undefined, + }); + } + } + return persistenceError; + } + private async getPersistedAssociationSnapshot(): Promise { await this.persistenceQueue; const state = await getWorkspacePersistentState(); diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index f47ceb15a..ae2a1c54f 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -316,6 +316,28 @@ suite('Clear Script Environment Cache Command Tests', () => { sinon.assert.calledOnceWithExactly(removeInlineSettings, [inlineProject]); sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); }); + + test('preserves project settings when cache cleanup reports a partial failure', async () => { + const clearCache = sinon.stub().rejects(new Error('one cache entry could not be deleted')); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([]), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); + + await assert.rejects(clearScriptEnvironmentCacheCommand(envManagers, projectManager), /could not be deleted/); + + sinon.assert.calledOnce(clearCache); + sinon.assert.notCalled(removeInlineSettings); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); }); suite('Reveal Env In Manager View Command Tests', () => { diff --git a/src/test/features/envManagers.unit.test.ts b/src/test/features/envManagers.unit.test.ts index d642fa948..aafe89f27 100644 --- a/src/test/features/envManagers.unit.test.ts +++ b/src/test/features/envManagers.unit.test.ts @@ -336,3 +336,61 @@ suite('PythonEnvironmentManagers - refreshEnvironment', () => { await envManagers.refreshEnvironment(Uri.file('/unknown/path')); }); }); + +suite('PythonEnvironmentManagers - clearCache', () => { + let sandbox: sinon.SinonSandbox; + let envManagers: PythonEnvironmentManagers; + + setup(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(frameUtils, 'getCallingExtension').returns('ms-python.python'); + envManagers = new PythonEnvironmentManagers({ + get: sandbox.stub().returns(undefined), + getProjects: sandbox.stub().returns([]), + } as unknown as PythonProjectManager); + }); + + teardown(() => { + sandbox.restore(); + }); + + function registerManager(name: string, clearCache: sinon.SinonStub): void { + envManagers.registerEnvironmentManager( + { + name, + displayName: name, + preferredPackageManagerId: 'ms-python.python:pip', + get: sandbox.stub().resolves(undefined), + set: sandbox.stub().resolves(), + resolve: sandbox.stub().resolves(undefined), + refresh: sandbox.stub().resolves(), + getEnvironments: sandbox.stub().resolves([]), + clearCache, + onDidChangeEnvironments: sandbox.stub().returns({ dispose: () => {} }), + onDidChangeEnvironment: sandbox.stub().returns({ dispose: () => {} }), + } as any, + { extensionId: 'ms-python.python' }, + ); + } + + test('clears every existing manager when the inline preview manager is absent', async () => { + const systemClearCache = sandbox.stub().resolves(); + registerManager('system', systemClearCache); + + await envManagers.clearCache(undefined); + + sinon.assert.calledOnce(systemClearCache); + }); + + test('does not clear the preview inline manager through the generic command path', async () => { + const systemClearCache = sandbox.stub().resolves(); + const inlineClearCache = sandbox.stub().resolves(); + registerManager('system', systemClearCache); + registerManager('inline-script', inlineClearCache); + + await envManagers.clearCache(undefined); + + sinon.assert.calledOnce(systemClearCache); + sinon.assert.notCalled(inlineClearCache); + }); +}); diff --git a/src/test/features/settings/settingHelpers.unit.test.ts b/src/test/features/settings/settingHelpers.unit.test.ts index 35c63f330..f75bc5b3c 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -654,16 +654,22 @@ suite('Setting Helpers - Project Removal', () => { function createProjectConfig(options: { workspaceName: string; + globalValue?: PythonProjectSettings[]; workspaceValue?: PythonProjectSettings[]; workspaceFolderValue?: PythonProjectSettings[]; }): MockWorkspaceConfiguration { const mockConfig = new MockWorkspaceConfiguration(); - const mergedProjects = [...(options.workspaceValue ?? []), ...(options.workspaceFolderValue ?? [])]; + const mergedProjects = [ + ...(options.globalValue ?? []), + ...(options.workspaceValue ?? []), + ...(options.workspaceFolderValue ?? []), + ]; (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => key === 'pythonProjects' ? (mergedProjects as unknown as T) : defaultValue; (mockConfig as any).inspect = (key: string) => key === 'pythonProjects' ? { + globalValue: options.globalValue, workspaceValue: options.workspaceValue, workspaceFolderValue: options.workspaceFolderValue, } @@ -1069,6 +1075,87 @@ suite('Setting Helpers - Project Removal', () => { 'Should update the same configuration scope that originally contained each project entry', ); }); + + test('removes global inline entries once while preserving higher-precedence non-inline entries', async () => { + const globalProject = new PythonProjectsImpl( + 'global.py', + Uri.file(path.join(firstWorkspacePath, 'global.py')), + ); + const workspaceProject = new PythonProjectsImpl( + 'workspace.py', + Uri.file(path.join(firstWorkspacePath, 'workspace.py')), + ); + const folderProject = new PythonProjectsImpl( + 'folder.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'folder.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + globalValue: [ + { path: 'global.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceValue: [ + { path: 'workspace.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'global.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + globalValue: [ + { path: 'global.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'folder.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([ + globalProject, + workspaceProject, + folderProject, + ]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [workspaceProject.uri.fsPath, folderProject.uri.fsPath].sort(), + 'The folder-level non-inline entry keeps the global project loaded', + ); + const globalUpdates = updateCalls.filter((call) => call.target === ConfigurationTarget.Global); + assert.strictEqual(globalUpdates.length, 1, 'Global settings should be updated exactly once'); + assert.deepStrictEqual(globalUpdates[0].value, [ + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + assert.ok( + updateCalls.some( + (call) => + call.workspace === firstWorkspace.name && + call.target === ConfigurationTarget.Workspace && + call.value === undefined, + ), + 'Workspace-scoped inline entry should be removed at its source', + ); + assert.ok( + updateCalls.some( + (call) => + call.workspace === secondWorkspace.name && + call.target === ConfigurationTarget.WorkspaceFolder && + call.value === undefined, + ), + 'Folder-scoped inline entry should be removed at its source', + ); + }); }); }); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 1e5811a55..3b3a9d04d 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -2318,6 +2318,7 @@ suite('InlineScriptEnvManager', () => { }); test('refuses to clear while a cached environment is locked', async () => { + lockStub.restore(); const uri = scriptUri(); const environment = await createOwnedEnvironment(); await manager.set(uri, environment); @@ -2334,6 +2335,7 @@ suite('InlineScriptEnvManager', () => { }); test('clears a retained lock and its corresponding cache entry', async () => { + lockStub.restore(); const retainedCacheDir = envDir().fsPath; const retainedLockPath = lockfileApis.getFileLockPath(retainedCacheDir); await fs.outputFile(venvPythonPath(retainedCacheDir), ''); @@ -2347,6 +2349,7 @@ suite('InlineScriptEnvManager', () => { }); test('clears a stale owner lock and its corresponding cache entry', async () => { + lockStub.restore(); const uri = scriptUri(); const environment = await createOwnedEnvironment(); await manager.set(uri, environment); @@ -2368,7 +2371,50 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), undefined); }); + test('does not delete an entry when another host acquires a new lock after stale lock reclamation', async () => { + lockStub.restore(); + const environment = await createOwnedEnvironment(); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + const quarantinedLockPath = `${lockPath}.reclaimed-for-test`; + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, 'owner-424242-dead'), ''); + sinon.stub(lockfileApis, 'inspectFileLock').onFirstCall().resolves('stale').onSecondCall().resolves('held'); + sinon.stub(lockfileApis, 'reclaimFileLock').callsFake(async () => { + await fs.rename(lockPath, quarantinedLockPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `owner-${process.pid}-live`), ''); + return true; + }); + + await assert.rejects(manager.clearCache(), /being created/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + assert.strictEqual(await fs.pathExists(lockPath), true); + }); + + test('holds the entry lock through deletion', async () => { + lockStub.restore(); + const environment = await createOwnedEnvironment(); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + const internalManager = manager as unknown as { + deleteCacheEntryForClear(entryPath: string): Promise; + }; + const removeStub = sinon.stub(internalManager, 'deleteCacheEntryForClear').callThrough(); + removeStub.callsFake(async (target) => { + if (normalizePath(target) === normalizePath(environment.sysPrefix)) { + assert.strictEqual(await fs.pathExists(lockPath), true, 'entry lock must protect deletion'); + } + await fs.remove(target); + }); + + await manager.clearCache(); + + sinon.assert.calledWith(removeStub, environment.sysPrefix); + assert.strictEqual(await fs.pathExists(environment.sysPrefix), false); + }); + test('rejects an orphaned lock directory conservatively', async () => { + lockStub.restore(); const uri = scriptUri(); const environment = await createOwnedEnvironment(); await manager.set(uri, environment); @@ -2398,6 +2444,55 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(listener.firstCall.args[0].new, undefined); }); + test('preserves associations and emits events only for entries removed before a partial failure', async () => { + const firstUri = scriptUri('first.py'); + const secondUri = scriptUri('second.py'); + const firstEnvironment = await createOwnedEnvironment(); + const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); + await manager.set(firstUri, firstEnvironment); + await manager.set(secondUri, secondEnvironment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + const internalManager = manager as unknown as { + deleteCacheEntryForClear(entryPath: string): Promise; + }; + sinon.stub(internalManager, 'deleteCacheEntryForClear').callsFake(async (target) => { + if (normalizePath(target) === normalizePath(secondEnvironment.sysPrefix)) { + throw new Error('second entry is busy'); + } + await fs.remove(target); + }); + + await assert.rejects(manager.clearCache(), /Failed to completely clear/); + + assert.strictEqual(await fs.pathExists(firstEnvironment.sysPrefix), false); + assert.strictEqual(await fs.pathExists(secondEnvironment.sysPrefix), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(secondUri.fsPath)]: secondEnvironment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(firstUri), undefined); + assert.strictEqual(await manager.get(secondUri), secondEnvironment); + sinon.assert.calledOnce(listener); + assert.strictEqual(normalizePath(listener.firstCall.args[0].uri.fsPath), normalizePath(firstUri.fsPath)); + assert.strictEqual(listener.firstCall.args[0].old, firstEnvironment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + }); + + test('stops before deletion when the physical cache root changes', async () => { + const environment = await createOwnedEnvironment(); + const otherPhysicalRoot = path.join(tempRoot, 'other-cache-root'); + await fs.ensureDir(otherPhysicalRoot); + const internalManager = manager as unknown as { + getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise; + }; + const rootStub = sinon.stub(internalManager, 'getPhysicalOwnedCacheRootPath').callThrough(); + rootStub.onSecondCall().resolves(otherPhysicalRoot); + + await assert.rejects(manager.clearCache(), /physical root changed/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + }); + test('does not let a pending rehydration restore an association after clear cache', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); From 8fb0a8b7139ae7234a077a998f961fc660aba6a2 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 20:00:01 -0700 Subject: [PATCH 7/7] Make lock reclamation generation-safe Claim exact stale or retained lock markers before inline cache cleanup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b12d843-8011-4bfc-9ba9-f75761eadee2 --- src/common/lockfile.apis.ts | 124 ++++++++++++------ src/test/common/lockfile.apis.unit.test.ts | 81 +++++++++++- .../inlineScript/envManager.unit.test.ts | 24 +++- 3 files changed, 176 insertions(+), 53 deletions(-) diff --git a/src/common/lockfile.apis.ts b/src/common/lockfile.apis.ts index 62dc6aaeb..cb8ff8032 100644 --- a/src/common/lockfile.apis.ts +++ b/src/common/lockfile.apis.ts @@ -18,6 +18,8 @@ export interface AcquiredFileLock { export const FILE_LOCK_DIR_SUFFIX = '.lock'; export const FILE_LOCK_OWNER_MARKER_PREFIX = 'owner-'; +export const FILE_LOCK_RETAINED_MARKER_PREFIX = 'retained-'; +/** Legacy retained marker. It remains recognizable but cannot be safely reclaimed. */ export const FILE_LOCK_RETAINED_MARKER = 'retained'; export type ProcessLiveness = 'live' | 'dead' | 'unavailable'; @@ -40,7 +42,7 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-${crypto.randomBytes(16).toString('hex')}`, ); - const retainedMarker = path.join(lockPath, FILE_LOCK_RETAINED_MARKER); + const retainedMarker = path.join(lockPath, getRetainedMarkerName(path.basename(ownerMarker))); const deadline = Date.now() + options.timeoutMs; while (true) { @@ -69,22 +71,9 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock } state = 'retained'; try { - await fsapi.writeFile(retainedMarker, '', { flag: 'wx' }); - } catch (error) { - if (hasErrorCode(error, 'EEXIST')) { - return; - } - try { - await fsapi.rename(ownerMarker, retainedMarker); - } catch (renameError) { - if (!hasErrorCode(renameError, 'EEXIST')) { - throw createLockError( - 'Failed to mark the lock as retained', - 'ERETAINFAILED', - lockPath, - ); - } - } + await fsapi.rename(ownerMarker, retainedMarker); + } catch (_error) { + throw createLockError('Failed to mark the lock as retained', 'ERETAINFAILED', lockPath); } }, release: async () => { @@ -119,6 +108,19 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock } export async function inspectFileLock(filePath: string, options?: InspectFileLockOptions): Promise { + return (await inspectFileLockSnapshot(filePath, options)).state; +} + +interface FileLockSnapshot { + readonly state: FileLockState; + readonly marker?: string; + readonly markerKind?: 'owner' | 'retained'; +} + +async function inspectFileLockSnapshot( + filePath: string, + options?: InspectFileLockOptions, +): Promise { const lockPath = getFileLockPath(filePath); let stat; @@ -126,65 +128,97 @@ export async function inspectFileLock(filePath: string, options?: InspectFileLoc stat = await fsapi.lstat(lockPath); } catch (error) { if (hasErrorCode(error, 'ENOENT')) { - return 'missing'; + return { state: 'missing' }; } throw error; } if (!stat.isDirectory() || stat.isSymbolicLink()) { - return 'malformed'; + return { state: 'malformed' }; } const entries = await fsapi.readdir(lockPath); const ownerEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX)); + const generationRetainedEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX)); const retainedEntries = entries.filter((entry) => entry === FILE_LOCK_RETAINED_MARKER); const unknownEntries = entries.filter( - (entry) => !entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX) && entry !== FILE_LOCK_RETAINED_MARKER, + (entry) => + !entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX) && + !entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX) && + entry !== FILE_LOCK_RETAINED_MARKER, ); - if (unknownEntries.length > 0 || ownerEntries.length > 1 || retainedEntries.length > 1) { - return 'malformed'; + if ( + unknownEntries.length > 0 || + ownerEntries.length > 1 || + generationRetainedEntries.length > 1 || + retainedEntries.length > 1 || + generationRetainedEntries.length + retainedEntries.length > 1 || + generationRetainedEntries.length + ownerEntries.length > 1 + ) { + return { state: 'malformed' }; } if (retainedEntries.length === 1) { - return 'retained'; + return { state: 'retained' }; + } + if (generationRetainedEntries.length === 1) { + const retainedPid = parseMarkerPid(generationRetainedEntries[0], FILE_LOCK_RETAINED_MARKER_PREFIX); + if (retainedPid === undefined) { + return { state: 'malformed' }; + } + return { state: 'retained', marker: generationRetainedEntries[0], markerKind: 'retained' }; } if (ownerEntries.length === 1) { - const ownerPid = parseOwnerPid(ownerEntries[0]); + const ownerPid = parseMarkerPid(ownerEntries[0], FILE_LOCK_OWNER_MARKER_PREFIX); if (ownerPid === undefined) { - return 'malformed'; + return { state: 'malformed' }; } const liveness = await (options?.checkProcessLiveness ?? getProcessLiveness)(ownerPid); if (liveness === 'dead') { - return 'stale'; + return { state: 'stale', marker: ownerEntries[0], markerKind: 'owner' }; } - return liveness === 'live' ? 'held' : 'unavailable'; + return { state: liveness === 'live' ? 'held' : 'unavailable', marker: ownerEntries[0], markerKind: 'owner' }; } - return 'orphaned'; + return { state: 'orphaned' }; } /** - * Move a stale or retained lock out of the lock name before a replacement owner is acquired. - * The rename prevents a newly-created lock from being removed based on an earlier inspection. + * Claim and remove the exact observed stale or retained generation without releasing the lock directory. */ -export async function reclaimFileLock(filePath: string): Promise { +export async function reclaimFileLock(filePath: string, options?: InspectFileLockOptions): Promise { const lockPath = getFileLockPath(filePath); - const state = await inspectFileLock(filePath); - if (state !== 'stale' && state !== 'retained') { + const snapshot = await inspectFileLockSnapshot(filePath, options); + if ( + (snapshot.state !== 'stale' && snapshot.state !== 'retained') || + !snapshot.marker || + !snapshot.markerKind + ) { return false; } - const quarantinedLockPath = `${lockPath}.reclaimed-${process.pid}-${crypto.randomBytes(16).toString('hex')}`; + const claimedMarker = path.join( + lockPath, + `.reclaim-${process.pid}-${crypto.randomBytes(16).toString('hex')}-${snapshot.marker}`, + ); try { - await fsapi.rename(lockPath, quarantinedLockPath); + await fsapi.rename(path.join(lockPath, snapshot.marker), claimedMarker); } catch (error) { - if (hasErrorCode(error, 'ENOENT')) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'EEXIST')) { return false; } throw error; } - await fsapi.remove(quarantinedLockPath); - return true; + try { + await fsapi.unlink(claimedMarker); + await fsapi.rmdir(lockPath); + return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTEMPTY')) { + return false; + } + throw error; + } } export async function getProcessLiveness(pid: number): Promise { @@ -204,8 +238,10 @@ export async function getProcessLiveness(pid: number): Promise async function isRetainedLock(lockPath: string): Promise { try { - await fsapi.lstat(path.join(lockPath, FILE_LOCK_RETAINED_MARKER)); - return true; + const entries = await fsapi.readdir(lockPath); + return entries.some( + (entry) => entry === FILE_LOCK_RETAINED_MARKER || entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX), + ); } catch (error) { if (hasErrorCode(error, 'ENOENT')) { return false; @@ -220,8 +256,12 @@ function hasErrorCode(error: unknown, code: string): boolean { ); } -function parseOwnerPid(entry: string): number | undefined { - const match = entry.match(new RegExp(`^${escapeRegExp(FILE_LOCK_OWNER_MARKER_PREFIX)}(\\d+)-`)); +function getRetainedMarkerName(ownerMarker: string): string { + return `${FILE_LOCK_RETAINED_MARKER_PREFIX}${ownerMarker.slice(FILE_LOCK_OWNER_MARKER_PREFIX.length)}`; +} + +function parseMarkerPid(entry: string, prefix: string): number | undefined { + const match = entry.match(new RegExp(`^${escapeRegExp(prefix)}(\\d+)-.+$`)); if (!match) { return undefined; } diff --git a/src/test/common/lockfile.apis.unit.test.ts b/src/test/common/lockfile.apis.unit.test.ts index df8c5acc4..a0230a230 100644 --- a/src/test/common/lockfile.apis.unit.test.ts +++ b/src/test/common/lockfile.apis.unit.test.ts @@ -12,8 +12,11 @@ import { acquireFileLock, AcquireFileLockOptions, FILE_LOCK_OWNER_MARKER_PREFIX, + FILE_LOCK_RETAINED_MARKER, + FILE_LOCK_RETAINED_MARKER_PREFIX, getFileLockPath, inspectFileLock, + reclaimFileLock, } from '../../common/lockfile.apis'; const OPTIONS: AcquireFileLockOptions = { @@ -171,29 +174,29 @@ suite('lockfile APIs', () => { assert.ok(Date.now() - startedAt < 1_000); const lockPath = `${path.resolve(targetPath)}.lock`; const retainedEntries = await fs.readdir(lockPath); - assert.ok(retainedEntries.includes('retained')); - assert.strictEqual(retainedEntries.filter((entry) => entry.startsWith('owner-')).length, 1); + assert.strictEqual(retainedEntries.length, 1); + assert.ok(retainedEntries[0].startsWith(`${FILE_LOCK_RETAINED_MARKER_PREFIX}${process.pid}-`)); await lock.release(); assert.deepStrictEqual(await fs.readdir(lockPath), retainedEntries); }); - test('falls back to renaming the owner marker when the retained sentinel cannot be written', async () => { + test('atomically converts the owner marker into a generation-specific retained marker', async () => { const lock = await acquireFileLock(targetPath, OPTIONS); - sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); await lock.retain(); const lockPath = `${path.resolve(targetPath)}.lock`; - assert.deepStrictEqual(await fs.readdir(lockPath), ['retained']); + const entries = await fs.readdir(lockPath); + assert.strictEqual(entries.length, 1); + assert.ok(entries[0].startsWith(`${FILE_LOCK_RETAINED_MARKER_PREFIX}${process.pid}-`)); await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { return error.code === 'ELOCKRETAINED'; }); }); - test('remains fail-closed when neither retained-marker strategy succeeds', async () => { + test('remains fail-closed when retaining the generation marker fails', async () => { const lock = await acquireFileLock(targetPath, OPTIONS); - sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); sinon.stub(fsExtra, 'rename').rejects(Object.assign(new Error('rename failed'), { code: 'EBUSY' })); await assert.rejects(lock.retain(), (error: NodeJS.ErrnoException) => error.code === 'ERETAINFAILED'); @@ -233,6 +236,70 @@ suite('lockfile APIs', () => { assert.strictEqual(await inspectFileLock(targetPath), 'retained'); }); + test('reclaims a generation-specific retained lock', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + await lock.retain(); + + assert.strictEqual(await reclaimFileLock(targetPath), true); + assert.strictEqual(await inspectFileLock(targetPath), 'missing'); + const replacement = await acquireFileLock(targetPath, OPTIONS); + await replacement.release(); + }); + + test('refuses to reclaim the ambiguous legacy retained marker', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-legacy`), ''); + await fs.writeFile(path.join(lockPath, FILE_LOCK_RETAINED_MARKER), ''); + + assert.strictEqual(await inspectFileLock(targetPath), 'retained'); + assert.strictEqual(await reclaimFileLock(targetPath), false); + assert.strictEqual(await fs.pathExists(path.join(lockPath, FILE_LOCK_RETAINED_MARKER)), true); + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKRETAINED'; + }); + }); + + test('does not touch a new generation when a delayed reclaimer loses its marker claim', async () => { + const lockPath = getFileLockPath(targetPath); + const staleMarker = `${FILE_LOCK_OWNER_MARKER_PREFIX}424242-dead`; + await fs.ensureDir(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, staleMarker), ''); + const rename = fsExtra.rename; + let releaseFirstClaim: (() => void) | undefined; + let firstClaimStarted: (() => void) | undefined; + const firstClaim = new Promise((resolve) => { + firstClaimStarted = resolve; + }); + const releaseClaim = new Promise((resolve) => { + releaseFirstClaim = resolve; + }); + let renameCount = 0; + sinon.stub(fsExtra, 'rename').callsFake(async (source, destination) => { + renameCount += 1; + if (renameCount === 1) { + firstClaimStarted!(); + await releaseClaim; + } + await rename(source, destination); + }); + + const staleInspection = { checkProcessLiveness: sinon.stub().resolves('dead') }; + const delayedReclaimer = reclaimFileLock(targetPath, staleInspection); + await firstClaim; + assert.strictEqual(await reclaimFileLock(targetPath, staleInspection), true); + const replacement = await acquireFileLock(targetPath, OPTIONS); + const replacementEntries = await fs.readdir(lockPath); + + releaseFirstClaim!(); + assert.strictEqual(await delayedReclaimer, false); + assert.deepStrictEqual(await fs.readdir(lockPath), replacementEntries); + assert.strictEqual(await fs.pathExists(targetPath), true); + + await replacement.release(); + }); + test('classifies a dead owner marker as stale using the liveness probe', async () => { const lockPath = getFileLockPath(targetPath); await fs.ensureDir(lockPath); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 3b3a9d04d..e47ca99fe 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -2334,7 +2334,23 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), environment); }); - test('clears a retained lock and its corresponding cache entry', async () => { + test('clears a generation-specific retained lock and its corresponding cache entry', async () => { + lockStub.restore(); + const retainedCacheDir = envDir().fsPath; + await fs.outputFile(venvPythonPath(retainedCacheDir), ''); + const lock = await lockfileApis.acquireFileLock(retainedCacheDir, { + timeoutMs: 0, + retryIntervalMs: 1, + }); + await lock.retain(); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(retainedCacheDir), false); + assert.strictEqual(await fs.pathExists(lockfileApis.getFileLockPath(retainedCacheDir)), false); + }); + + test('refuses to clear a legacy retained lock conservatively', async () => { lockStub.restore(); const retainedCacheDir = envDir().fsPath; const retainedLockPath = lockfileApis.getFileLockPath(retainedCacheDir); @@ -2342,10 +2358,10 @@ suite('InlineScriptEnvManager', () => { await fs.ensureDir(retainedLockPath); await fs.writeFile(path.join(retainedLockPath, 'retained'), ''); - await manager.clearCache(); + await assert.rejects(manager.clearCache(), /incomplete or malformed/); - assert.strictEqual(await fs.pathExists(retainedCacheDir), false); - assert.strictEqual(await fs.pathExists(retainedLockPath), false); + assert.strictEqual(await fs.pathExists(retainedCacheDir), true); + assert.strictEqual(await fs.pathExists(retainedLockPath), true); }); test('clears a stale owner lock and its corresponding cache entry', async () => {