diff --git a/package.json b/package.json index dd7cba3cf..fc37a6277 100644 --- a/package.json +++ b/package.json @@ -245,6 +245,13 @@ "category": "Python", "icon": "$(trash)" }, + { + "command": "python-envs.clearInlineScriptCache", + "title": "%python-envs.clearInlineScriptCache.title%", + "category": "Python", + "icon": "$(trash)", + "enablement": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" + }, { "command": "python-envs.runInTerminal", "title": "%python-envs.runInTerminal.title%", @@ -414,6 +421,10 @@ "command": "python-envs.runAsTask", "when": "config.python.useEnvironmentsExtension != false" }, + { + "command": "python-envs.clearInlineScriptCache", + "when": "config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true" + }, { "command": "python-envs.terminal.activate", "when": "pythonTerminalActivation" diff --git a/package.nls.json b/package.nls.json index 483ecfd29..538b5abb7 100644 --- a/package.nls.json +++ b/package.nls.json @@ -35,6 +35,7 @@ "python-envs.refreshPackages.title": "Refresh Packages List", "python-envs.packages.title": "Manage Packages", "python-envs.clearCache.title": "Clear Cache", + "python-envs.clearInlineScriptCache.title": "Clear Script Environment Cache", "python-envs.runInTerminal.title": "Run in Terminal", "python-envs.createTerminal.title": "Create Python Terminal", "python-envs.runAsTask.title": "Run as Task", diff --git a/src/extension.ts b/src/extension.ts index 1d2d98aee..c4755f09a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,7 +13,7 @@ import { PythonEnvironment, PythonEnvironmentApi, PythonProjectCreator } from '. import { ENVS_EXTENSION_ID } from './common/constants'; import { ensureCorrectVersion } from './common/extVersion'; import { registerLogger, traceError, traceInfo, traceWarn } from './common/logging'; -import { clearPersistentState, setPersistentState } from './common/persistentState'; +import { setPersistentState } from './common/persistentState'; import { newProjectSelection } from './common/pickers/managers'; import { StopWatch } from './common/stopWatch'; import { EventNames } from './common/telemetry/constants'; @@ -44,6 +44,8 @@ import { NewScriptProject } from './features/creators/newScriptProject'; import { ProjectCreatorsImpl } from './features/creators/projectCreators'; import { addPythonProjectCommand, + clearCacheCommand, + clearInlineScriptCacheCommand, copyPathToClipboard, createAnyEnvironmentCommand, createEnvironmentCommand, @@ -96,6 +98,7 @@ import { TemporaryStateManager } from './features/views/temporaryStateManager'; import { PythonEnvTreeItem } from './features/views/treeViewItems'; import { collectEnvironmentInfo, getEnvManagerAndPackageManagerConfigLevels, runPetInTerminalImpl } from './helpers'; import { EnvironmentManagers, ProjectCreators, PythonProjectManager } from './internal.api'; +import type { InlineScriptEnvManager } from './managers/builtin/inlineScript/envManager'; import { registerInlineScriptFeatures } from './managers/builtin/inlineScript/main'; import { registerSystemPythonFeatures } from './managers/builtin/main'; import { SysPythonManager } from './managers/builtin/sysPythonManager'; @@ -191,6 +194,7 @@ export async function activate(context: ExtensionContext): Promise { - await clearPersistentState(); - await envManagers.clearCache(undefined); - await clearShellProfileCache(shellStartupProviders); + await clearCacheCommand(envManagers, () => clearShellProfileCache(shellStartupProviders)); + }), + commands.registerCommand('python-envs.clearInlineScriptCache', async () => { + await clearInlineScriptCacheCommand(() => inlineScriptEnvManager); }), commands.registerCommand('python-envs.runInTerminal', (item) => { return runInTerminalCommand(item, api, terminalManager); @@ -651,13 +656,15 @@ export async function activate(context: ExtensionContext): Promise { + inlineScriptEnvManager = await registerInlineScriptFeatures( + nativeFinder, + context.subscriptions, + outputChannel, + sysMgr, + context.globalStorageUri, + ); + })(), ), safeRegister('shellStartupVars', shellStartupVarsMgr.initialize()), ]); diff --git a/src/features/envCommands.ts b/src/features/envCommands.ts index 1de8a13a6..d5c0ef657 100644 --- a/src/features/envCommands.ts +++ b/src/features/envCommands.ts @@ -18,7 +18,10 @@ import { PythonProjectCreator, PythonProjectCreatorOptions, } from '../api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../common/constants'; import { traceError, traceInfo, traceVerbose } from '../common/logging'; +import { clearPersistentState } from '../common/persistentState'; +import type { InlineScriptEnvManager } from '../managers/builtin/inlineScript/envManager'; import { EnvironmentManagers, InternalEnvironmentManager, @@ -26,6 +29,8 @@ import { ProjectCreators, PythonProjectManager, } from '../internal.api'; +import { isInlineScriptsFeatureEnabled } from '../helpers'; +import { waitForEnvManagerId } from './common/managerReady'; import { removePythonProjectSetting, setEnvironmentManager, setPackageManager } from './settings/settingHelpers'; import { valid as pep440Valid } from '@renovatebot/pep440'; @@ -50,6 +55,7 @@ import { showInputBox, showOpenDialog, showQuickPick, + showWarningMessage, withProgress, } from '../common/window.apis'; import { runAsTask } from './execution/runAsTask'; @@ -306,6 +312,50 @@ export async function removeEnvironmentCommand(context: unknown, managers: Envir } } +export async function clearCacheCommand( + envManagers: EnvironmentManagers, + clearShellProfileCache: () => Promise, +): Promise { + await clearPersistentState(); + await envManagers.clearCache(undefined); + await clearShellProfileCache(); +} + +export async function clearInlineScriptCacheCommand( + getManager: () => InlineScriptEnvManager | undefined | Promise, +): Promise { + if (!isInlineScriptsFeatureEnabled()) { + const message = l10n.t( + 'Script environment cache is unavailable because inline script environments are disabled in this window.', + ); + showErrorMessage(message); + throw new Error(message); + } + + await waitForEnvManagerId([INLINE_SCRIPT_MANAGER_ID]); + const manager = await getManager(); + if (!manager) { + const message = l10n.t( + 'Script environment cache is unavailable because the inline script environment manager is not available in this window.', + ); + showErrorMessage(message); + throw new Error(message); + } + + const clearLabel = l10n.t('Clear Cache'); + const confirm = await showWarningMessage( + l10n.t('Delete cached environments created for inline Python scripts?'), + { modal: true }, + clearLabel, + l10n.t('Cancel'), + ); + if (confirm !== clearLabel) { + return; + } + + await manager.clearScriptCache(); +} + export async function handlePackageUninstall(context: unknown, em: EnvironmentManagers) { if (context instanceof PackageTreeItem || context instanceof ProjectPackage) { if (context.pkg.isTransitive) { diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index d68d9dda6..acfb64568 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -24,6 +24,7 @@ import { getErrorMessage } from '../../../common/errors/utils'; import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey'; import { CacheEnvironmentInspection, + INLINE_SCRIPT_CACHE_DIR_NAME, META_SCHEMA_VERSION, getBaseInterpreterStatus, getScriptEnvCacheRoot, @@ -59,8 +60,10 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([ PYENV_MANAGER_ID, ]); -const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000; -const CACHE_LOCK_RETRY_MS = 500; +const CACHE_CLEAR_ROOT_LOCK_TIMEOUT_MS = 1_000; +const CACHE_CLEAR_ROOT_LOCK_RETRY_MS = 50; +const CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS = 1_000; +const CACHE_CREATE_HANDOFF_LOCK_RETRY_MS = 50; const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000; /** Workspace-state key for PEP 723 script path to environment executable associations. */ export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`; @@ -86,6 +89,8 @@ type CacheEntryInspection = | { readonly kind: 'absent' | 'stale' | 'uncertain' } | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; +type CacheLockDisposition = 'retained' | 'active' | 'unknown'; + /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingSetups = new Map>(); @@ -99,6 +104,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly associationRevisions = new Map(); private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); + private activeCreateCount = 0; + private isClearCacheInProgress = false; private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -129,6 +136,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scope: CreateEnvironmentScope, options?: CreateEnvironmentOptions, ): Promise { + if (this.isClearCacheInProgress) { + throw this.createCacheOperationConflict( + l10n.t( + 'Cannot create an inline script environment while the script environment cache is being cleared. Retry after the cache clear finishes.', + ), + ); + } + this.activeCreateCount += 1; try { const scriptUri = this.getScriptUri(scope); if (!scriptUri) { @@ -167,8 +182,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } } } catch (error) { + if (error instanceof InlineScriptCacheOperationError) { + this.log.warn(error.message); + throw error; + } this.log.error(`Failed to set up inline-script environment: ${getErrorMessage(error)}`); return undefined; + } finally { + this.activeCreateCount -= 1; } } @@ -247,6 +268,64 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } + async clearScriptCache(): Promise { + if (this.isClearCacheInProgress) { + throw this.createCacheOperationConflict( + l10n.t('Script environment cache clear is already in progress.'), + ); + } + this.isClearCacheInProgress = true; + + try { + if (this.activeCreateCount > 0) { + throw this.createCacheOperationConflict( + l10n.t( + 'Cannot clear the script environment cache while another inline script environment operation may still be using it. Close other VS Code windows or restart VS Code, then retry.', + ), + ); + } + + const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); + let rootLock: AcquiredFileLock | undefined = await this.acquireCacheRootLock(cacheRoot, { + timeoutMs: CACHE_CLEAR_ROOT_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_CLEAR_ROOT_LOCK_RETRY_MS, + }, 'clear'); + try { + const clearableCacheRoot = await this.getClearableCacheRootPath(cacheRoot); + if (clearableCacheRoot) { + await this.assertNoCacheLocks(clearableCacheRoot); + await this.removeClearableCacheRoot(clearableCacheRoot); + } + + let persistError: unknown; + try { + await this.clearPersistedAssociations(); + } catch (error) { + persistError = error; + } + + this.clearKnownAssociations(); + + if (persistError) { + throw persistError; + } + } finally { + const lockToRelease = rootLock; + rootLock = undefined; + await this.releaseCacheRootLockOrThrow(lockToRelease, cacheRoot.fsPath); + } + } catch (error) { + if (error instanceof InlineScriptCacheOperationError) { + this.log.warn(error.message); + } else { + this.log.error(`Failed to clear inline-script cache: ${getErrorMessage(error)}`); + } + throw error; + } finally { + this.isClearCacheInProgress = false; + } + } + private getScriptUri(scope: CreateEnvironmentScope): Uri | undefined { const uri = scope instanceof Uri ? scope : Array.isArray(scope) && scope.length === 1 ? scope[0] : undefined; return uri?.scheme === 'file' ? uri : undefined; @@ -741,6 +820,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }); } + private clearPersistedAssociations(): Promise { + return this.enqueuePersistence((state) => state.clear([INLINE_SCRIPT_ENVS_KEY])); + } + private asPersistedAssociations(value: unknown): PersistedInlineScriptEnvironments | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; @@ -769,6 +852,232 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } + private clearKnownAssociations(): void { + const cleared = [...this.fsPathToEnv.entries()].map(([scriptPath, old]) => ({ + uri: Uri.file(scriptPath), + old, + new: undefined as PythonEnvironment | undefined, + })); + const knownScriptPaths = new Set([ + ...this.associationRevisions.keys(), + ...this.pendingRehydrations.keys(), + ...this.fsPathToPersistedEnvPath.keys(), + ...this.fsPathToEnv.keys(), + ]); + for (const scriptPath of knownScriptPaths) { + this.bumpAssociationRevision(scriptPath); + this.pendingRehydrations.delete(scriptPath); + } + this.fsPathToEnv.clear(); + this.fsPathToPersistedEnvPath.clear(); + this.cachedAssociationValidatedAt.clear(); + + cleared.forEach((event) => this._onDidChangeEnvironment.fire(event)); + } + + private async getClearableCacheRootPath(cacheRoot: Uri): Promise { + const globalStoragePath = path.resolve(this.globalStorageUri.fsPath); + let globalStorageStat: fs.Stats; + try { + globalStorageStat = await fs.lstat(globalStoragePath); + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + if (!globalStorageStat.isDirectory() || globalStorageStat.isSymbolicLink()) { + throw this.createUnsafeClearTargetError(globalStoragePath); + } + + const resolvedGlobalStorage = await fs.realpath(globalStoragePath); + if (normalizePath(resolvedGlobalStorage) !== normalizePath(globalStoragePath)) { + throw this.createUnsafeClearTargetError(globalStoragePath); + } + + const cacheRootPath = path.resolve(cacheRoot.fsPath); + try { + const cacheRootStat = await fs.lstat(cacheRootPath); + if (!cacheRootStat.isDirectory() || cacheRootStat.isSymbolicLink()) { + throw this.createUnsafeClearTargetError(cacheRootPath); + } + } catch (error) { + if (isFileNotFoundError(error)) { + return undefined; + } + throw error; + } + + const resolvedCacheRoot = await resolveCacheEntryPath(Uri.file(globalStoragePath), Uri.file(cacheRootPath)); + const expectedCacheRoot = path.join(resolvedGlobalStorage, INLINE_SCRIPT_CACHE_DIR_NAME); + if (!resolvedCacheRoot || normalizePath(resolvedCacheRoot) !== normalizePath(expectedCacheRoot)) { + throw this.createUnsafeClearTargetError(cacheRootPath); + } + + return resolvedCacheRoot; + } + + private async acquireCacheRootLock( + cacheRoot: Uri, + options: { + timeoutMs: number; + retryIntervalMs: number; + }, + operation: 'create' | 'clear', + ): Promise { + await fs.ensureDir(path.dirname(cacheRoot.fsPath)); + const lockPath = this.getLockPath(cacheRoot.fsPath); + try { + return await acquireFileLock(cacheRoot.fsPath, options); + } catch (error) { + if (this.isBusyLockError(error)) { + throw this.createCacheRootBusyError(operation, lockPath); + } + throw error; + } + } + + private async assertNoCacheLocks(cacheRootPath: string): Promise { + let entries: string[]; + try { + entries = await fs.readdir(cacheRootPath); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + throw error; + } + + for (const entry of entries.filter((candidate) => candidate.endsWith('.lock'))) { + const lockPath = path.join(cacheRootPath, entry); + const lockDisposition = await this.inspectCacheLock(lockPath); + if (lockDisposition === 'active') { + throw this.createActiveLockError(lockPath); + } + if (lockDisposition === 'unknown') { + throw this.createUnknownLockError(lockPath); + } + } + } + + private removeClearableCacheRoot(cacheRootPath: string): Promise { + return fs.remove(cacheRootPath); + } + + private async inspectCacheLock(lockPath: string): Promise { + try { + const lockStat = await fs.lstat(lockPath); + if (!lockStat.isDirectory() || lockStat.isSymbolicLink()) { + return 'unknown'; + } + } catch { + return 'unknown'; + } + + const retainedPath = path.join(lockPath, 'retained'); + try { + const retainedStat = await fs.lstat(retainedPath); + if (retainedStat.isFile()) { + return 'retained'; + } + return 'unknown'; + } catch (error) { + if (!isFileNotFoundError(error)) { + return 'unknown'; + } + } + + try { + return (await fs.readdir(lockPath)).some((entry) => entry.startsWith('owner-')) ? 'active' : 'unknown'; + } catch { + return 'unknown'; + } + } + + private createUnsafeClearTargetError(targetPath: string): Error { + return new Error( + l10n.t( + 'Cannot clear the script environment cache because the target could not be proven safe: {0}', + targetPath, + ), + ); + } + + private createCacheOperationConflict(message: string): InlineScriptCacheOperationError { + return new InlineScriptCacheOperationError(message); + } + + private createCacheRootBusyError(operation: 'create' | 'clear', lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + operation === 'clear' + ? l10n.t( + 'Cannot clear the script environment cache because the cache root lock at {0} may still be active or may have been left by an interrupted operation. Wait for current work to finish or close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', + lockPath, + ) + : l10n.t( + 'Inline script environment cache is busy because the cache root lock at {0} may still be active or may have been left by an interrupted operation. Wait for current work to finish or close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', + lockPath, + ), + ); + } + + private createActiveLockError(lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + l10n.t( + 'Cannot clear the script environment cache because the owner-only lock at {0} may still be active or may have been left by an interrupted operation. Close other VS Code windows and retry. If it persists after restart, manually remove only this lock path after confirming that no inline script cache operation is using it.', + lockPath, + ), + ); + } + + private createUnknownLockError(lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + l10n.t( + 'Cannot clear the script environment cache because the cache lock at {0} could not be verified as retained. Remove it manually only if you know no inline script environment operation still needs it.', + lockPath, + ), + ); + } + + private createCacheRootReleaseError(lockPath: string): InlineScriptCacheOperationError { + return this.createCacheOperationConflict( + l10n.t( + 'Failed to release the script environment cache root lock at {0}. Close other VS Code windows and retry. If it persists after restart and no inline script cache operation is using it, remove only this lock path manually.', + lockPath, + ), + ); + } + + private async releaseCacheRootLockOrThrow(lock: AcquiredFileLock, cacheRootPath: string): Promise { + const lockPath = this.getLockPath(cacheRootPath); + try { + await lock.release(); + } catch { + throw this.createCacheRootReleaseError(lockPath); + } + } + + private async releaseCacheLock(lock: AcquiredFileLock, label: string): Promise { + try { + await lock.release(); + } catch (error) { + this.log.warn(`Failed to release ${label} lock: ${getErrorMessage(error)}`); + } + } + + private isBusyLockError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + ['ELOCKED', 'ELOCKRETAINED'].includes((error as NodeJS.ErrnoException).code ?? '') + ); + } + + private getLockPath(targetPath: string): string { + return `${path.resolve(targetPath)}.lock`; + } + private async isCacheEntryBusy(envDirPath: string): Promise { return ( this.pendingCreations.has(path.basename(envDirPath)) || @@ -1060,14 +1369,22 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }: CreateOrReuseEnvironmentOptions): Promise { const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); - await fs.ensureDir(cacheRoot.fsPath); + let rootLock: AcquiredFileLock | undefined; let lock: AcquiredFileLock | undefined; try { + rootLock = await this.acquireCacheRootLock(cacheRoot, { + timeoutMs: CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_CREATE_HANDOFF_LOCK_RETRY_MS, + }, 'create'); + await fs.ensureDir(cacheRoot.fsPath); lock = await acquireFileLock(envDir.fsPath, { - timeoutMs: CACHE_LOCK_TIMEOUT_MS, - retryIntervalMs: CACHE_LOCK_RETRY_MS, + timeoutMs: CACHE_CREATE_HANDOFF_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_CREATE_HANDOFF_LOCK_RETRY_MS, }); + const handoffRootLock = rootLock; + rootLock = undefined; + await this.releaseCacheRootLockOrThrow(handoffRootLock, cacheRoot.fsPath); const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase); if (cached.kind === 'reusable') { @@ -1097,15 +1414,20 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } return build.environment; } catch (error) { + if (error instanceof InlineScriptCacheOperationError) { + this.log.warn(error.message); + return undefined; + } this.log.error(`Failed to create or reuse inline-script cache entry: ${getErrorMessage(error)}`); return undefined; } finally { if (lock) { - try { - await lock.release(); - } catch (error) { - this.log.warn(`Failed to release inline-script cache lock: ${getErrorMessage(error)}`); - } + await this.releaseCacheLock(lock, 'inline-script cache entry'); + } + if (rootLock) { + const lockToRelease = rootLock; + rootLock = undefined; + await this.releaseCacheRootLockOrThrow(lockToRelease, cacheRoot.fsPath); } } } @@ -1306,3 +1628,5 @@ interface PendingScriptUpdate extends ScriptReference { readonly needsPersistence: boolean; readonly shouldNotify: boolean; } + +class InlineScriptCacheOperationError extends Error {} diff --git a/src/managers/builtin/inlineScript/main.ts b/src/managers/builtin/inlineScript/main.ts index 8c35fc6ed..94531313f 100644 --- a/src/managers/builtin/inlineScript/main.ts +++ b/src/managers/builtin/inlineScript/main.ts @@ -20,14 +20,15 @@ export async function registerInlineScriptFeatures( log: LogOutputChannel, baseManager: EnvironmentManager, globalStorageUri: Uri, -): Promise { +): Promise { if (!isInlineScriptsFeatureEnabled()) { traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); - return; + return undefined; } const api: PythonEnvironmentApi = await getPythonApi(); const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); disposables.push(mgr, api.registerEnvironmentManager(mgr)); traceInfo('Inline-script env manager: registered (internal flag is on)'); + return mgr; } diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 079ca9d73..6fd6229d6 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -6,8 +6,19 @@ import { PythonEnvironment, PythonProject } from '../../api'; import * as commandApi from '../../common/command.api'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; -import { createAnyEnvironmentCommand, removePythonProject, revealEnvInManagerView } from '../../features/envCommands'; +import * as persistentState from '../../common/persistentState'; +import * as windowApis from '../../common/window.apis'; +import { + clearCacheCommand, + clearInlineScriptCacheCommand, + createAnyEnvironmentCommand, + removePythonProject, + revealEnvInManagerView, +} from '../../features/envCommands'; +import * as managerReady from '../../features/common/managerReady'; import * as settingHelpers from '../../features/settings/settingHelpers'; +import * as helpers from '../../helpers'; +import type { InlineScriptEnvManager } from '../../managers/builtin/inlineScript/envManager'; import { EnvManagerView } from '../../features/views/envManagersView'; import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; import { EnvironmentManagers, InternalEnvironmentManager, PythonProjectManager } from '../../internal.api'; @@ -216,6 +227,112 @@ suite('Remove Python Project Command Tests', () => { }); }); +suite('Clear Cache Command Tests', () => { + teardown(() => { + sinon.restore(); + }); + + test('keeps the broad clear handler on the base path', async () => { + const calls: string[] = []; + const envManagers = { + clearCache: sinon.stub().callsFake(async (scope: unknown) => { + calls.push(`managers:${String(scope)}`); + }), + } as unknown as EnvironmentManagers; + const clearShellProfileCache = sinon.stub().callsFake(async () => { + calls.push('shell'); + }); + sinon.stub(persistentState, 'clearPersistentState').callsFake(async () => { + calls.push('state'); + }); + + await clearCacheCommand(envManagers, clearShellProfileCache); + + assert.deepStrictEqual(calls, ['state', 'managers:undefined', 'shell']); + assert.ok((envManagers.clearCache as sinon.SinonStub).calledOnceWithExactly(undefined)); + assert.ok(clearShellProfileCache.calledOnce); + }); +}); + +suite('Clear Inline Script Environment Cache Command Tests', () => { + let clearScriptCacheStub: sinon.SinonStub; + let getManager: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let showWarningMessageStub: sinon.SinonStub; + let isInlineScriptsFeatureEnabledStub: sinon.SinonStub; + let waitForEnvManagerIdStub: sinon.SinonStub; + + setup(() => { + clearScriptCacheStub = sinon.stub().resolves(); + getManager = sinon + .stub<[], InlineScriptEnvManager | undefined>() + .returns({ clearScriptCache: clearScriptCacheStub } as unknown as InlineScriptEnvManager); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage'); + showWarningMessageStub = sinon.stub(windowApis, 'showWarningMessage'); + isInlineScriptsFeatureEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled').returns(true); + waitForEnvManagerIdStub = sinon.stub(managerReady, 'waitForEnvManagerId').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('clears the cache after confirmation', async () => { + showWarningMessageStub.callsFake(async (_message, _options, clearLabel: string) => clearLabel); + + await clearInlineScriptCacheCommand(getManager); + + assert.ok(showWarningMessageStub.calledOnce); + assert.deepStrictEqual(showWarningMessageStub.firstCall.args[1], { modal: true }); + assert.ok(waitForEnvManagerIdStub.calledOnce); + assert.ok(getManager.calledOnce); + assert.ok(clearScriptCacheStub.calledOnce); + assert.strictEqual(showErrorMessageStub.called, false); + }); + + test('does nothing when the confirmation is cancelled', async () => { + showWarningMessageStub.resolves(undefined); + + await clearInlineScriptCacheCommand(getManager); + + assert.ok(showWarningMessageStub.calledOnce); + assert.ok(waitForEnvManagerIdStub.calledOnce); + assert.ok(getManager.calledOnce); + assert.strictEqual(clearScriptCacheStub.called, false); + assert.strictEqual(showErrorMessageStub.called, false); + }); + + test('fails fast when the feature setting is off', async () => { + isInlineScriptsFeatureEnabledStub.returns(false); + showErrorMessageStub.resolves(undefined); + + await assert.rejects( + clearInlineScriptCacheCommand(getManager), + /inline script environments are disabled in this window/i, + ); + + assert.ok(showErrorMessageStub.calledOnce); + assert.strictEqual(waitForEnvManagerIdStub.called, false); + assert.strictEqual(getManager.called, false); + assert.strictEqual(showWarningMessageStub.called, false); + }); + + test('throws a clear error when the manager is unavailable after the readiness wait', async () => { + getManager.returns(undefined); + showErrorMessageStub.resolves(undefined); + + await assert.rejects( + clearInlineScriptCacheCommand(getManager), + /inline script environment manager is not available in this window/i, + ); + + assert.ok(waitForEnvManagerIdStub.calledOnce); + assert.ok(getManager.calledOnce); + assert.ok(showErrorMessageStub.calledOnce); + assert.strictEqual(showWarningMessageStub.called, false); + }); +}); + suite('Reveal Env In Manager View Command Tests', () => { let managerView: typeMoq.IMock; let executeCommandStub: sinon.SinonStub; diff --git a/src/test/features/envManagers.unit.test.ts b/src/test/features/envManagers.unit.test.ts index d642fa948..42a64579e 100644 --- a/src/test/features/envManagers.unit.test.ts +++ b/src/test/features/envManagers.unit.test.ts @@ -7,6 +7,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { Uri } from 'vscode'; import { PythonEnvironment } from '../../api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; import * as frameUtils from '../../common/utils/frameUtils'; import * as workspaceApis from '../../common/workspace.apis'; import { PythonEnvironmentManagers } from '../../features/envManagers'; @@ -336,3 +337,70 @@ suite('PythonEnvironmentManagers - refreshEnvironment', () => { await envManagers.refreshEnvironment(Uri.file('/unknown/path')); }); }); + +suite('PythonEnvironmentManagers - clearCache', () => { + let sandbox: sinon.SinonSandbox; + let envManagers: PythonEnvironmentManagers; + let mockProjectManager: sinon.SinonStubbedInstance; + + setup(() => { + sandbox = sinon.createSandbox(); + sandbox.stub(frameUtils, 'getCallingExtension').returns('ms-python.python'); + sandbox.stub(workspaceApis, 'getConfiguration').returns({ + get: (key: string, defaultValue?: unknown) => { + if (key === 'defaultEnvManager') { + return 'ms-python.python:system'; + } + if (key === 'pythonProjects') { + return []; + } + return defaultValue; + }, + has: () => false, + inspect: () => undefined, + update: () => Promise.resolve(), + } as any); + + mockProjectManager = { + getProjects: sandbox.stub().returns([]), + get: sandbox.stub().returns(undefined), + } as unknown as sinon.SinonStubbedInstance; + + envManagers = new PythonEnvironmentManagers(mockProjectManager as unknown as PythonProjectManager); + }); + + teardown(() => { + sandbox.restore(); + }); + + function registerFakeManager(managerId: string, clearCache: sinon.SinonStub): void { + envManagers.registerEnvironmentManager( + { + name: managerId.split(':')[1], + displayName: managerId, + preferredPackageManagerId: 'ms-python.python:pip', + clearCache, + get: sandbox.stub().resolves(undefined), + set: sandbox.stub().resolves(), + resolve: sandbox.stub().resolves(undefined), + refresh: sandbox.stub().resolves(), + getEnvironments: sandbox.stub().resolves([]), + onDidChangeEnvironments: sandbox.stub().returns({ dispose: () => {} }), + onDidChangeEnvironment: sandbox.stub().returns({ dispose: () => {} }), + } as any, + { extensionId: 'ms-python.python' }, + ); + } + + test('does not special-case managers during broad cache clears', async () => { + const systemClearCache = sandbox.stub().resolves(); + const inlineClearCache = sandbox.stub().resolves(); + registerFakeManager('ms-python.python:system', systemClearCache); + registerFakeManager(INLINE_SCRIPT_MANAGER_ID, inlineClearCache); + + await envManagers.clearCache(undefined); + + assert.ok(systemClearCache.calledOnce); + assert.ok(inlineClearCache.calledOnce); + }); +}); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 3d0488cae..7508d3739 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -100,6 +100,7 @@ suite('InlineScriptEnvManager', () => { let ensureUvForVersionLookupStub: sinon.SinonStub; let globalStorageUri: Uri; let lockStub: sinon.SinonStub; + let log: LogOutputChannel; let manager: InlineScriptEnvManager; let nativeFinder: NativePythonFinder; let promptInstallPythonViaUvStub: sinon.SinonStub; @@ -144,7 +145,11 @@ suite('InlineScriptEnvManager', () => { persistedAssociations = value; } }), - clear: sinon.stub(), + clear: sinon.stub().callsFake(async (keys?: string[]) => { + if (!keys || keys.includes(INLINE_SCRIPT_ENVS_KEY)) { + persistedAssociations = undefined; + } + }), }; sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(workspaceState); @@ -180,7 +185,8 @@ suite('InlineScriptEnvManager', () => { }); clock = sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }); - manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); + log = makeFakeLog(); + manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); }); teardown(async () => { @@ -197,6 +203,10 @@ suite('InlineScriptEnvManager', () => { return cacheLayout.getScriptEnvDir(globalStorageUri, CACHE_KEY); } + function cacheRoot(): Uri { + return cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + } + function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta): void { inspectMetaStub.resolves({ kind: 'valid', metadata }); } @@ -232,6 +242,7 @@ suite('InlineScriptEnvManager', () => { test('exposes creation but leaves later-phase methods empty', async () => { const asInterface: EnvironmentManager = manager; assert.strictEqual(typeof asInterface.create, 'function'); + assert.strictEqual(asInterface.clearCache, undefined); assert.strictEqual(asInterface.remove, undefined); assert.strictEqual(asInterface.quickCreateConfig, undefined); assert.deepStrictEqual(await manager.getEnvironments('all'), []); @@ -968,16 +979,55 @@ suite('InlineScriptEnvManager', () => { false, 'inline-script cache entries must not be tracked as workspace uv environments', ); - assert.ok(releaseLockStub.calledOnce); - }); + assert.strictEqual(lockStub.callCount, 2); + assert.strictEqual(lockStub.firstCall.args[0], cacheRoot().fsPath); + assert.strictEqual(lockStub.secondCall.args[0], envDir().fsPath); + assert.strictEqual(releaseLockStub.callCount, 2); + }); + + test('acquires the cache root lock before the final cache-entry lock and releases root before build', async () => { + const rootRelease = sinon.stub().resolves(); + const entryRelease = sinon.stub().resolves(); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + return { + retain: sinon.stub().resolves(), + release: rootRelease, + }; + } + return { + retain: sinon.stub().resolves(), + release: entryRelease, + }; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + assert.ok(rootRelease.calledOnce, 'root lock should be released before build starts'); + assert.strictEqual(entryRelease.called, false, 'entry lock should remain held during build'); + const envDir = args[6] as string; + const selectedBase = args[4] as PythonEnvironment; + await fs.outputFile(getVenvPythonPath(envDir), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + selectedBase.version, + getVenvPythonPath(envDir), + envDir, + ), + }; + }); - test('uses a bounded cross-process lock at the final cache path', async () => { await manager.create(scriptUri()); - assert.strictEqual(lockStub.firstCall.args[0], envDir().fsPath); - const options = lockStub.firstCall.args[1]; - assert.ok(options.timeoutMs > 0); - assert.ok(options.retryIntervalMs > 0); + assert.strictEqual(lockStub.firstCall.args[0], cacheRoot().fsPath); + assert.strictEqual(lockStub.secondCall.args[0], envDir().fsPath); + const rootOptions = lockStub.firstCall.args[1]; + const entryOptions = lockStub.secondCall.args[1]; + assert.strictEqual(rootOptions.timeoutMs, 1_000); + assert.strictEqual(rootOptions.retryIntervalMs, 50); + assert.strictEqual(entryOptions.timeoutMs, 1_000); + assert.strictEqual(entryOptions.retryIntervalMs, 50); + assert.ok(rootRelease.calledOnce); + assert.ok(entryRelease.calledOnce); }); test('coalesces simultaneous same-key creation within one extension host', async () => { @@ -1022,14 +1072,110 @@ suite('InlineScriptEnvManager', () => { const [firstResult, secondResult] = await Promise.all([first, second]); assert.strictEqual(firstResult, secondResult); - assert.strictEqual(lockStub.callCount, 1); + assert.strictEqual(lockStub.callCount, 2); assert.strictEqual(createWithProgressStub.callCount, 1); }); - test('returns undefined without building when the cache lock cannot be acquired', async () => { + test('returns undefined without building when the cache root lock cannot be acquired', async () => { + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; lockStub.rejects(Object.assign(new Error('already locked'), { code: 'ELOCKED' })); assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(createWithProgressStub.callCount, 0); + sinon.assert.calledWithMatch( + log.warn as sinon.SinonStub, + sinon.match(new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i')), + ); + }); + + test('aborts before inspect/build when releasing the cache root lock for handoff fails', async () => { + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; + const rootRelease = sinon.stub().callsFake(async () => { + await fs.ensureDir(rootLockPath); + throw new Error('root release failed'); + }); + const entryRelease = sinon.stub().resolves(); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + return { + retain: sinon.stub().resolves(), + release: rootRelease, + }; + } + return { + retain: sinon.stub().resolves(), + release: entryRelease, + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.ok(rootRelease.calledOnce); + assert.ok(entryRelease.calledOnce); + assert.strictEqual(await fs.pathExists(rootLockPath), true); + sinon.assert.calledWithMatch( + log.warn as sinon.SinonStub, + sinon.match(new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i')), + ); + }); + + test('allows different cache entries to build concurrently after the root-to-entry handoff', async () => { + const secondCacheKey = 'fedcba9876543210'; + const secondEnvDir = cacheLayout.getScriptEnvDir(globalStorageUri, secondCacheKey); + computeCacheKeyStub.onFirstCall().returns(CACHE_KEY); + computeCacheKeyStub.onSecondCall().returns(secondCacheKey); + + let releaseFirstBuild: (() => void) | undefined; + const firstBuildGate = new Promise((resolve) => { + releaseFirstBuild = resolve; + }); + const secondBuildStarted = sinon.stub(); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + if (target === envDir().fsPath) { + await firstBuildGate; + } else if (target === secondEnvDir.fsPath) { + secondBuildStarted(); + } + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + }; + }); + + const first = manager.create(scriptUri('first.py')); + let second: Promise | undefined; + try { + await waitForStubCall(createWithProgressStub); + second = manager.create(scriptUri('second.py')); + await waitForStubCall(secondBuildStarted); + assert.ok(secondBuildStarted.calledOnce); + assert.strictEqual(createWithProgressStub.callCount, 2); + } finally { + releaseFirstBuild?.(); + await Promise.allSettled([first, second ?? Promise.resolve(undefined)]); + } + }); + + test('releases the cache root lock when the per-entry lock cannot be acquired', async () => { + const rootRelease = sinon.stub().resolves(); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + return { + retain: sinon.stub().resolves(), + release: rootRelease, + }; + } + throw Object.assign(new Error('entry locked'), { code: 'ELOCKED' }); + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.ok(rootRelease.calledOnce); }); }); @@ -1362,7 +1508,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await fs.pathExists(envDir().fsPath), true); assert.strictEqual(writeMetaStub.callCount, 0); assert.ok(retainLockStub.calledOnce); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('keeps a failed lock-retain transition fail-closed', async () => { @@ -1380,7 +1526,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.ok(retainLockStub.calledOnce); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('removes the partial environment when package installation fails', async () => { @@ -1401,7 +1547,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); assert.strictEqual(writeMetaStub.callCount, 0); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('removes the new environment when sidecar writing fails', async () => { @@ -1409,7 +1555,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('removes a partial environment when createWithProgress throws', async () => { @@ -1420,7 +1566,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.create(scriptUri()), undefined); assert.strictEqual(await fs.pathExists(envDir().fsPath), false); - assert.ok(releaseLockStub.calledOnce); + assert.strictEqual(releaseLockStub.callCount, 2); }); test('rejects and removes a created environment with a different Python release', async () => { @@ -1465,6 +1611,335 @@ suite('InlineScriptEnvManager', () => { }); }); + suite('clear cache', () => { + test('treats a missing cache root as idempotent and clears persisted associations', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set(uri, environment); + listener.resetHistory(); + await fs.remove(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath); + + await manager.clearScriptCache(); + await manager.clearScriptCache(); + + assert.strictEqual(workspaceState.clear.callCount, 2); + assert.deepStrictEqual(workspaceState.clear.firstCall.args[0], [INLINE_SCRIPT_ENVS_KEY]); + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(uri), undefined); + sinon.assert.calledOnceWithMatch(listener, { old: environment, new: undefined }); + }); + + test('removes the cache root, clears state, and notifies known associations', async () => { + const firstUri = scriptUri('first.py'); + const secondUri = scriptUri('second.py'); + const firstEnvironment = await createOwnedEnvironment(); + const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set([firstUri, secondUri], firstEnvironment); + await manager.set(secondUri, secondEnvironment); + listener.resetHistory(); + + await manager.clearScriptCache(); + + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + assert.strictEqual(await manager.get(firstUri), undefined); + assert.strictEqual(await manager.get(secondUri), undefined); + assert.strictEqual(listener.callCount, 2); + assert.strictEqual(listener.firstCall.args[0].old, firstEnvironment); + assert.strictEqual(listener.firstCall.args[0].new, undefined); + assert.strictEqual(listener.secondCall.args[0].old, secondEnvironment); + assert.strictEqual(listener.secondCall.args[0].new, undefined); + }); + + test('refuses to clear while a create is active', async () => { + let releaseMetadata: ((value: metadataReader.InlineScriptMetadata | undefined) => void) | undefined; + readMetadataStub.callsFake( + () => + new Promise((resolve) => { + releaseMetadata = resolve; + }), + ); + + const createPromise = manager.create(scriptUri()); + + await assert.rejects( + manager.clearScriptCache(), + /Close other VS Code windows or restart VS Code, then retry/i, + ); + + releaseMetadata!(VALID_METADATA); + assert.ok(await createPromise); + }); + + test('refuses create requests while a clear is in progress', async () => { + let clearStarted: (() => void) | undefined; + let releaseClear: (() => void) | undefined; + const started = new Promise((resolve) => { + clearStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseClear = resolve; + }); + const clearManager = manager as unknown as { + getClearableCacheRootPath(cacheRoot: Uri): Promise; + }; + sinon.stub(clearManager, 'getClearableCacheRootPath').callsFake(async () => { + clearStarted!(); + await gate; + return undefined; + }); + + const clearPromise = manager.clearScriptCache(); + await started; + + await assert.rejects(manager.create(scriptUri()), /cache is being cleared/i); + + releaseClear!(); + await clearPromise; + }); + + test('refuses to clear when the cache root lock is already held', async () => { + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === cacheRoot().fsPath) { + throw Object.assign(new Error('already locked'), { code: 'ELOCKED' }); + } + return { release: releaseLockStub, retain: retainLockStub }; + }); + + await assert.rejects( + manager.clearScriptCache(), + new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*remove only this lock path manually`, 'i'), + ); + assert.strictEqual(workspaceState.clear.callCount, 0); + }); + + test('rejects when cache deletion and state clear succeed but root lock release fails', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const rootLockPath = `${path.resolve(cacheRoot().fsPath)}.lock`; + const rootRelease = sinon.stub().callsFake(async () => { + await fs.ensureDir(rootLockPath); + throw new Error('root release failed'); + }); + await manager.set(uri, environment); + lockStub.callsFake(async () => ({ + retain: sinon.stub().resolves(), + release: rootRelease, + })); + + await assert.rejects( + manager.clearScriptCache(), + new RegExp(`${rootLockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually`, 'i'), + ); + + assert.strictEqual(await fs.pathExists(cacheRoot().fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + assert.ok(rootRelease.calledOnce); + }); + + test('refuses clear after the root-to-entry handoff because the entry lock is visible on disk', async () => { + const otherManager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); + const entryLockPath = `${path.resolve(envDir().fsPath)}.lock`; + let releaseBuild: (() => void) | undefined; + const buildGate = new Promise((resolve) => { + releaseBuild = resolve; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + const selectedBase = args[4] as PythonEnvironment; + await fs.outputFile(getVenvPythonPath(target), ''); + await buildGate; + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + selectedBase.version, + getVenvPythonPath(target), + target, + ), + }; + }); + lockStub.callsFake(async (lockPath: string) => { + if (lockPath === envDir().fsPath) { + await fs.ensureDir(entryLockPath); + await fs.outputFile(path.join(entryLockPath, 'owner-1234'), ''); + return { + retain: sinon.stub().resolves(), + release: sinon.stub().callsFake(async () => { + await fs.remove(entryLockPath); + }), + }; + } + return { + retain: sinon.stub().resolves(), + release: sinon.stub().resolves(), + }; + }); + + const createPromise = manager.create(scriptUri()); + try { + await waitForStubCall(createWithProgressStub); + await assert.rejects(otherManager.clearScriptCache(), /owner-only lock/i); + } finally { + releaseBuild!(); + await createPromise; + otherManager.dispose(); + } + }); + + test('allows retained lock directories to be removed with the cache root', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const lockPath = path.join(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath, `${CACHE_KEY}.lock`); + await fs.ensureDir(lockPath); + await fs.outputFile(path.join(lockPath, 'owner-1234'), ''); + await fs.outputFile(path.join(lockPath, 'retained'), ''); + + await manager.clearScriptCache(); + + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); + assert.strictEqual(persistedAssociations, undefined); + }); + + test('rejects active owner lock directories', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const lockPath = path.join(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath, `${CACHE_KEY}.lock`); + await fs.ensureDir(lockPath); + await fs.outputFile(path.join(lockPath, 'owner-1234'), ''); + + await assert.rejects( + manager.clearScriptCache(), + new RegExp(`${lockPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*manually remove`, 'i'), + ); + + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + }); + + test('rejects orphaned or malformed lock entries', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const cacheRootPath = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; + const lockPath = path.join(cacheRootPath, `${CACHE_KEY}.lock`); + await manager.set(uri, environment); + + await fs.ensureDir(lockPath); + await assert.rejects(manager.clearScriptCache(), /could not be verified as retained/i); + await fs.remove(lockPath); + + await fs.outputFile(lockPath, 'not a directory'); + await assert.rejects(manager.clearScriptCache(), /could not be verified as retained/i); + + assert.strictEqual(await fs.pathExists(cacheRootPath), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + }); + + test('fails closed when the cache root is redirected through a symlink or junction', async function () { + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + const externalRoot = path.join(tempRoot, 'external-cache-root'); + const markerPath = path.join(externalRoot, 'keep.txt'); + await fs.ensureDir(globalStorageUri.fsPath); + await fs.remove(cacheRoot.fsPath); + await fs.outputFile(markerPath, 'keep'); + try { + await fs.symlink(externalRoot, cacheRoot.fsPath, isWindows() ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + return; + } + throw error; + } + + await assert.rejects(manager.clearScriptCache(), /could not be proven safe/i); + + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual((await fs.lstat(cacheRoot.fsPath)).isSymbolicLink(), true); + }); + + test('surfaces state clear failures after removing the cache root and clearing in-memory state', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set(uri, environment); + listener.resetHistory(); + workspaceState.clear.rejects(new Error('Memento unavailable')); + + await assert.rejects(manager.clearScriptCache(), /Memento unavailable/); + + const clearState = manager as unknown as { + fsPathToEnv: Map; + fsPathToPersistedEnvPath: Map; + }; + assert.strictEqual(await fs.pathExists(cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath), false); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + assert.strictEqual(clearState.fsPathToEnv.size, 0); + assert.strictEqual(clearState.fsPathToPersistedEnvPath.size, 0); + sinon.assert.calledOnceWithMatch(listener, { old: environment, new: undefined }); + }); + + test('surfaces disk deletion failures without clearing state', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const cacheRootPath = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; + const clearManager = manager as unknown as { + removeClearableCacheRoot(cacheRootPath: string): Promise; + }; + sinon.stub(clearManager, 'removeClearableCacheRoot').rejects(new Error('disk busy')); + + await assert.rejects(manager.clearScriptCache(), /disk busy/); + + assert.strictEqual(await fs.pathExists(cacheRootPath), true); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(uri), environment); + }); + + test('does not let a pending rehydration repopulate after clear', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + + let resolvePending: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolvePending = resolve; + }), + ); + + const pendingGet = manager.get(uri); + await waitForStubCall(resolveVenvStub); + + await manager.clearScriptCache(); + resolvePending!(environment); + + assert.strictEqual(await pendingGet, undefined); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(persistedAssociations, undefined); + }); + }); + suite('events and disposal', () => { test('create does not establish an association or fire later-phase events', async () => { const environmentsListener = sinon.spy(); diff --git a/src/test/managers/builtin/inlineScript/main.unit.test.ts b/src/test/managers/builtin/inlineScript/main.unit.test.ts index d109e318d..1fec3cd12 100644 --- a/src/test/managers/builtin/inlineScript/main.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/main.unit.test.ts @@ -51,23 +51,37 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(false); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); + const result = await registerInlineScriptFeatures( + nativeFinder, + disposables, + makeFakeLog(), + baseManager, + globalStorageUri, + ); assert.strictEqual(disposables.length, 0, 'no disposables should be added when flag is off'); assert.strictEqual(getPythonApiStub.called, false, 'should not even call getPythonApi when gated off'); assert.strictEqual(registerEnvironmentManagerStub.called, false); + assert.strictEqual(result, undefined); }); test('when the feature flag is TRUE: registers the manager and pushes the disposable', async () => { isEnabledStub.returns(true); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); + const result = await registerInlineScriptFeatures( + nativeFinder, + disposables, + makeFakeLog(), + baseManager, + globalStorageUri, + ); assert.strictEqual(getPythonApiStub.callCount, 1); assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); assert.strictEqual(disposables.length, 2, 'expected manager + registration disposable'); const manager = registerEnvironmentManagerStub.firstCall.args[0]; + assert.strictEqual(result, manager); assert.ok(disposables.includes(manager), 'manager itself should be disposed'); assert.ok( disposables.includes(registerEnvironmentManagerStub.firstCall.returnValue), diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index bd8d469e0..176aba815 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -26,9 +26,10 @@ suite('Smoke: Registration Checks', function () { this.timeout(MAX_EXTENSION_ACTIVATION_TIME); let api: PythonEnvironmentApi; + let extension: vscode.Extension; suiteSetup(async function () { - const extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID); + extension = vscode.extensions.getExtension(ENVS_EXTENSION_ID)!; assert.ok(extension, `Extension ${ENVS_EXTENSION_ID} not found`); if (!extension.isActive) { @@ -65,6 +66,7 @@ suite('Smoke: Registration Checks', function () { 'python-envs.setPkgManager', 'python-envs.refreshAllManagers', 'python-envs.clearCache', + 'python-envs.clearInlineScriptCache', 'python-envs.searchSettings', // Package management @@ -113,6 +115,41 @@ suite('Smoke: Registration Checks', function () { ); }); + test('Clear cache commands are contributed from package.json', function () { + const clearCacheCommand = extension.packageJSON?.contributes?.commands?.find( + (item: { command: string }) => item.command === 'python-envs.clearCache', + ); + const clearInlineScriptCacheCommand = extension.packageJSON?.contributes?.commands?.find( + (item: { command: string }) => item.command === 'python-envs.clearInlineScriptCache', + ); + const clearInlineScriptCachePaletteEntry = extension.packageJSON?.contributes?.menus?.commandPalette?.find( + (item: { command: string }) => item.command === 'python-envs.clearInlineScriptCache', + ); + + assert.ok(clearCacheCommand, 'python-envs.clearCache should be contributed in package.json'); + assert.strictEqual(clearCacheCommand.category, 'Python'); + assert.strictEqual(clearCacheCommand.title, 'Clear Cache'); + + assert.ok( + clearInlineScriptCacheCommand, + 'python-envs.clearInlineScriptCache should be contributed in package.json', + ); + assert.strictEqual(clearInlineScriptCacheCommand.category, 'Python'); + assert.strictEqual(clearInlineScriptCacheCommand.title, 'Clear Script Environment Cache'); + assert.strictEqual( + clearInlineScriptCacheCommand.enablement, + 'config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true', + ); + assert.ok( + clearInlineScriptCachePaletteEntry, + 'python-envs.clearInlineScriptCache should have a command palette contribution', + ); + assert.strictEqual( + clearInlineScriptCachePaletteEntry.when, + 'config.python.useEnvironmentsExtension != false && config.python-envs.inlineScripts.enabled == true', + ); + }); + // ========================================================================= // API METHODS - All API methods must exist and be functions // =========================================================================