From e08e1a1ad9480941ad0458af73a06169e3e6dc1d Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 10:03:25 -0700 Subject: [PATCH 1/2] Add clear script environment cache command 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/common/lockfile.apis.ts | 94 +++- src/extension.ts | 4 + src/features/envCommands.ts | 75 +++- src/features/projectManager.ts | 5 +- src/features/settings/settingHelpers.ts | 237 +++++++++- .../builtin/inlineScript/envManager.ts | 384 +++++++++++++++-- src/managers/builtin/venvUtils.ts | 4 +- src/test/common/lockfile.apis.unit.test.ts | 67 ++- src/test/features/envCommands.unit.test.ts | 252 ++++++++++- .../projectManager.initialize.unit.test.ts | 159 +++++++ .../settings/settingHelpers.unit.test.ts | 408 +++++++++++++++++- .../inlineScript/envManager.unit.test.ts | 315 +++++++++++++- src/test/smoke/registration.smoke.test.ts | 1 + 15 files changed, 1947 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index dd7cba3cf..0a9a6abaf 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,12 @@ "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%", @@ -465,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 483ecfd29..c128863de 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.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 1d2d98aee..5b66eee7d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -45,6 +45,7 @@ import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, copyPathToClipboard, + clearScriptEnvironmentCacheCommand, createAnyEnvironmentCommand, createEnvironmentCommand, createTerminalCommand, @@ -371,6 +372,9 @@ export async function activate(context: ExtensionContext): Promise { + 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 1de8a13a6..0136539f1 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -26,7 +26,12 @@ import { ProjectCreators, PythonProjectManager, } from '../internal.api'; -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'; @@ -50,8 +55,11 @@ import { showInputBox, showOpenDialog, showQuickPick, + 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'; @@ -662,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 d68d9dda6..68a21d940 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, @@ -42,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'; @@ -51,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, @@ -99,6 +112,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly associationRevisions = new Map(); private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); + private cacheMaintenanceQueue: Promise = Promise.resolve(); + private cacheMaintenanceBarrier: Deferred | undefined; + private pendingCacheMaintenances = 0; + private activeCreateOperations = 0; private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -129,46 +146,53 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions, ): Promise { + 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) { - this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); - return undefined; + }); + } finally { + this.activeCreateOperations -= 1; } } @@ -236,17 +260,24 @@ 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 clearCache(): Promise { + const activeCreatesAtStart = this.activeCreateOperations; + return this.enqueueCacheMaintenance(() => + this.enqueueSelection(() => this.clearCacheInternal(activeCreatesAtStart)), + ); + } + 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; @@ -760,6 +791,33 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } + private async waitForCacheMaintenance(operation: () => Promise): Promise { + const barrier = this.cacheMaintenanceBarrier; + if (barrier) { + await barrier.promise; + } + return operation(); + } + + private enqueueCacheMaintenance(operation: () => Promise): Promise { + if (!this.cacheMaintenanceBarrier) { + this.cacheMaintenanceBarrier = createDeferred(); + } + 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 enqueueSelection(operation: () => Promise): Promise { const run = this.selectionQueue.then(operation); this.selectionQueue = run.then( @@ -772,7 +830,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { 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))) ); } @@ -1251,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); diff --git a/src/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index c06146999..da482e3a1 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -507,7 +507,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); @@ -515,7 +515,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 079ca9d73..e31e98adc 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -1,12 +1,21 @@ 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 { createAnyEnvironmentCommand, removePythonProject, revealEnvInManagerView } from '../../features/envCommands'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import { + clearScriptEnvironmentCacheCommand, + createAnyEnvironmentCommand, + removePythonProject, + revealEnvInManagerView, +} from '../../features/envCommands'; import * as settingHelpers from '../../features/settings/settingHelpers'; import { EnvManagerView } from '../../features/views/envManagersView'; import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; @@ -204,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); @@ -216,6 +226,244 @@ 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(); + }); + + test('cancels without clearing the cache or touching project settings', async () => { + 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(), + 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 clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.notCalled(clearCache); + sinon.assert.notCalled(removeSettings); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); + + 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('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', + }; + 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); + }); +}); + suite('Reveal Env In Manager View Command Tests', () => { let managerView: typeMoq.IMock; let executeCommandStub: sinon.SinonStub; 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 3d0488cae..1e5811a55 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -144,7 +144,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); @@ -2159,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/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index bd8d469e0..727bf2bc0 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -65,6 +65,7 @@ suite('Smoke: Registration Checks', function () { 'python-envs.setPkgManager', 'python-envs.refreshAllManagers', 'python-envs.clearCache', + 'python-envs.clearScriptEnvCache', 'python-envs.searchSettings', // Package management From 344c68164bcea5ceffb5d4d8f06079a7d2766efc Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 12:17:37 -0700 Subject: [PATCH 2/2] Simplify inline script cache clearing for preview 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, 1175 insertions(+), 1948 deletions(-) diff --git a/package.json b/package.json index 0a9a6abaf..fc37a6277 100644 --- a/package.json +++ b/package.json @@ -246,10 +246,11 @@ "icon": "$(trash)" }, { - "command": "python-envs.clearScriptEnvCache", - "title": "%python-envs.clearScriptEnvCache.title%", + "command": "python-envs.clearInlineScriptCache", + "title": "%python-envs.clearInlineScriptCache.title%", "category": "Python", - "icon": "$(trash)" + "icon": "$(trash)", + "enablement": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" }, { "command": "python-envs.runInTerminal", @@ -420,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" @@ -471,10 +476,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..538b5abb7 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.clearScriptEnvCache.title": "Clear Script Environment 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/common/lockfile.apis.ts b/src/common/lockfile.apis.ts index 2fbe10352..1d8409056 100644 --- a/src/common/lockfile.apis.ts +++ b/src/common/lockfile.apis.ts @@ -16,31 +16,13 @@ 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 = 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 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 deadline = Date.now() + options.timeoutMs; while (true) { @@ -118,68 +100,9 @@ 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, FILE_LOCK_RETAINED_MARKER)); + await fsapi.lstat(path.join(lockPath, 'retained')); return true; } catch (error) { if (hasErrorCode(error, 'ENOENT')) { @@ -195,15 +118,6 @@ 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 5b66eee7d..c4755f09a 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,8 +44,9 @@ import { NewScriptProject } from './features/creators/newScriptProject'; import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, + clearCacheCommand, + clearInlineScriptCacheCommand, copyPathToClipboard, - clearScriptEnvironmentCacheCommand, createAnyEnvironmentCommand, createEnvironmentCommand, createTerminalCommand, @@ -97,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'; @@ -192,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.clearScriptEnvCache', async () => { - await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + commands.registerCommand('python-envs.clearInlineScriptCache', async () => { + await clearInlineScriptCacheCommand(() => inlineScriptEnvManager); }), commands.registerCommand('python-envs.runInTerminal', (item) => { return runInTerminalCommand(item, api, terminalManager); @@ -655,13 +656,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 0136539f1..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,12 +29,9 @@ import { ProjectCreators, PythonProjectManager, } from '../internal.api'; -import { - getResolvedPythonProjectSettings, - removePythonProjectSetting, - setEnvironmentManager, - setPackageManager, -} from './settings/settingHelpers'; +import { isInlineScriptsFeatureEnabled } from '../helpers'; +import { waitForEnvManagerId } from './common/managerReady'; +import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers'; import { valid as pep440Valid } from '@renovatebot/pep440'; import { executeCommand } from '../common/command.api'; @@ -58,8 +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'; import { TerminalManager } from './terminal/terminalManager'; @@ -314,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) { @@ -670,71 +712,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, -): 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 31dd86cd0..9c1cf7bb3 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -130,17 +130,20 @@ 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; + continue; // skip if the workspace is not the same as the current workspace } 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 25b2eca9d..752a1c2c0 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -10,112 +10,6 @@ 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, @@ -455,46 +349,6 @@ 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(); @@ -591,7 +445,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 +461,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/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 68a21d940..acfb64568 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -43,15 +43,8 @@ import { PYENV_MANAGER_ID, SYSTEM_MANAGER_ID, } from '../../../common/constants'; -import { - acquireFileLock, - AcquiredFileLock, - FILE_LOCK_DIR_SUFFIX, - getFileLockPath, - inspectFileLock, -} from '../../../common/lockfile.apis'; +import { acquireFileLock, AcquiredFileLock } 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'; @@ -59,12 +52,7 @@ import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { NativePythonFinder } from '../../common/nativePythonFinder'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; -import { - createWithProgress, - hasMinimumPathDepth, - isDriveRoot, - resolveVenvPythonEnvironmentPath, -} from '../venvUtils'; +import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils'; const BASE_INTERPRETER_MANAGER_IDS = new Set([ SYSTEM_MANAGER_ID, @@ -72,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`; @@ -99,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>(); @@ -112,10 +104,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly associationRevisions = new Map(); private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); - private cacheMaintenanceQueue: Promise = Promise.resolve(); - private cacheMaintenanceBarrier: Deferred | undefined; - private pendingCacheMaintenances = 0; - private activeCreateOperations = 0; + private activeCreateCount = 0; + private isClearCacheInProgress = false; private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -146,53 +136,60 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions, ): Promise { - this.activeCreateOperations += 1; + 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 { - 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 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); - } - } - } catch (error) { - this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); - return undefined; + 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) { + 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.activeCreateOperations -= 1; + this.activeCreateCount -= 1; } } @@ -260,22 +257,73 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } async set(scope: SetEnvironmentScope, environment?: PythonEnvironment): Promise { - return this.waitForCacheMaintenance(() => this.enqueueSelection(() => this.setInternal(scope, environment))); + return this.enqueueSelection(() => this.setInternal(scope, environment)); } async get(scope: GetEnvironmentScope): Promise { - return this.waitForCacheMaintenance(() => this.getInternal(scope)); + return this.getInternal(scope); } async resolve(_context: ResolveEnvironmentContext): Promise { return undefined; } - async clearCache(): Promise { - const activeCreatesAtStart = this.activeCreateOperations; - return this.enqueueCacheMaintenance(() => - this.enqueueSelection(() => this.clearCacheInternal(activeCreatesAtStart)), - ); + 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 { @@ -772,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; @@ -791,46 +843,245 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } - private async waitForCacheMaintenance(operation: () => Promise): Promise { - const barrier = this.cacheMaintenanceBarrier; - if (barrier) { - await barrier.promise; + 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; } - return operation(); } - private enqueueCacheMaintenance(operation: () => Promise): Promise { - if (!this.cacheMaintenanceBarrier) { - this.cacheMaintenanceBarrier = createDeferred(); + private async assertNoCacheLocks(cacheRootPath: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(cacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + throw error; } - 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; + + 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 enqueueSelection(operation: () => Promise): Promise { - const run = this.selectionQueue.then(operation); - this.selectionQueue = run.then( - () => undefined, - () => undefined, + 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, + ), ); - return run; + } + + 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)) || - (await fs.pathExists(getFileLockPath(envDirPath))) + (await fs.pathExists(`${path.resolve(envDirPath)}.lock`)) ); } @@ -1118,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') { @@ -1155,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); } } } @@ -1309,254 +1573,6 @@ 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); @@ -1612,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/managers/builtin/venvUtils.ts b/src/managers/builtin/venvUtils.ts index da482e3a1..c06146999 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -507,7 +507,7 @@ export async function createPythonVenv( return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options); } -export function isDriveRoot(fsPath: string): boolean { +function isDriveRoot(fsPath: string): boolean { const normalized = path.normalize(fsPath); if (os.platform() === 'win32') { return /^[a-zA-Z]:[\\/]?$/.test(normalized); @@ -515,7 +515,7 @@ export function isDriveRoot(fsPath: string): boolean { return normalized === '/'; } -export function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean { +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 df8c5acc4..a2d343a19 100644 --- a/src/test/common/lockfile.apis.unit.test.ts +++ b/src/test/common/lockfile.apis.unit.test.ts @@ -8,13 +8,7 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { - acquireFileLock, - AcquireFileLockOptions, - FILE_LOCK_OWNER_MARKER_PREFIX, - getFileLockPath, - inspectFileLock, -} from '../../common/lockfile.apis'; +import { acquireFileLock, AcquireFileLockOptions } from '../../common/lockfile.apis'; const OPTIONS: AcquireFileLockOptions = { timeoutMs: 40, @@ -215,63 +209,4 @@ 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 e31e98adc..6fd6229d6 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -1,22 +1,24 @@ 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 persistentState from '../../common/persistentState'; import * as windowApis from '../../common/window.apis'; -import * as workspaceApis from '../../common/workspace.apis'; import { - clearScriptEnvironmentCacheCommand, + 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'; @@ -213,7 +215,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); @@ -226,241 +227,109 @@ 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, - }; - +suite('Clear Cache Command Tests', () => { teardown(() => { sinon.restore(); }); - test('cancels without clearing the cache or touching project settings', async () => { - const clearCache = sinon.stub().resolves(); + test('keeps the broad clear handler on the base path', async () => { + const calls: string[] = []; const envManagers = { - getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ - supportsClearCache: () => true, - clearCache, + clearCache: sinon.stub().callsFake(async (scope: unknown) => { + calls.push(`managers:${String(scope)}`); }), } 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 clearShellProfileCache = sinon.stub().callsFake(async () => { + calls.push('shell'); + }); + sinon.stub(persistentState, 'clearPersistentState').callsFake(async () => { + calls.push('state'); + }); - await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + await clearCacheCommand(envManagers, clearShellProfileCache); - sinon.assert.notCalled(clearCache); - sinon.assert.notCalled(removeSettings); - sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + assert.deepStrictEqual(calls, ['state', 'managers:undefined', 'shell']); + assert.ok((envManagers.clearCache as sinon.SinonStub).calledOnceWithExactly(undefined)); + assert.ok(clearShellProfileCache.calledOnce); }); +}); - 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]); +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(); }); - 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(), + 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, ); - sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + + assert.ok(showErrorMessageStub.calledOnce); + assert.strictEqual(waitForEnvManagerIdStub.called, false); + assert.strictEqual(getManager.called, false); + 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); + 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); }); }); 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/features/projectManager.initialize.unit.test.ts b/src/test/features/projectManager.initialize.unit.test.ts index f89a325ac..84e0c9fc7 100644 --- a/src/test/features/projectManager.initialize.unit.test.ts +++ b/src/test/features/projectManager.initialize.unit.test.ts @@ -1,10 +1,8 @@ /* 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'; @@ -306,163 +304,6 @@ 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 09835fab7..ef195addd 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -9,14 +9,12 @@ 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 { PythonProjectSettings, PythonProjectsImpl } from '../../../internal.api'; +import { PythonProjectsImpl } from '../../../internal.api'; import { MockWorkspaceConfiguration } from '../../mocks/mockWorkspaceConfig'; /** @@ -619,410 +617,6 @@ 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 1e5811a55..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; @@ -184,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 () => { @@ -201,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 }); } @@ -236,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'), []); @@ -972,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 () => { @@ -1026,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); }); }); @@ -1366,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 () => { @@ -1384,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 () => { @@ -1405,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 () => { @@ -1413,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 () => { @@ -1424,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 () => { @@ -1469,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(); @@ -2163,313 +2634,4 @@ 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 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 727bf2bc0..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,7 +66,7 @@ suite('Smoke: Registration Checks', function () { 'python-envs.setPkgManager', 'python-envs.refreshAllManagers', 'python-envs.clearCache', - 'python-envs.clearScriptEnvCache', + 'python-envs.clearInlineScriptCache', 'python-envs.searchSettings', // Package management @@ -114,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 // =========================================================================