diff --git a/src/common/lockfile.apis.ts b/src/common/lockfile.apis.ts index 1d8409056..cb8ff8032 100644 --- a/src/common/lockfile.apis.ts +++ b/src/common/lockfile.apis.ts @@ -16,13 +16,33 @@ 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_PREFIX = 'retained-'; +/** Legacy retained marker. It remains recognizable but cannot be safely reclaimed. */ +export const FILE_LOCK_RETAINED_MARKER = 'retained'; + +export type ProcessLiveness = 'live' | 'dead' | 'unavailable'; +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, getRetainedMarkerName(path.basename(ownerMarker))); const deadline = Date.now() + options.timeoutMs; while (true) { @@ -51,22 +71,9 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock } state = 'retained'; try { - await fsapi.writeFile(retainedMarker, '', { flag: 'wx' }); - } catch (error) { - if (hasErrorCode(error, 'EEXIST')) { - return; - } - try { - await fsapi.rename(ownerMarker, retainedMarker); - } catch (renameError) { - if (!hasErrorCode(renameError, 'EEXIST')) { - throw createLockError( - 'Failed to mark the lock as retained', - 'ERETAINFAILED', - lockPath, - ); - } - } + await fsapi.rename(ownerMarker, retainedMarker); + } catch (_error) { + throw createLockError('Failed to mark the lock as retained', 'ERETAINFAILED', lockPath); } }, release: async () => { @@ -100,10 +107,141 @@ export async function acquireFileLock(filePath: string, options: AcquireFileLock } } -async function isRetainedLock(lockPath: string): Promise { +export async function inspectFileLock(filePath: string, options?: InspectFileLockOptions): Promise { + return (await inspectFileLockSnapshot(filePath, options)).state; +} + +interface FileLockSnapshot { + readonly state: FileLockState; + readonly marker?: string; + readonly markerKind?: 'owner' | 'retained'; +} + +async function inspectFileLockSnapshot( + filePath: string, + options?: InspectFileLockOptions, +): Promise { + const lockPath = getFileLockPath(filePath); + + let stat; try { - await fsapi.lstat(path.join(lockPath, 'retained')); + stat = await fsapi.lstat(lockPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return { state: 'missing' }; + } + throw error; + } + + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { state: 'malformed' }; + } + + const entries = await fsapi.readdir(lockPath); + const ownerEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX)); + const generationRetainedEntries = entries.filter((entry) => entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX)); + const retainedEntries = entries.filter((entry) => entry === FILE_LOCK_RETAINED_MARKER); + const unknownEntries = entries.filter( + (entry) => + !entry.startsWith(FILE_LOCK_OWNER_MARKER_PREFIX) && + !entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX) && + entry !== FILE_LOCK_RETAINED_MARKER, + ); + + if ( + unknownEntries.length > 0 || + ownerEntries.length > 1 || + generationRetainedEntries.length > 1 || + retainedEntries.length > 1 || + generationRetainedEntries.length + retainedEntries.length > 1 || + generationRetainedEntries.length + ownerEntries.length > 1 + ) { + return { state: 'malformed' }; + } + if (retainedEntries.length === 1) { + return { state: 'retained' }; + } + if (generationRetainedEntries.length === 1) { + const retainedPid = parseMarkerPid(generationRetainedEntries[0], FILE_LOCK_RETAINED_MARKER_PREFIX); + if (retainedPid === undefined) { + return { state: 'malformed' }; + } + return { state: 'retained', marker: generationRetainedEntries[0], markerKind: 'retained' }; + } + if (ownerEntries.length === 1) { + const ownerPid = parseMarkerPid(ownerEntries[0], FILE_LOCK_OWNER_MARKER_PREFIX); + if (ownerPid === undefined) { + return { state: 'malformed' }; + } + const liveness = await (options?.checkProcessLiveness ?? getProcessLiveness)(ownerPid); + if (liveness === 'dead') { + return { state: 'stale', marker: ownerEntries[0], markerKind: 'owner' }; + } + return { state: liveness === 'live' ? 'held' : 'unavailable', marker: ownerEntries[0], markerKind: 'owner' }; + } + return { state: 'orphaned' }; +} + +/** + * Claim and remove the exact observed stale or retained generation without releasing the lock directory. + */ +export async function reclaimFileLock(filePath: string, options?: InspectFileLockOptions): Promise { + const lockPath = getFileLockPath(filePath); + const snapshot = await inspectFileLockSnapshot(filePath, options); + if ( + (snapshot.state !== 'stale' && snapshot.state !== 'retained') || + !snapshot.marker || + !snapshot.markerKind + ) { + return false; + } + + const claimedMarker = path.join( + lockPath, + `.reclaim-${process.pid}-${crypto.randomBytes(16).toString('hex')}-${snapshot.marker}`, + ); + try { + await fsapi.rename(path.join(lockPath, snapshot.marker), claimedMarker); + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'EEXIST')) { + return false; + } + throw error; + } + + try { + await fsapi.unlink(claimedMarker); + await fsapi.rmdir(lockPath); return true; + } catch (error) { + if (hasErrorCode(error, 'ENOENT') || hasErrorCode(error, 'ENOTEMPTY')) { + return false; + } + throw error; + } +} + +export async function getProcessLiveness(pid: number): Promise { + 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 { + const entries = await fsapi.readdir(lockPath); + return entries.some( + (entry) => entry === FILE_LOCK_RETAINED_MARKER || entry.startsWith(FILE_LOCK_RETAINED_MARKER_PREFIX), + ); } catch (error) { if (hasErrorCode(error, 'ENOENT')) { return false; @@ -118,6 +256,23 @@ function hasErrorCode(error: unknown, code: string): boolean { ); } +function getRetainedMarkerName(ownerMarker: string): string { + return `${FILE_LOCK_RETAINED_MARKER_PREFIX}${ownerMarker.slice(FILE_LOCK_OWNER_MARKER_PREFIX.length)}`; +} + +function parseMarkerPid(entry: string, prefix: string): number | undefined { + const match = entry.match(new RegExp(`^${escapeRegExp(prefix)}(\\d+)-.+$`)); + if (!match) { + return undefined; + } + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function createLockError(message: string, code: string, lockPath: string): NodeJS.ErrnoException { return Object.assign(new Error(message), { code, path: lockPath }); } diff --git a/src/extension.ts b/src/extension.ts index 46f89009b..f45d46aa2 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, @@ -94,7 +95,12 @@ import { PythonStatusBarImpl } from './features/views/pythonStatusBar'; import { updateViewsAndStatus } from './features/views/revealHandler'; import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; -import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; +import { + collectEnvironmentInfo, + getEnvManagerAndPackageManagerConfigLevels, + isInlineScriptsFeatureEnabled, + runPetInTerminalImpl, +} from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main'; import { registerSystemPythonFeatures } from './managers/builtin/main'; @@ -386,6 +392,13 @@ 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..fafb6fbcd 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 { + removeInlineScriptPythonProjectSettings, + removePythonProjectSetting, + setEnvironmentManager, + setPackageManager, +} from './settings/settingHelpers'; import { valid as pep440Valid } from '@renovatebot/pep440'; import { executeCommand } from '../common/command.api'; @@ -50,8 +55,10 @@ import { showInputBox, showOpenDialog, showQuickPick, + showWarningMessage, withProgress, } from '../common/window.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 +669,36 @@ export async function removePythonProject( wm.remove(item.project); } +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; + } + + await manager.clearCache(); + const loadedProjectsToRemove = await removeInlineScriptPythonProjectSettings(wm.getProjects()); + if (loadedProjectsToRemove.length > 0) { + wm.remove(loadedProjectsToRemove); + } +} + export async function getPackageCommandOptions( e: unknown, em: EnvironmentManagers, diff --git a/src/features/envManagers.ts b/src/features/envManagers.ts index 9fa545d3b..d68973c9c 100644 --- a/src/features/envManagers.ts +++ b/src/features/envManagers.ts @@ -320,12 +320,16 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { public async clearCache(scope: EnvironmentManagerScope): Promise { if (scope === undefined) { - await Promise.all(this.managers.map((m) => m.clearCache())); + await Promise.all( + this.managers + .filter((manager) => manager.id !== INLINE_SCRIPT_MANAGER_ID) + .map((manager) => manager.clearCache()), + ); return; } const manager = this.getEnvironmentManager(scope); - if (manager) { + if (manager && manager.id !== INLINE_SCRIPT_MANAGER_ID) { await manager.clearCache(); } } diff --git a/src/features/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..24189aacb 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -1,7 +1,12 @@ import * as path from 'path'; import { ConfigurationScope, ConfigurationTarget, Uri, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { PythonProject } from '../../api'; -import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../common/constants'; +import { + DEFAULT_ENV_MANAGER_ID, + DEFAULT_PACKAGE_MANAGER_ID, + INLINE_SCRIPT_MANAGER_ID, + SYSTEM_MANAGER_ID, +} from '../../common/constants'; import { traceError, traceInfo, traceVerbose, traceWarn } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; import { normalizePath } from '../../common/utils/pathUtils'; @@ -10,6 +15,123 @@ import { sendTelemetryEvent } from '../../common/telemetry/sender'; import * as workspaceApis from '../../common/workspace.apis'; import { PythonProjectManager, PythonProjectSettings } from '../../internal.api'; +interface ResolvedPythonProjectSettingSource { + readonly setting: PythonProjectSettings; + readonly uri: Uri; + readonly workspaceFolder: WorkspaceFolder; + readonly source: 'global' | 'workspace' | 'workspaceFolder'; + readonly target: ConfigurationTarget.Global | ConfigurationTarget.Workspace | ConfigurationTarget.WorkspaceFolder; +} + +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: 'global' | 'workspace' | 'workspaceFolder', + target: ConfigurationTarget.Global | 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; +} + +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?.globalValue ?? []) + .map((setting) => + resolvePythonProjectSettingSource( + setting, + workspaceFolder, + allWorkspaceFolders, + 'global', + ConfigurationTarget.Global, + ), + ) + .filter((setting): setting is ResolvedPythonProjectSettingSource => setting !== undefined), + ...(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 +471,178 @@ 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 removeInlineScriptPythonProjectSettings( + currentProjects: readonly PythonProject[], +): Promise { + const currentProjectsByUri = new Map(currentProjects.map((project) => [project.uri.toString(), project] as const)); + const workspaceEntries: Array = []; + for (const workspaceFolder of workspaceApis.getWorkspaceFolders() ?? []) { + const edits: EditProjectSettings[] = getResolvedPythonProjectSettings(workspaceFolder) + .filter((resolvedSetting) => + resolvedSetting.sources.some((source) => source.setting.envManager === INLINE_SCRIPT_MANAGER_ID), + ) + .map((resolvedSetting) => ({ + project: + currentProjectsByUri.get(resolvedSetting.uri.toString()) ?? { + name: path.basename(resolvedSetting.uri.fsPath) || resolvedSetting.effective.setting.path, + uri: resolvedSetting.uri, + }, + envManager: INLINE_SCRIPT_MANAGER_ID, + })); + + if (edits.length > 0) { + workspaceEntries.push([workspaceFolder, edits]); + } + } + + if (workspaceEntries.length === 0) { + return []; + } + + const removedProjects = new Map(); + const folderRemainingSettings = new Map(); + const folderExistingSettings = new Map(); + const promises: Thenable[] = []; + let globalConfig: WorkspaceConfiguration | undefined; + let globalValueOriginal: PythonProjectSettings[] | undefined; + let workspaceConfig: WorkspaceConfiguration | undefined; + let workspaceValueOriginal: PythonProjectSettings[] | undefined; + + workspaceEntries.forEach(([workspaceFolder, edits]) => { + const config = workspaceApis.getConfiguration('python-envs', workspaceFolder.uri); + const projectsInspect = config.inspect('pythonProjects'); + globalConfig ??= config; + globalValueOriginal ??= cloneProjectSettings(projectsInspect?.globalValue); + workspaceConfig ??= config; + workspaceValueOriginal ??= cloneProjectSettings(projectsInspect?.workspaceValue); + + const workspaceFolderOriginal = cloneProjectSettings(projectsInspect?.workspaceFolderValue) ?? []; + folderExistingSettings.set(workspaceFolder.uri.toString(), workspaceFolderOriginal); + const workspaceFolderRemaining = workspaceFolderOriginal.filter( + (projectSetting) => !edits.some((edit) => matchesProjectSettingEdit(projectSetting, edit, workspaceFolder)), + ); + folderRemainingSettings.set(workspaceFolder.uri.toString(), workspaceFolderRemaining); + + if (workspaceFolderRemaining.length !== workspaceFolderOriginal.length) { + promises.push( + config.update( + 'pythonProjects', + workspaceFolderRemaining.length > 0 ? workspaceFolderRemaining : undefined, + ConfigurationTarget.WorkspaceFolder, + ), + ); + } + }); + + const aggregatedEdits = workspaceEntries.flatMap(([workspaceFolder, edits]) => + edits.map((edit) => ({ workspaceFolder, edit })), + ); + const globalValueRemaining = + globalValueOriginal?.filter( + (projectSetting) => + !aggregatedEdits.some(({ workspaceFolder, edit }) => + matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), + ), + ) ?? []; + const workspaceValueRemaining = + workspaceValueOriginal?.filter( + (projectSetting) => + !aggregatedEdits.some(({ workspaceFolder, edit }) => + matchesProjectSettingEdit(projectSetting, edit, workspaceFolder), + ), + ) ?? []; + + if (globalConfig && globalValueOriginal !== undefined && globalValueRemaining.length !== globalValueOriginal.length) { + promises.push( + globalConfig.update( + 'pythonProjects', + globalValueRemaining.length > 0 ? globalValueRemaining : undefined, + ConfigurationTarget.Global, + ), + ); + } + + if ( + workspaceConfig && + workspaceValueOriginal !== undefined && + workspaceValueRemaining.length !== workspaceValueOriginal.length + ) { + promises.push( + workspaceConfig.update( + 'pythonProjects', + workspaceValueRemaining.length > 0 ? workspaceValueRemaining : undefined, + ConfigurationTarget.Workspace, + ), + ); + } + + workspaceEntries.forEach(([workspaceFolder, edits]) => { + const existingSettings = [ + ...(globalValueOriginal ?? []), + ...(workspaceValueOriginal ?? []), + ...((folderExistingSettings.get(workspaceFolder.uri.toString()) ?? [])), + ]; + const remainingSettings = [ + ...globalValueRemaining, + ...workspaceValueRemaining, + ...((folderRemainingSettings.get(workspaceFolder.uri.toString()) ?? [])), + ]; + edits.filter( + (edit) => + existingSettings.some((projectSetting) => matchesProjectSettingEdit(projectSetting, edit, workspaceFolder)) && + !hasProjectSetting(remainingSettings, edit.project, workspaceFolder), + ).forEach((edit) => { + removedProjects.set(edit.project.uri.toString(), edit.project); + }); + }); + + await Promise.all(promises); + + return Array.from(removedProjects.values()) + .map((project) => currentProjectsByUri.get(project.uri.toString())) + .filter((project): project is PythonProject => project !== undefined); +} + export async function addPythonProjectSetting(edits: EditProjectSettings[]): Promise { const noWorkspace: EditProjectSettings[] = []; const workspaces = new Map(); diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index d68d9dda6..e230ce7e4 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,16 @@ 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, + reclaimFileLock, +} 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 +60,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 +113,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 +147,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 +261,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 +792,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 +831,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 +1310,414 @@ 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 physicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(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)); + }); + + const removedCacheEntries = new Set(); + const deletionErrors: unknown[] = []; + if (physicalCacheRootPath) { + let entryNames: string[]; + try { + entryNames = await fs.readdir(physicalCacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + entryNames = []; + } else { + throw error; + } + } + + const cacheEntryNames = new Set(); + for (const entryName of entryNames) { + if (entryName.endsWith(FILE_LOCK_DIR_SUFFIX)) { + const envName = entryName.slice(0, -FILE_LOCK_DIR_SUFFIX.length); + if (envName.length === 0) { + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is malformed.', + ); + this.log.error(`${message} (${path.join(physicalCacheRootPath, entryName)})`); + throw new Error(message); + } + cacheEntryNames.add(envName); + } else { + cacheEntryNames.add(entryName); + } + } + + for (const entryName of cacheEntryNames) { + try { + const removed = await this.removeCacheEntryForClear( + cacheRoot, + physicalCacheRootPath, + entryName, + ); + if (removed) { + removedCacheEntries.add(normalizePath(removed)); + } + } catch (error) { + deletionErrors.push(error); + this.log.error( + `Failed to remove inline-script cache entry ${path.join(physicalCacheRootPath, entryName)}: ${getErrorMessage(error)}`, + ); + } + } + } + + const invalidatedScriptPaths = await this.getInvalidatedAssociationPaths( + scriptPaths, + persistedAssociations, + removedCacheEntries, + ); + const persistenceError = await this.clearInvalidatedAssociations( + invalidatedScriptPaths, + persistedAssociations, + priorSelections, + ); + if (persistenceError) { + deletionErrors.push(persistenceError); + } + if (deletionErrors.length > 0) { + throw new Error( + `Failed to completely clear the inline-script environment cache: ${deletionErrors + .map((error) => getErrorMessage(error)) + .join('; ')}`, + ); + } + } + + private async removeCacheEntryForClear( + cacheRoot: Uri, + originalPhysicalCacheRootPath: string, + entryName: string, + ): Promise { + const envDirPath = path.join(originalPhysicalCacheRootPath, entryName); + let lock: AcquiredFileLock | undefined; + try { + lock = await this.acquireCacheEntryLockForClear(envDirPath); + const currentPhysicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot); + if (!currentPhysicalCacheRootPath) { + return undefined; + } + if ( + normalizePath(currentPhysicalCacheRootPath) !== normalizePath(originalPhysicalCacheRootPath) + ) { + const message = l10n.t( + 'Refusing to clear the script environment cache because its physical root changed during cleanup.', + ); + this.log.error( + `${message} (${originalPhysicalCacheRootPath} -> ${currentPhysicalCacheRootPath})`, + ); + throw new Error(message); + } + + const entryPath = await this.getClearableCacheEntryPath( + Uri.file(currentPhysicalCacheRootPath), + path.join(currentPhysicalCacheRootPath, entryName), + ); + if (!entryPath) { + return undefined; + } + await this.deleteCacheEntryForClear(entryPath); + return entryPath; + } finally { + if (lock) { + await lock.release(); + } + } + } + + private async acquireCacheEntryLockForClear(envDirPath: string): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + return await acquireFileLock(envDirPath, { timeoutMs: 0, retryIntervalMs: CACHE_LOCK_RETRY_MS }); + } catch (error) { + if (!this.isLockContentionError(error)) { + throw error; + } + const lockState = await inspectFileLock(envDirPath); + if (lockState === 'stale' || lockState === 'retained') { + await reclaimFileLock(envDirPath); + continue; + } + if (lockState === 'missing') { + continue; + } + this.throwClearCacheLockError(envDirPath, lockState); + } + } + + const lockState = await inspectFileLock(envDirPath); + this.throwClearCacheLockError(envDirPath, lockState); + } + + private isLockContentionError(error: unknown): boolean { + const code = typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + return code === 'ELOCKED' || code === 'ELOCKRETAINED'; + } + + private throwClearCacheLockError(envDirPath: string, lockState: string): never { + if (lockState === 'held') { + const message = l10n.t( + 'Cannot clear the script environment cache while a cached environment is being created.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + if (lockState === 'unavailable') { + const message = l10n.t( + 'Cannot clear the script environment cache because a cached environment lock could not be verified.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + + const message = l10n.t( + 'Refusing to clear the script environment cache because a lock entry is incomplete or malformed.', + ); + this.log.error(`${message} (${getFileLockPath(envDirPath)})`); + throw new Error(message); + } + + private async getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise { + 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 deleteCacheEntryForClear(entryPath: string): Promise { + return fs.remove(entryPath); + } + + private async getInvalidatedAssociationPaths( + scriptPaths: ReadonlySet, + persistedAssociations: PersistedInlineScriptEnvironments, + removedCacheEntries: ReadonlySet, + ): Promise> { + const invalidatedScriptPaths = new Set(); + for (const scriptPath of scriptPaths) { + const environmentPaths = [ + persistedAssociations[scriptPath], + this.fsPathToPersistedEnvPath.get(scriptPath), + this.fsPathToEnv.get(scriptPath)?.environmentPath.fsPath, + ].filter((value): value is string => value !== undefined); + const states = await Promise.all( + environmentPaths.map((environmentPath) => + this.isRemovedOrMissingCacheAssociation(environmentPath, removedCacheEntries), + ), + ); + if (states.some((state) => state)) { + invalidatedScriptPaths.add(scriptPath); + } + } + return invalidatedScriptPaths; + } + + private async isRemovedOrMissingCacheAssociation( + environmentPath: string, + removedCacheEntries: ReadonlySet, + ): Promise { + const envDirPath = path.dirname(path.dirname(environmentPath)); + if (removedCacheEntries.has(normalizePath(envDirPath))) { + return true; + } + try { + return !(await fs.pathExists(environmentPath)); + } catch (error) { + this.log.warn( + `Unable to verify inline-script environment association ${environmentPath}: ${getErrorMessage(error)}`, + ); + return false; + } + } + + private async clearInvalidatedAssociations( + invalidatedScriptPaths: ReadonlySet, + persistedAssociations: PersistedInlineScriptEnvironments, + priorSelections: ReadonlyMap, + ): Promise { + if (invalidatedScriptPaths.size === 0) { + if (Object.keys(persistedAssociations).length > 0) { + return undefined; + } + try { + await this.enqueuePersistence(async (state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + return undefined; + } catch (error) { + this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); + return error; + } + } + + let persistenceError: unknown; + const persistedPathsToClear = Array.from(invalidatedScriptPaths).filter( + (scriptPath) => persistedAssociations[scriptPath] !== undefined, + ); + try { + if (persistedPathsToClear.length === Object.keys(persistedAssociations).length) { + await this.enqueuePersistence(async (state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + } else if (persistedPathsToClear.length > 0) { + await this.updatePersistedAssociations( + persistedPathsToClear.map((scriptPath) => ({ + scriptPath, + expectedEnvironmentPath: persistedAssociations[scriptPath], + })), + ); + } + } catch (error) { + persistenceError = error; + this.log.error(`Failed to clear inline-script environment associations: ${getErrorMessage(error)}`); + } + + for (const scriptPath of invalidatedScriptPaths) { + this.bumpAssociationRevision(scriptPath); + this.pendingRehydrations.delete(scriptPath); + this.fsPathToEnv.delete(scriptPath); + this.fsPathToPersistedEnvPath.delete(scriptPath); + this.cachedAssociationValidatedAt.delete(scriptPath); + + const environment = priorSelections.get(scriptPath); + if (environment) { + this._onDidChangeEnvironment.fire({ + uri: Uri.file(scriptPath), + old: environment, + new: undefined, + }); + } + } + return persistenceError; + } + + private async getPersistedAssociationSnapshot(): Promise { + await this.persistenceQueue; + const state = await getWorkspacePersistentState(); + 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 2962235e1..e35825866 100644 --- a/src/managers/builtin/venvUtils.ts +++ b/src/managers/builtin/venvUtils.ts @@ -513,7 +513,7 @@ export async function createPythonVenv( return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options); } -function isDriveRoot(fsPath: string): boolean { +export function isDriveRoot(fsPath: string): boolean { const normalized = path.normalize(fsPath); if (os.platform() === 'win32') { return /^[a-zA-Z]:[\\/]?$/.test(normalized); @@ -521,7 +521,7 @@ function isDriveRoot(fsPath: string): boolean { return normalized === '/'; } -function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean { +export function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean { const normalized = path.normalize(fsPath); const parts = normalized.split(path.sep).filter((p) => p.length > 0 && p !== '.'); diff --git a/src/test/common/lockfile.apis.unit.test.ts b/src/test/common/lockfile.apis.unit.test.ts index a2d343a19..a0230a230 100644 --- a/src/test/common/lockfile.apis.unit.test.ts +++ b/src/test/common/lockfile.apis.unit.test.ts @@ -8,7 +8,16 @@ 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, + FILE_LOCK_RETAINED_MARKER, + FILE_LOCK_RETAINED_MARKER_PREFIX, + getFileLockPath, + inspectFileLock, + reclaimFileLock, +} from '../../common/lockfile.apis'; const OPTIONS: AcquireFileLockOptions = { timeoutMs: 40, @@ -165,29 +174,29 @@ suite('lockfile APIs', () => { assert.ok(Date.now() - startedAt < 1_000); const lockPath = `${path.resolve(targetPath)}.lock`; const retainedEntries = await fs.readdir(lockPath); - assert.ok(retainedEntries.includes('retained')); - assert.strictEqual(retainedEntries.filter((entry) => entry.startsWith('owner-')).length, 1); + assert.strictEqual(retainedEntries.length, 1); + assert.ok(retainedEntries[0].startsWith(`${FILE_LOCK_RETAINED_MARKER_PREFIX}${process.pid}-`)); await lock.release(); assert.deepStrictEqual(await fs.readdir(lockPath), retainedEntries); }); - test('falls back to renaming the owner marker when the retained sentinel cannot be written', async () => { + test('atomically converts the owner marker into a generation-specific retained marker', async () => { const lock = await acquireFileLock(targetPath, OPTIONS); - sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); await lock.retain(); const lockPath = `${path.resolve(targetPath)}.lock`; - assert.deepStrictEqual(await fs.readdir(lockPath), ['retained']); + const entries = await fs.readdir(lockPath); + assert.strictEqual(entries.length, 1); + assert.ok(entries[0].startsWith(`${FILE_LOCK_RETAINED_MARKER_PREFIX}${process.pid}-`)); await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { return error.code === 'ELOCKRETAINED'; }); }); - test('remains fail-closed when neither retained-marker strategy succeeds', async () => { + test('remains fail-closed when retaining the generation marker fails', async () => { const lock = await acquireFileLock(targetPath, OPTIONS); - sinon.stub(fsExtra, 'writeFile').rejects(Object.assign(new Error('write failed'), { code: 'EACCES' })); sinon.stub(fsExtra, 'rename').rejects(Object.assign(new Error('rename failed'), { code: 'EBUSY' })); await assert.rejects(lock.retain(), (error: NodeJS.ErrnoException) => error.code === 'ERETAINFAILED'); @@ -209,4 +218,127 @@ 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('reclaims a generation-specific retained lock', async () => { + const lock = await acquireFileLock(targetPath, OPTIONS); + await lock.retain(); + + assert.strictEqual(await reclaimFileLock(targetPath), true); + assert.strictEqual(await inspectFileLock(targetPath), 'missing'); + const replacement = await acquireFileLock(targetPath, OPTIONS); + await replacement.release(); + }); + + test('refuses to reclaim the ambiguous legacy retained marker', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `${FILE_LOCK_OWNER_MARKER_PREFIX}${process.pid}-legacy`), ''); + await fs.writeFile(path.join(lockPath, FILE_LOCK_RETAINED_MARKER), ''); + + assert.strictEqual(await inspectFileLock(targetPath), 'retained'); + assert.strictEqual(await reclaimFileLock(targetPath), false); + assert.strictEqual(await fs.pathExists(path.join(lockPath, FILE_LOCK_RETAINED_MARKER)), true); + await assert.rejects(acquireFileLock(targetPath, OPTIONS), (error: NodeJS.ErrnoException) => { + return error.code === 'ELOCKRETAINED'; + }); + }); + + test('does not touch a new generation when a delayed reclaimer loses its marker claim', async () => { + const lockPath = getFileLockPath(targetPath); + const staleMarker = `${FILE_LOCK_OWNER_MARKER_PREFIX}424242-dead`; + await fs.ensureDir(targetPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, staleMarker), ''); + const rename = fsExtra.rename; + let releaseFirstClaim: (() => void) | undefined; + let firstClaimStarted: (() => void) | undefined; + const firstClaim = new Promise((resolve) => { + firstClaimStarted = resolve; + }); + const releaseClaim = new Promise((resolve) => { + releaseFirstClaim = resolve; + }); + let renameCount = 0; + sinon.stub(fsExtra, 'rename').callsFake(async (source, destination) => { + renameCount += 1; + if (renameCount === 1) { + firstClaimStarted!(); + await releaseClaim; + } + await rename(source, destination); + }); + + const staleInspection = { checkProcessLiveness: sinon.stub().resolves('dead') }; + const delayedReclaimer = reclaimFileLock(targetPath, staleInspection); + await firstClaim; + assert.strictEqual(await reclaimFileLock(targetPath, staleInspection), true); + const replacement = await acquireFileLock(targetPath, OPTIONS); + const replacementEntries = await fs.readdir(lockPath); + + releaseFirstClaim!(); + assert.strictEqual(await delayedReclaimer, false); + assert.deepStrictEqual(await fs.readdir(lockPath), replacementEntries); + assert.strictEqual(await fs.pathExists(targetPath), true); + + await replacement.release(); + }); + + test('classifies a dead owner marker as stale using the liveness probe', async () => { + const lockPath = getFileLockPath(targetPath); + await fs.ensureDir(lockPath); + 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..ae2a1c54f 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -4,9 +4,16 @@ import * as typeMoq from 'typemoq'; 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 { createAnyEnvironmentCommand, removePythonProject, revealEnvInManagerView } from '../../features/envCommands'; +import * as windowApis from '../../common/window.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'; @@ -216,6 +223,123 @@ suite('Remove Python Project Command Tests', () => { }); }); +suite('Clear Script Environment Cache Command Tests', () => { + 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([]), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves(undefined); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.notCalled(clearCache); + sinon.assert.notCalled(removeInlineSettings); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); + + test('clears cache before inline settings cleanup and unloads removed projects', async () => { + const calls: string[] = []; + const inlineProject: PythonProject = { + uri: Uri.file('/workspace/script.py'), + name: 'script.py', + }; + const clearCache = sinon.stub().callsFake(async () => { + calls.push('clearCache'); + }); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().callsFake(() => { + calls.push('getProjects'); + return [inlineProject]; + }), + remove: sinon.stub().callsFake(() => { + calls.push('removeProjects'); + }), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + const removeInlineSettings = sinon + .stub(settingHelpers, 'removeInlineScriptPythonProjectSettings') + .callsFake(async (projects) => { + calls.push('removeInlineSettings'); + assert.deepStrictEqual(projects, [inlineProject]); + return [inlineProject]; + }); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.calledOnce(clearCache); + sinon.assert.calledOnce(removeInlineSettings); + sinon.assert.calledOnceWithExactly(projectManager.remove as sinon.SinonStub, [inlineProject]); + assert.deepStrictEqual(calls, ['clearCache', 'getProjects', 'removeInlineSettings', 'removeProjects']); + }); + + test('keeps loaded projects when inline settings cleanup leaves them configured', async () => { + const inlineProject: PythonProject = { + uri: Uri.file('/workspace/runner'), + name: 'runner', + }; + const clearCache = sinon.stub().resolves(); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([inlineProject]), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); + + await clearScriptEnvironmentCacheCommand(envManagers, projectManager); + + sinon.assert.calledOnce(clearCache); + sinon.assert.calledOnceWithExactly(removeInlineSettings, [inlineProject]); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); + + test('preserves project settings when cache cleanup reports a partial failure', async () => { + const clearCache = sinon.stub().rejects(new Error('one cache entry could not be deleted')); + const envManagers = { + getEnvironmentManager: sinon.stub().withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + supportsClearCache: () => true, + clearCache, + }), + } as unknown as EnvironmentManagers; + const projectManager = { + getProjects: sinon.stub().returns([]), + remove: sinon.stub(), + } as unknown as PythonProjectManager; + sinon.stub(windowApis, 'showWarningMessage').resolves('Clear Cache' as never); + const removeInlineSettings = sinon.stub(settingHelpers, 'removeInlineScriptPythonProjectSettings').resolves([]); + + await assert.rejects(clearScriptEnvironmentCacheCommand(envManagers, projectManager), /could not be deleted/); + + sinon.assert.calledOnce(clearCache); + sinon.assert.notCalled(removeInlineSettings); + sinon.assert.notCalled(projectManager.remove as sinon.SinonStub); + }); +}); + suite('Reveal Env In Manager View Command Tests', () => { let managerView: typeMoq.IMock; let executeCommandStub: sinon.SinonStub; diff --git a/src/test/features/envManagers.unit.test.ts b/src/test/features/envManagers.unit.test.ts index d642fa948..aafe89f27 100644 --- a/src/test/features/envManagers.unit.test.ts +++ b/src/test/features/envManagers.unit.test.ts @@ -336,3 +336,61 @@ suite('PythonEnvironmentManagers - refreshEnvironment', () => { await envManagers.refreshEnvironment(Uri.file('/unknown/path')); }); }); + +suite('PythonEnvironmentManagers - clearCache', () => { + let sandbox: sinon.SinonSandbox; + let envManagers: PythonEnvironmentManagers; + + setup(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(frameUtils, 'getCallingExtension').returns('ms-python.python'); + envManagers = new PythonEnvironmentManagers({ + get: sandbox.stub().returns(undefined), + getProjects: sandbox.stub().returns([]), + } as unknown as PythonProjectManager); + }); + + teardown(() => { + sandbox.restore(); + }); + + function registerManager(name: string, clearCache: sinon.SinonStub): void { + envManagers.registerEnvironmentManager( + { + name, + displayName: name, + preferredPackageManagerId: 'ms-python.python:pip', + get: sandbox.stub().resolves(undefined), + set: sandbox.stub().resolves(), + resolve: sandbox.stub().resolves(undefined), + refresh: sandbox.stub().resolves(), + getEnvironments: sandbox.stub().resolves([]), + clearCache, + onDidChangeEnvironments: sandbox.stub().returns({ dispose: () => {} }), + onDidChangeEnvironment: sandbox.stub().returns({ dispose: () => {} }), + } as any, + { extensionId: 'ms-python.python' }, + ); + } + + test('clears every existing manager when the inline preview manager is absent', async () => { + const systemClearCache = sandbox.stub().resolves(); + registerManager('system', systemClearCache); + + await envManagers.clearCache(undefined); + + sinon.assert.calledOnce(systemClearCache); + }); + + test('does not clear the preview inline manager through the generic command path', async () => { + const systemClearCache = sandbox.stub().resolves(); + const inlineClearCache = sandbox.stub().resolves(); + registerManager('system', systemClearCache); + registerManager('inline-script', inlineClearCache); + + await envManagers.clearCache(undefined); + + sinon.assert.calledOnce(systemClearCache); + sinon.assert.notCalled(inlineClearCache); + }); +}); diff --git a/src/test/features/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..f75bc5b3c 100644 --- a/src/test/features/settings/settingHelpers.unit.test.ts +++ b/src/test/features/settings/settingHelpers.unit.test.ts @@ -10,11 +10,13 @@ import * as workspaceApis from '../../../common/workspace.apis'; import { addPythonProjectSetting, migrateGlobalDefaultEnvManagerSetting, + removeInlineScriptPythonProjectSettings, + 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,546 @@ suite('Setting Helpers - Empty Path Migration', () => { }); }); +suite('Setting Helpers - Project Removal', () => { + const INLINE_MANAGER_ID = 'ms-python.python:inline-script'; + const VENV_MANAGER_ID = 'ms-python.python:venv'; + const PIP_MANAGER_ID = 'ms-python.python:pip'; + 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; + globalValue?: PythonProjectSettings[]; + workspaceValue?: PythonProjectSettings[]; + workspaceFolderValue?: PythonProjectSettings[]; + }): MockWorkspaceConfiguration { + const mockConfig = new MockWorkspaceConfiguration(); + const mergedProjects = [ + ...(options.globalValue ?? []), + ...(options.workspaceValue ?? []), + ...(options.workspaceFolderValue ?? []), + ]; + (mockConfig as any).get = (key: string, defaultValue?: T): T | undefined => + key === 'pythonProjects' ? (mergedProjects as unknown as T) : defaultValue; + (mockConfig as any).inspect = (key: string) => + key === 'pythonProjects' + ? { + globalValue: options.globalValue, + workspaceValue: options.workspaceValue, + workspaceFolderValue: options.workspaceFolderValue, + } + : 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), + }; + } + + suite('removePythonProjectSetting (bde7cf8-equivalent generic behavior)', () => { + test('rewrites the merged effective array back to workspace scope', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0], { + workspace: firstWorkspace.name, + key: 'pythonProjects', + value: [{ path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }], + target: ConfigurationTarget.Workspace, + }); + }); + + test('ignores envManager metadata and removes the first same-path entry', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + await removePythonProjectSetting([{ project, envManager: VENV_MANAGER_ID }]); + + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + }); + }); + + suite('removeInlineScriptPythonProjectSettings', () => { + test('removes all inline-script entries while preserving non-inline duplicates', async () => { + const project = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const otherProject = new PythonProjectsImpl('other.py', Uri.file(path.join(firstWorkspacePath, 'other.py'))); + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'other.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').returns(firstWorkspace); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removeInlineScriptPythonProjectSettings([project, otherProject]); + + assert.deepStrictEqual( + removedProjects.map((entry) => entry.uri.fsPath), + [otherProject.uri.fsPath], + 'Only projects left without any non-inline setting should be removed from memory', + ); + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.strictEqual(updateCalls[0].workspace, firstWorkspace.name); + assert.strictEqual(updateCalls[0].key, 'pythonProjects'); + assert.strictEqual(updateCalls[0].target, ConfigurationTarget.Workspace); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + }); + + test('removes inline-script settings even when the project is not loaded', async () => { + const config = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceValue: [ + { path: 'runner', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace]); + sinon.stub(workspaceApis, 'getConfiguration').returns(config); + + const removedProjects = await removeInlineScriptPythonProjectSettings([]); + + assert.deepStrictEqual(removedProjects, [], 'No loaded project should be returned for memory cleanup'); + assert.strictEqual(updateCalls.length, 1, 'Should update pythonProjects once'); + assert.deepStrictEqual(updateCalls[0].value, [ + { path: 'keep', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + }); + + test('removes only one of two roots that share the same relative path', async () => { + const firstProject = new PythonProjectsImpl('script.py', Uri.file(path.join(firstWorkspacePath, 'script.py'))); + const secondProject = new PythonProjectsImpl( + 'script.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'script.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + workspaceFolderValue: [ + { path: 'script.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + 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 removeInlineScriptPythonProjectSettings([project]); + + assert.deepStrictEqual(removedProjects, [], 'Folder override should keep the project configured'); + const workspaceUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.Workspace); + const folderUpdate = updateCalls.find((call) => call.target === ConfigurationTarget.WorkspaceFolder); + assert.ok(workspaceUpdate, 'WorkspaceValue source should be updated'); + assert.strictEqual(workspaceUpdate!.value, undefined); + assert.strictEqual(folderUpdate, undefined, 'Folder override should not be rewritten'); + }); + + test('aggregates shared workspaceValue removals across folders into one update', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl('second', Uri.file(path.join(secondWorkspaceUri.fsPath, 'second'))); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), []); + }); + + test('removes every inline shared entry without resurrecting non-inline siblings', async () => { + const firstProject = new PythonProjectsImpl('first', Uri.file(path.join(firstWorkspacePath, 'first'))); + const secondProject = new PythonProjectsImpl( + 'second', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'second')), + ); + const { firstConfig, secondConfig, getWorkspaceValue } = createSharedWorkspaceConfigs({ + workspaceValue: [ + { + path: 'first', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: firstWorkspace.name, + }, + { + path: 'second', + envManager: INLINE_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + { + path: 'keep', + envManager: VENV_MANAGER_ID, + packageManager: PIP_MANAGER_ID, + workspace: secondWorkspace.name, + }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [firstProject.uri.fsPath, secondProject.uri.fsPath].sort(), + ); + assert.strictEqual( + updateCalls.filter((call) => call.target === ConfigurationTarget.Workspace).length, + 1, + 'Shared workspaceValue should still be written once', + ); + assert.deepStrictEqual(getWorkspaceValue(), [ + { + 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 removeInlineScriptPythonProjectSettings([firstProject, secondProject]); + + 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', + ); + }); + + test('removes global inline entries once while preserving higher-precedence non-inline entries', async () => { + const globalProject = new PythonProjectsImpl( + 'global.py', + Uri.file(path.join(firstWorkspacePath, 'global.py')), + ); + const workspaceProject = new PythonProjectsImpl( + 'workspace.py', + Uri.file(path.join(firstWorkspacePath, 'workspace.py')), + ); + const folderProject = new PythonProjectsImpl( + 'folder.py', + Uri.file(path.join(secondWorkspaceUri.fsPath, 'folder.py')), + ); + const firstConfig = createProjectConfig({ + workspaceName: firstWorkspace.name, + globalValue: [ + { path: 'global.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceValue: [ + { path: 'workspace.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'global.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + const secondConfig = createProjectConfig({ + workspaceName: secondWorkspace.name, + globalValue: [ + { path: 'global.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + workspaceFolderValue: [ + { path: 'folder.py', envManager: INLINE_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ], + }); + sinon.stub(workspaceApis, 'getWorkspaceFolders').returns([firstWorkspace, secondWorkspace]); + sinon.stub(workspaceApis, 'getWorkspaceFolder').callsFake((uri) => + uri.fsPath.startsWith(secondWorkspaceUri.fsPath) ? secondWorkspace : firstWorkspace, + ); + sinon.stub(workspaceApis, 'getConfiguration').callsFake((_section?: string, scope?: unknown) => { + const uri = scope as Uri; + return uri.fsPath === secondWorkspaceUri.fsPath ? secondConfig : firstConfig; + }); + + const removedProjects = await removeInlineScriptPythonProjectSettings([ + globalProject, + workspaceProject, + folderProject, + ]); + + assert.deepStrictEqual( + removedProjects.map((project) => project.uri.fsPath).sort(), + [workspaceProject.uri.fsPath, folderProject.uri.fsPath].sort(), + 'The folder-level non-inline entry keeps the global project loaded', + ); + const globalUpdates = updateCalls.filter((call) => call.target === ConfigurationTarget.Global); + assert.strictEqual(globalUpdates.length, 1, 'Global settings should be updated exactly once'); + assert.deepStrictEqual(globalUpdates[0].value, [ + { path: 'keep.py', envManager: VENV_MANAGER_ID, packageManager: PIP_MANAGER_ID }, + ]); + assert.ok( + updateCalls.some( + (call) => + call.workspace === firstWorkspace.name && + call.target === ConfigurationTarget.Workspace && + call.value === undefined, + ), + 'Workspace-scoped inline entry should be removed at its source', + ); + assert.ok( + updateCalls.some( + (call) => + call.workspace === secondWorkspace.name && + call.target === ConfigurationTarget.WorkspaceFolder && + call.value === undefined, + ), + 'Folder-scoped inline entry should be removed at its source', + ); + }); + }); +}); + 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..e47ca99fe 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,424 @@ 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 () => { + lockStub.restore(); + 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 generation-specific retained lock and its corresponding cache entry', async () => { + lockStub.restore(); + const retainedCacheDir = envDir().fsPath; + await fs.outputFile(venvPythonPath(retainedCacheDir), ''); + const lock = await lockfileApis.acquireFileLock(retainedCacheDir, { + timeoutMs: 0, + retryIntervalMs: 1, + }); + await lock.retain(); + + await manager.clearCache(); + + assert.strictEqual(await fs.pathExists(retainedCacheDir), false); + assert.strictEqual(await fs.pathExists(lockfileApis.getFileLockPath(retainedCacheDir)), false); + }); + + test('refuses to clear a legacy retained lock conservatively', async () => { + lockStub.restore(); + const retainedCacheDir = envDir().fsPath; + const retainedLockPath = lockfileApis.getFileLockPath(retainedCacheDir); + await fs.outputFile(venvPythonPath(retainedCacheDir), ''); + await fs.ensureDir(retainedLockPath); + await fs.writeFile(path.join(retainedLockPath, 'retained'), ''); + + await assert.rejects(manager.clearCache(), /incomplete or malformed/); + + assert.strictEqual(await fs.pathExists(retainedCacheDir), true); + assert.strictEqual(await fs.pathExists(retainedLockPath), true); + }); + + test('clears a stale owner lock and its corresponding cache entry', async () => { + lockStub.restore(); + 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('does not delete an entry when another host acquires a new lock after stale lock reclamation', async () => { + lockStub.restore(); + const environment = await createOwnedEnvironment(); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + const quarantinedLockPath = `${lockPath}.reclaimed-for-test`; + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, 'owner-424242-dead'), ''); + sinon.stub(lockfileApis, 'inspectFileLock').onFirstCall().resolves('stale').onSecondCall().resolves('held'); + sinon.stub(lockfileApis, 'reclaimFileLock').callsFake(async () => { + await fs.rename(lockPath, quarantinedLockPath); + await fs.ensureDir(lockPath); + await fs.writeFile(path.join(lockPath, `owner-${process.pid}-live`), ''); + return true; + }); + + await assert.rejects(manager.clearCache(), /being created/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + assert.strictEqual(await fs.pathExists(lockPath), true); + }); + + test('holds the entry lock through deletion', async () => { + lockStub.restore(); + const environment = await createOwnedEnvironment(); + const lockPath = lockfileApis.getFileLockPath(environment.sysPrefix); + const internalManager = manager as unknown as { + deleteCacheEntryForClear(entryPath: string): Promise; + }; + const removeStub = sinon.stub(internalManager, 'deleteCacheEntryForClear').callThrough(); + removeStub.callsFake(async (target) => { + if (normalizePath(target) === normalizePath(environment.sysPrefix)) { + assert.strictEqual(await fs.pathExists(lockPath), true, 'entry lock must protect deletion'); + } + await fs.remove(target); + }); + + await manager.clearCache(); + + sinon.assert.calledWith(removeStub, environment.sysPrefix); + assert.strictEqual(await fs.pathExists(environment.sysPrefix), false); + }); + + test('rejects an orphaned lock directory conservatively', async () => { + lockStub.restore(); + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + 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('preserves associations and emits events only for entries removed before a partial failure', async () => { + const firstUri = scriptUri('first.py'); + const secondUri = scriptUri('second.py'); + const firstEnvironment = await createOwnedEnvironment(); + const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); + await manager.set(firstUri, firstEnvironment); + await manager.set(secondUri, secondEnvironment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + const internalManager = manager as unknown as { + deleteCacheEntryForClear(entryPath: string): Promise; + }; + sinon.stub(internalManager, 'deleteCacheEntryForClear').callsFake(async (target) => { + if (normalizePath(target) === normalizePath(secondEnvironment.sysPrefix)) { + throw new Error('second entry is busy'); + } + await fs.remove(target); + }); + + await assert.rejects(manager.clearCache(), /Failed to completely clear/); + + assert.strictEqual(await fs.pathExists(firstEnvironment.sysPrefix), false); + assert.strictEqual(await fs.pathExists(secondEnvironment.sysPrefix), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(secondUri.fsPath)]: secondEnvironment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(firstUri), undefined); + assert.strictEqual(await manager.get(secondUri), secondEnvironment); + sinon.assert.calledOnce(listener); + assert.strictEqual(normalizePath(listener.firstCall.args[0].uri.fsPath), normalizePath(firstUri.fsPath)); + assert.strictEqual(listener.firstCall.args[0].old, firstEnvironment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + }); + + test('stops before deletion when the physical cache root changes', async () => { + const environment = await createOwnedEnvironment(); + const otherPhysicalRoot = path.join(tempRoot, 'other-cache-root'); + await fs.ensureDir(otherPhysicalRoot); + const internalManager = manager as unknown as { + getPhysicalOwnedCacheRootPath(cacheRoot: Uri): Promise; + }; + const rootStub = sinon.stub(internalManager, 'getPhysicalOwnedCacheRootPath').callThrough(); + rootStub.onSecondCall().resolves(otherPhysicalRoot); + + await assert.rejects(manager.clearCache(), /physical root changed/); + + assert.strictEqual(await fs.pathExists(environment.sysPrefix), true); + }); + + test('does not let a pending rehydration restore an association after clear cache', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + 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..56d0b2944 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -113,6 +113,38 @@ suite('Smoke: Registration Checks', function () { ); }); + test('Internal inline clear command is not publicly contributed', function () { + const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); + + const contributedCommands = (extension.packageJSON?.contributes?.commands ?? []) as Array<{ command: string }>; + const commandPaletteEntries = (extension.packageJSON?.contributes?.menus?.commandPalette ?? []) as Array<{ + command: string; + }>; + + assert.ok( + !contributedCommands.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not be publicly contributed before rollout', + ); + assert.ok( + !commandPaletteEntries.some((entry) => entry.command === 'python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not appear in contributed menus before rollout', + ); + }); + + test('Internal inline clear command is not registered while the feature flag is off', async function () { + const allCommands = await vscode.commands.getCommands(true); + + assert.ok( + !allCommands.includes('python-envs.clearScriptEnvCache'), + 'python-envs.clearScriptEnvCache should not be registered by default', + ); + await assert.rejects( + () => Promise.resolve(vscode.commands.executeCommand('python-envs.clearScriptEnvCache')), + /not found/i, + ); + }); + // ========================================================================= // API METHODS - All API methods must exist and be functions // =========================================================================