diff --git a/src/common/inlineScript/cacheLayout.ts b/src/common/inlineScript/cacheLayout.ts index 1371040b..fdc31bf9 100644 --- a/src/common/inlineScript/cacheLayout.ts +++ b/src/common/inlineScript/cacheLayout.ts @@ -204,7 +204,7 @@ export function selectStaleEntries(entries: ReadonlyArray, no } /** - * Verify that a cached env's base interpreter still exists on disk. + * Verify that a cached env's launcher and base interpreter still exist on disk. */ export async function verifyBaseInterpreterExists(envDir: Uri): Promise { return (await getBaseInterpreterStatus(envDir)) === 'available'; @@ -221,6 +221,11 @@ async function getPosixBaseInterpreterStatus(envDir: Uri): Promise { + const launcherStatus = await getRegularFileStatus(getVenvPythonPath(envDir.fsPath), 'cached interpreter launcher'); + if (launcherStatus !== 'available') { + return launcherStatus; + } + const pyvenvPath = Uri.joinPath(envDir, 'pyvenv.cfg').fsPath; let raw: string; try { diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index d68d9dda..3212f6f1 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -11,6 +11,7 @@ import { DidChangeEnvironmentEventArgs, DidChangeEnvironmentsEventArgs, EnvironmentManager, + EnvironmentChangeKind, GetEnvironmentScope, GetEnvironmentsScope, IconPath, @@ -49,6 +50,7 @@ import { normalizePath } from '../../../common/utils/pathUtils'; import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { NativePythonFinder } from '../../common/nativePythonFinder'; +import { sortEnvironments } from '../../common/utils'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils'; @@ -62,6 +64,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([ const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000; const CACHE_LOCK_RETRY_MS = 500; const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000; +const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000, 30_000] as const; /** 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`; @@ -82,6 +85,11 @@ interface BuildCacheEntryResult { readonly retainLock?: boolean; } +interface DiscoveryRefreshPass { + readonly promise: Promise; + readonly checksForSnapshotChanges: boolean; +} + type CacheEntryInspection = | { readonly kind: 'absent' | 'stale' | 'uncertain' } | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; @@ -92,13 +100,20 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingCreations = new Map>(); private readonly directlyResolvedBaseInterpreters = new Map(); private baseInterpreterInstallationQueue: Promise = Promise.resolve(); + private collection: PythonEnvironment[] = []; private readonly pendingRehydrations = new Map>(); private readonly fsPathToEnv = new Map(); private readonly fsPathToPersistedEnvPath = new Map(); private readonly cachedAssociationValidatedAt = new Map(); private readonly associationRevisions = new Map(); + private pendingRefresh: DiscoveryRefreshPass | undefined; + private pendingSnapshotRefresh: Promise | undefined; + private activationDiscoveryActive = false; + private discoveryRetryAttempt = 0; + private discoveryRetryTimer: ReturnType | undefined; private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); + private disposed = false; private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = @@ -228,10 +243,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } async refresh(_scope: RefreshEnvironmentsScope): Promise { - return; + if (this.disposed) { + return; + } + this.stopActivationDiscovery(); + await this.getOrStartRefreshPass(false); } - async getEnvironments(_scope: GetEnvironmentsScope): Promise { + async getEnvironments(scope: GetEnvironmentsScope): Promise { + if (scope === 'all') { + return Array.from(this.collection); + } return []; } @@ -247,6 +269,339 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } + public startActivationDiscovery(): void { + if (this.disposed || this.activationDiscoveryActive) { + return; + } + this.activationDiscoveryActive = true; + this.discoveryRetryAttempt = 0; + this.runActivationDiscoveryPass(); + } + + private getOrStartRefreshPass(checkForSnapshotChanges: boolean): Promise { + const pending = this.pendingRefresh; + if (pending) { + return checkForSnapshotChanges && !pending.checksForSnapshotChanges + ? this.getOrScheduleSnapshotRefresh(pending) + : pending.promise; + } + + return this.startRefreshPass(checkForSnapshotChanges); + } + + private startRefreshPass(checkForSnapshotChanges: boolean): Promise { + const pass: DiscoveryRefreshPass = { + promise: this.refreshDiscoveredEnvironments(checkForSnapshotChanges), + checksForSnapshotChanges: checkForSnapshotChanges, + }; + this.pendingRefresh = pass; + void pass.promise.then( + () => { + if (this.pendingRefresh === pass) { + this.pendingRefresh = undefined; + } + }, + () => { + if (this.pendingRefresh === pass) { + this.pendingRefresh = undefined; + } + }, + ); + return pass.promise; + } + + private getOrScheduleSnapshotRefresh(sharedPass: DiscoveryRefreshPass): Promise { + const pending = this.pendingSnapshotRefresh; + if (pending) { + return pending; + } + + const followUp = this.startSnapshotRefreshAfter(sharedPass); + this.pendingSnapshotRefresh = followUp; + void followUp.then( + () => { + if (this.pendingSnapshotRefresh === followUp) { + this.pendingSnapshotRefresh = undefined; + } + }, + () => { + if (this.pendingSnapshotRefresh === followUp) { + this.pendingSnapshotRefresh = undefined; + } + }, + ); + return followUp; + } + + private async startSnapshotRefreshAfter(sharedPass: DiscoveryRefreshPass): Promise { + await sharedPass.promise; + if (this.disposed || !this.activationDiscoveryActive) { + return false; + } + const pending = this.pendingRefresh; + if (pending && pending !== sharedPass) { + return pending.checksForSnapshotChanges + ? pending.promise + : this.startSnapshotRefreshAfter(pending); + } + return this.startRefreshPass(true); + } + + private runActivationDiscoveryPass(): void { + if (this.disposed || !this.activationDiscoveryActive) { + return; + } + + void this.getOrStartRefreshPass(true) + .then((shouldRetry) => { + if (this.disposed || !this.activationDiscoveryActive) { + return; + } + if (!shouldRetry) { + this.stopActivationDiscovery(); + return; + } + this.scheduleActivationDiscoveryRetry(); + }) + .catch((error) => { + if (this.disposed || !this.activationDiscoveryActive) { + return; + } + this.log.warn(`Activation-time inline-script discovery failed: ${getErrorMessage(error)}`); + this.stopActivationDiscovery(); + }); + } + + private async refreshDiscoveredEnvironments(checkForSnapshotChanges: boolean): Promise { + const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); + const previousByKey = new Map( + this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]), + ); + + let entryNames: string[]; + try { + entryNames = await fs.readdir(cacheRoot.fsPath); + } catch (error) { + if (this.isDefinitivelyStalePathError(error)) { + entryNames = []; + } else { + this.log.warn( + `Unable to inspect the inline-script cache root ${cacheRoot.fsPath}: ${getErrorMessage(error)}`, + ); + return true; + } + } + + const lockedKeys = new Set(); + const nextByKey = new Map(); + let shouldRetry = false; + for (const entryName of entryNames.sort()) { + if (entryName.endsWith('.lock')) { + lockedKeys.add(this.getDiscoveryEntryKey(entryName.slice(0, -5))); + shouldRetry = true; + continue; + } + + if (this.disposed) { + return false; + } + + const envDir = Uri.joinPath(cacheRoot, entryName); + const key = this.getDiscoveryEntryKey(entryName); + const discovered = await this.inspectDiscoveredCacheEntry(cacheRoot, envDir); + if (discovered.kind === 'resolved') { + nextByKey.set(key, discovered.environment); + } else if (discovered.kind === 'preserve') { + shouldRetry = true; + const previous = previousByKey.get(key); + if (previous) { + nextByKey.set(key, previous); + } + } + } + for (const [key, previous] of previousByKey) { + if (!nextByKey.has(key) && lockedKeys.has(key)) { + nextByKey.set(key, previous); + } + } + + if (this.disposed) { + return false; + } + + if (checkForSnapshotChanges) { + let finalEntryNames: string[] | undefined; + try { + finalEntryNames = await fs.readdir(cacheRoot.fsPath); + } catch (error) { + if (this.isDefinitivelyStalePathError(error)) { + finalEntryNames = []; + } else { + shouldRetry = true; + } + } + if (finalEntryNames !== undefined) { + const initialEntries = new Set(entryNames); + if ( + finalEntryNames.length !== entryNames.length || + finalEntryNames.some((entryName) => !initialEntries.has(entryName)) + ) { + shouldRetry = true; + } + } + } + + if (this.disposed) { + return false; + } + + // Preserve previously known entries when a refresh cannot safely classify + // them because a build is in progress or the filesystem is transiently unavailable. + this.replaceDiscoveredEnvironments(sortEnvironments(Array.from(nextByKey.values()))); + return shouldRetry; + } + + private async inspectDiscoveredCacheEntry( + cacheRoot: Uri, + envDir: Uri, + ): Promise { + try { + const stat = await fs.lstat(envDir.fsPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { kind: 'skip' }; + } + } catch (error) { + return this.isDefinitivelyStalePathError(error) ? { kind: 'skip' } : { kind: 'preserve' }; + } + + if (await this.isCacheEntryBusy(envDir.fsPath)) { + return { kind: 'preserve' }; + } + + try { + if (!(await resolveCacheEntryPath(cacheRoot, envDir))) { + return { kind: 'skip' }; + } + } catch (error) { + return this.isDefinitivelyStalePathError(error) ? { kind: 'skip' } : { kind: 'preserve' }; + } + + const sidecarResult = await inspectMetaJson(envDir); + if (sidecarResult.kind !== 'valid') { + return { kind: sidecarResult.kind === 'unavailable' ? 'preserve' : 'skip' }; + } + + const baseInterpreterStatus = await getBaseInterpreterStatus(envDir); + if (baseInterpreterStatus !== 'available') { + return { kind: baseInterpreterStatus === 'unavailable' ? 'preserve' : 'skip' }; + } + + let environment: PythonEnvironment | undefined; + try { + environment = await resolveVenvPythonEnvironmentPath( + getVenvPythonPath(envDir.fsPath), + this.nativeFinder, + this.api, + this, + this.baseManager, + ); + } catch (error) { + this.log.warn( + `Unable to resolve inline-script cache entry ${envDir.fsPath}: ${getErrorMessage(error)}`, + ); + return { kind: 'preserve' }; + } + if (!environment) { + return { kind: 'preserve' }; + } + + const ownership = await inspectOwnedCacheEntry(environment, cacheRoot, envDir); + if (ownership !== 'expected') { + return { kind: ownership === 'uncertain' ? 'preserve' : 'skip' }; + } + if (!this.areEqualPythonReleases(environment.version, sidecarResult.metadata.baseInterpreterVersion)) { + return { kind: 'skip' }; + } + + return { kind: 'resolved', environment }; + } + + private replaceDiscoveredEnvironments(next: PythonEnvironment[]): void { + const previousByKey = new Map( + this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]), + ); + const nextByKey = new Map(next.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment])); + const changes: DidChangeEnvironmentsEventArgs = []; + + for (const [key, previous] of previousByKey) { + const current = nextByKey.get(key); + if (!current || !this.isSameDiscoveredEnvironment(previous, current)) { + changes.push({ kind: EnvironmentChangeKind.remove, environment: previous }); + } + } + for (const [key, current] of nextByKey) { + const previous = previousByKey.get(key); + if (!previous || !this.isSameDiscoveredEnvironment(previous, current)) { + changes.push({ kind: EnvironmentChangeKind.add, environment: current }); + } + } + + this.collection = next; + if (changes.length > 0) { + this._onDidChangeEnvironments.fire(changes); + } + } + + private getDiscoveryEntryKey(entryName: string): string { + return normalizePath(entryName); + } + + private getDiscoveredEnvironmentKey(environment: PythonEnvironment): string { + return this.getDiscoveryEntryKey(path.basename(environment.sysPrefix)); + } + + private isSameDiscoveredEnvironment(first: PythonEnvironment, second: PythonEnvironment): boolean { + return ( + first.envId.managerId === second.envId.managerId && + normalizePath(first.environmentPath.fsPath) === normalizePath(second.environmentPath.fsPath) && + first.version === second.version + ); + } + + private scheduleActivationDiscoveryRetry(): void { + if (this.discoveryRetryTimer) { + return; + } + + const delayMs = this.getDiscoveryRetryDelayMs(this.discoveryRetryAttempt); + if (delayMs === undefined) { + this.stopActivationDiscovery(); + return; + } + + this.discoveryRetryAttempt += 1; + this.discoveryRetryTimer = setTimeout(() => { + this.discoveryRetryTimer = undefined; + if (this.disposed || !this.activationDiscoveryActive) { + return; + } + this.runActivationDiscoveryPass(); + }, delayMs); + } + + private getDiscoveryRetryDelayMs(attempt: number): number | undefined { + return DISCOVERY_RETRY_DELAYS_MS[attempt]; + } + + private stopActivationDiscovery(): void { + if (this.discoveryRetryTimer) { + clearTimeout(this.discoveryRetryTimer); + this.discoveryRetryTimer = undefined; + } + this.activationDiscoveryActive = false; + this.discoveryRetryAttempt = 0; + } + 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; @@ -770,10 +1125,15 @@ 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`)) - ); + if (this.pendingCreations.has(path.basename(envDirPath))) { + return true; + } + try { + await fs.lstat(`${path.resolve(envDirPath)}.lock`); + return true; + } catch (error) { + return !isFileNotFoundError(error); + } } private bumpAssociationRevision(scriptPath: string): void { @@ -1283,6 +1643,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } dispose(): void { + this.disposed = true; + this.stopActivationDiscovery(); this._onDidChangeEnvironments.dispose(); this._onDidChangeEnvironment.dispose(); } @@ -1306,3 +1668,7 @@ interface PendingScriptUpdate extends ScriptReference { readonly needsPersistence: boolean; readonly shouldNotify: boolean; } + +type DiscoveredCacheEntryResult = + | { readonly kind: 'preserve' | 'skip' } + | { readonly kind: 'resolved'; readonly environment: PythonEnvironment }; diff --git a/src/managers/builtin/inlineScript/main.ts b/src/managers/builtin/inlineScript/main.ts index 8c35fc6e..46760ff7 100644 --- a/src/managers/builtin/inlineScript/main.ts +++ b/src/managers/builtin/inlineScript/main.ts @@ -29,5 +29,6 @@ export async function registerInlineScriptFeatures( const api: PythonEnvironmentApi = await getPythonApi(); const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); disposables.push(mgr, api.registerEnvironmentManager(mgr)); + setImmediate(() => mgr.startActivationDiscovery()); traceInfo('Inline-script env manager: registered (internal flag is on)'); } diff --git a/src/test/common/inlineScript/cacheLayout.unit.test.ts b/src/test/common/inlineScript/cacheLayout.unit.test.ts index d57be848..ce63d1c4 100644 --- a/src/test/common/inlineScript/cacheLayout.unit.test.ts +++ b/src/test/common/inlineScript/cacheLayout.unit.test.ts @@ -640,18 +640,60 @@ suite('inlineScriptCacheLayout', () => { await fs.writeFile(path.join(envDir.fsPath, 'pyvenv.cfg'), content, 'utf8'); } + async function writeLauncher(): Promise { + const launcherPath = getVenvPythonPath(envDir.fsPath); + await fs.ensureDir(path.dirname(launcherPath)); + await fs.writeFile(launcherPath, ''); + return launcherPath; + } + test('returns true when pyvenv.cfg.home points to an existing python.exe', async () => { const homeDir = path.join(tmpDir, 'Python313'); await fs.ensureDir(homeDir); await fs.writeFile(path.join(homeDir, 'python.exe'), ''); + await writeLauncher(); await writePyvenvCfg(`home = ${homeDir}\ninclude-system-site-packages = false\nversion = 3.13.0\n`); assert.strictEqual(await verifyBaseInterpreterExists(envDir), true); assert.strictEqual(traceWarnStub.called, false, 'no warn on success'); }); + test('returns false when the cached launcher is missing even if the base python still exists', async () => { + const homeDir = path.join(tmpDir, 'Python313'); + await fs.ensureDir(homeDir); + await fs.writeFile(path.join(homeDir, 'python.exe'), ''); + await writePyvenvCfg(`home = ${homeDir}\n`); + assert.strictEqual(await getBaseInterpreterStatus(envDir), 'missing'); + assert.strictEqual(await verifyBaseInterpreterExists(envDir), false); + assert.ok( + traceWarnStub.getCalls().some((c) => String(c.args[0]).includes('cached interpreter launcher')), + 'expected a missing-launcher warn', + ); + }); + + test('returns false when the cached launcher path is not a regular file', async () => { + const homeDir = path.join(tmpDir, 'Python313'); + await fs.ensureDir(homeDir); + await fs.writeFile(path.join(homeDir, 'python.exe'), ''); + const launcherPath = getVenvPythonPath(envDir.fsPath); + await fs.ensureDir(launcherPath); + await writePyvenvCfg(`home = ${homeDir}\n`); + assert.strictEqual(await getBaseInterpreterStatus(envDir), 'missing'); + assert.strictEqual(await verifyBaseInterpreterExists(envDir), false); + assert.ok( + traceWarnStub.getCalls().some((c) => String(c.args[0]).includes('not a regular file')), + 'expected a non-file launcher warn', + ); + }); + + test('classifies a transient cached-launcher stat failure as unavailable', async () => { + sinon.stub(fsExtra, 'stat').rejects(Object.assign(new Error('I/O error'), { code: 'EIO' })); + assert.strictEqual(await getBaseInterpreterStatus(envDir), 'unavailable'); + }); + test('returns false when pyvenv.cfg.home points to a removed python.exe', async () => { const homeDir = path.join(tmpDir, 'Python313'); await fs.ensureDir(homeDir); + await writeLauncher(); await writePyvenvCfg(`home = ${homeDir}\n`); assert.strictEqual(await verifyBaseInterpreterExists(envDir), false); assert.ok( @@ -661,6 +703,7 @@ suite('inlineScriptCacheLayout', () => { }); test('returns false when pyvenv.cfg is missing entirely', async () => { + await writeLauncher(); assert.strictEqual(await verifyBaseInterpreterExists(envDir), false); assert.ok( traceWarnStub @@ -671,6 +714,7 @@ suite('inlineScriptCacheLayout', () => { }); test('returns false when pyvenv.cfg has no `home =` line', async () => { + await writeLauncher(); await writePyvenvCfg('include-system-site-packages = false\nversion = 3.13.0\n'); assert.strictEqual(await verifyBaseInterpreterExists(envDir), false); assert.ok( @@ -679,6 +723,7 @@ suite('inlineScriptCacheLayout', () => { }); test('returns false when pyvenv.cfg has an empty home value', async () => { + await writeLauncher(); await writePyvenvCfg('home =\n'); assert.strictEqual(await verifyBaseInterpreterExists(envDir), false); assert.ok( @@ -690,6 +735,7 @@ suite('inlineScriptCacheLayout', () => { const homeDir = path.join(tmpDir, 'Python313'); await fs.ensureDir(homeDir); await fs.writeFile(path.join(homeDir, 'python.exe'), ''); + await writeLauncher(); await writePyvenvCfg(` home = ${homeDir} \n`); assert.strictEqual(await verifyBaseInterpreterExists(envDir), true); }); @@ -698,6 +744,7 @@ suite('inlineScriptCacheLayout', () => { const homeDir = path.join(tmpDir, 'Python313'); await fs.ensureDir(homeDir); await fs.writeFile(path.join(homeDir, 'python.exe'), ''); + await writeLauncher(); await writePyvenvCfg(`home = ${homeDir}\r\nversion = 3.13.0\r\n`); assert.strictEqual(await verifyBaseInterpreterExists(envDir), true); }); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 3d0488ca..fc052f6e 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -2,12 +2,13 @@ // Licensed under the MIT License. import assert from 'assert'; +import fsExtra from 'fs-extra'; import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../../../api'; +import { EnvironmentChangeKind, EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../../../api'; import * as cacheKey from '../../../../common/inlineScript/cacheKey'; import * as cacheLayout from '../../../../common/inlineScript/cacheLayout'; import * as metadataReader from '../../../../common/inlineScript/metadata'; @@ -201,6 +202,27 @@ suite('InlineScriptEnvManager', () => { inspectMetaStub.resolves({ kind: 'valid', metadata }); } + async function makeSidecar( + overrides: Partial = {}, + ): Promise { + return { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: await fs.realpath(baseExecutable), + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + ...overrides, + }; + } + + function setSidecarResults(results: Record): void { + inspectMetaStub.callsFake(async (candidate: Uri) => results[path.basename(candidate.fsPath)] ?? { kind: 'missing' }); + } + + function setResolvedVenvs(environments: readonly PythonEnvironment[]): void { + const byPath = new Map(environments.map((environment) => [normalizePath(environment.environmentPath.fsPath), environment])); + resolveVenvStub.callsFake(async (candidatePath: string) => byPath.get(normalizePath(candidatePath))); + } + async function createOwnedEnvironment( cacheKey: string = CACHE_KEY, envId: string = `inline-${cacheKey}`, @@ -224,6 +246,29 @@ suite('InlineScriptEnvManager', () => { assert.fail('Expected the stub to be called'); } + async function waitForStubCallCount(stub: sinon.SinonStub, expectedCallCount: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (stub.callCount >= expectedCallCount) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(`Expected the stub to be called ${expectedCallCount} times`); + } + + async function waitForCondition( + predicate: () => boolean | Promise, + message: string, + ): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(message); + } + function nextTurn(): Promise { return new Promise((resolve) => setImmediate(resolve)); } @@ -1465,6 +1510,496 @@ suite('InlineScriptEnvManager', () => { }); }); + suite('activation-time discovery', () => { + test('cold-start transient resolution retries and later discovers the environment', async () => { + const environment = await createOwnedEnvironment(); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + resolveVenvStub.onFirstCall().resolves(undefined); + resolveVenvStub.onSecondCall().resolves(environment); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').callsFake((attempt) => (attempt === 0 ? 0 : undefined)); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + + manager.startActivationDiscovery(); + + await waitForStubCallCount(resolveVenvStub, 2); + await waitForCondition( + async () => (await manager.getEnvironments('all')).length === 1, + 'Expected the follow-up discovery retry to publish the environment', + ); + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + assert.deepStrictEqual(listener.firstCall.args[0], [ + { kind: EnvironmentChangeKind.add, environment }, + ]); + }); + + test('refresh discovers valid cached environments and exposes them only through all-scope', async () => { + const first = await createOwnedEnvironment(); + const secondKey = 'fedcba9876543210'; + const second = await createOwnedEnvironment(secondKey); + const sidecar = await makeSidecar(); + setSidecarResults({ + [CACHE_KEY]: { kind: 'valid', metadata: sidecar }, + [secondKey]: { kind: 'valid', metadata: sidecar }, + }); + setResolvedVenvs([first, second]); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + + await manager.refresh(undefined); + + const discovered = await manager.getEnvironments('all'); + assert.deepStrictEqual( + discovered.map((environment) => normalizePath(environment.sysPrefix)).sort(), + [first, second].map((environment) => normalizePath(environment.sysPrefix)).sort(), + ); + assert.deepStrictEqual(await manager.getEnvironments('global'), []); + assert.strictEqual(listener.callCount, 1); + assert.deepStrictEqual( + listener.firstCall.args[0].map((change: { kind: EnvironmentChangeKind }) => change.kind), + [EnvironmentChangeKind.add, EnvironmentChangeKind.add], + ); + }); + + test('explicit refresh takes a single cache-root snapshot', async () => { + const environment = await createOwnedEnvironment(); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + setResolvedVenvs([environment]); + const readdirStub = sinon.stub(fsExtra, 'readdir').resolves([CACHE_KEY]); + + await manager.refresh(undefined); + + assert.strictEqual(readdirStub.callCount, 1); + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + }); + + test('does not retry when the final cache-root snapshot is definitively absent and empty', async () => { + const readdirStub = sinon.stub(fsExtra, 'readdir'); + readdirStub.onFirstCall().resolves([]); + readdirStub.onSecondCall().rejects(Object.assign(new Error('cache root removed'), { code: 'ENOENT' })); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').returns(0); + + manager.startActivationDiscovery(); + await waitForStubCallCount(readdirStub, 2); + await new Promise((resolve) => setTimeout(resolve, 25)); + + assert.strictEqual(readdirStub.callCount, 2); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + }); + + test('refresh skips missing, invalid, unavailable, and non-directory cache entries', async () => { + const valid = await createOwnedEnvironment(); + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; + const invalidKey = 'invalid-sidecar'; + const unavailableKey = 'unavailable-sidecar'; + await fs.ensureDir(path.join(cacheRoot, 'missing-sidecar')); + await fs.ensureDir(path.join(cacheRoot, invalidKey)); + await fs.ensureDir(path.join(cacheRoot, unavailableKey)); + await fs.outputFile(path.join(cacheRoot, 'not-a-directory'), ''); + const sidecar = await makeSidecar(); + setSidecarResults({ + [CACHE_KEY]: { kind: 'valid', metadata: sidecar }, + [invalidKey]: { kind: 'invalid' }, + [unavailableKey]: { kind: 'unavailable' }, + }); + setResolvedVenvs([valid]); + + await manager.refresh(undefined); + + const discovered = await manager.getEnvironments('all'); + assert.deepStrictEqual(discovered, [valid]); + assert.strictEqual(resolveVenvStub.callCount, 1); + }); + + test('refresh preserves a previously discovered environment while its cache entry is locked', async () => { + const environment = await createOwnedEnvironment(); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + setResolvedVenvs([environment]); + await manager.refresh(undefined); + resolveVenvStub.resetHistory(); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + await fs.remove(environment.sysPrefix); + await fs.ensureDir(`${path.resolve(environment.sysPrefix)}.lock`); + + await manager.refresh(undefined); + + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(listener.callCount, 0); + }); + + test('refresh preserves a discovered environment when its lock probe reports EIO', async () => { + const environment = await createOwnedEnvironment(); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + setResolvedVenvs([environment]); + await manager.refresh(undefined); + resolveVenvStub.resetHistory(); + baseInterpreterStatusStub.resetHistory(); + baseInterpreterStatusStub.resolves('missing'); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + const lockPath = `${path.resolve(environment.sysPrefix)}.lock`; + sinon + .stub(fsExtra, 'lstat') + .callThrough() + .withArgs(lockPath) + .rejects(Object.assign(new Error('I/O error'), { code: 'EIO' })); + + await manager.refresh(undefined); + + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(baseInterpreterStatusStub.callCount, 0); + assert.strictEqual(listener.callCount, 0); + }); + + test('uses the cache entry name to preserve a canonical sysPrefix through a cache-root link', async function () { + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath; + const physicalCacheRoot = path.join(tempRoot, 'physical-cache-root'); + const physicalEnvDir = path.join(physicalCacheRoot, CACHE_KEY); + const physicalExecutable = getVenvPythonPath(physicalEnvDir); + await fs.ensureDir(path.dirname(cacheRoot)); + await fs.ensureDir(physicalCacheRoot); + try { + await fs.symlink(physicalCacheRoot, cacheRoot, isWindows() ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + return; + } + throw error; + } + await fs.outputFile(physicalExecutable, ''); + const environment = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + physicalExecutable, + physicalEnvDir, + ); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + resolveVenvStub.resolves(environment); + await manager.refresh(undefined); + resolveVenvStub.resetHistory(); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + await fs.remove(physicalEnvDir); + await fs.ensureDir(`${physicalEnvDir}.lock`); + + await manager.refresh(undefined); + + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(listener.callCount, 0); + }); + + test('refresh removes a previously discovered environment when launcher inspection marks it missing', async () => { + const environment = await createOwnedEnvironment(); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + setResolvedVenvs([environment]); + await manager.refresh(undefined); + resolveVenvStub.resetHistory(); + baseInterpreterStatusStub.resolves('missing'); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + + await manager.refresh(undefined); + + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.deepStrictEqual(listener.firstCall.args[0], [ + { kind: EnvironmentChangeKind.remove, environment }, + ]); + }); + + test('coalesces concurrent refresh requests for the same scan', async () => { + const environment = await createOwnedEnvironment(); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + let resolveDiscovery: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolveDiscovery = resolve; + }), + ); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + + const firstRefresh = manager.refresh(undefined); + const secondRefresh = manager.refresh(undefined); + await waitForStubCall(resolveVenvStub); + assert.strictEqual(resolveVenvStub.callCount, 1); + resolveDiscovery!(environment); + await Promise.all([firstRefresh, secondRefresh]); + + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + assert.strictEqual(listener.callCount, 1); + }); + + test('runs a snapshot-aware follow-up when activation joins an explicit refresh', async () => { + const first = await createOwnedEnvironment(); + const secondKey = 'fedcba9876543210'; + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + let second: PythonEnvironment | undefined; + let releaseFirstResolution: (() => void) | undefined; + let signalFirstResolution: (() => void) | undefined; + const firstResolution = new Promise((resolve) => { + signalFirstResolution = resolve; + }); + const resolutionGate = new Promise((resolve) => { + releaseFirstResolution = resolve; + }); + let firstResolutionPending = true; + resolveVenvStub.callsFake(async (candidatePath: string) => { + if ( + firstResolutionPending && + normalizePath(candidatePath) === normalizePath(first.environmentPath.fsPath) + ) { + firstResolutionPending = false; + signalFirstResolution!(); + await resolutionGate; + } + return normalizePath(candidatePath) === normalizePath(first.environmentPath.fsPath) ? first : second; + }); + + const refresh = manager.refresh(undefined); + await firstResolution; + manager.startActivationDiscovery(); + second = await createOwnedEnvironment(secondKey); + setSidecarResults({ + [CACHE_KEY]: { kind: 'valid', metadata: sidecar }, + [secondKey]: { kind: 'valid', metadata: sidecar }, + }); + releaseFirstResolution!(); + await refresh; + + await waitForStubCallCount(resolveVenvStub, 3); + await waitForCondition( + async () => (await manager.getEnvironments('all')).length === 2, + 'Expected activation discovery to scan the entry added after the explicit refresh snapshot', + ); + assert.deepStrictEqual(await manager.getEnvironments('all'), [first, second]); + }); + + test('retries when a cache entry appears during a discovery scan', async () => { + const first = await createOwnedEnvironment(); + const secondKey = 'fedcba9876543210'; + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + let second: PythonEnvironment | undefined; + let releaseFirstResolution: (() => void) | undefined; + let signalFirstResolution: (() => void) | undefined; + const firstResolution = new Promise((resolve) => { + signalFirstResolution = resolve; + }); + const resolutionGate = new Promise((resolve) => { + releaseFirstResolution = resolve; + }); + let firstResolutionPending = true; + resolveVenvStub.callsFake(async (candidatePath: string) => { + if ( + firstResolutionPending && + normalizePath(candidatePath) === normalizePath(first.environmentPath.fsPath) + ) { + firstResolutionPending = false; + signalFirstResolution!(); + await resolutionGate; + } + return normalizePath(candidatePath) === normalizePath(first.environmentPath.fsPath) ? first : second; + }); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').callsFake((attempt) => (attempt === 0 ? 0 : undefined)); + + manager.startActivationDiscovery(); + await firstResolution; + second = await createOwnedEnvironment(secondKey); + setSidecarResults({ + [CACHE_KEY]: { kind: 'valid', metadata: sidecar }, + [secondKey]: { kind: 'valid', metadata: sidecar }, + }); + releaseFirstResolution!(); + + await waitForStubCallCount(resolveVenvStub, 3); + await waitForCondition( + async () => (await manager.getEnvironments('all')).length === 2, + 'Expected the scan after the changed snapshot to publish both environments', + ); + assert.deepStrictEqual(await manager.getEnvironments('all'), [first, second]); + }); + + test('discovers a build that completes after the short retry window', async () => { + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + assert.strictEqual(retryManager.getDiscoveryRetryDelayMs(2), 30_000); + const retryDelayStub = sinon + .stub(retryManager, 'getDiscoveryRetryDelayMs') + .callsFake((attempt) => (attempt < 2 ? 0 : attempt === 2 ? 25 : undefined)); + const lockPath = `${path.resolve(envDir().fsPath)}.lock`; + await fs.ensureDir(lockPath); + + manager.startActivationDiscovery(); + await waitForStubCallCount(retryDelayStub, 3); + const environment = await createOwnedEnvironment(); + resolveVenvStub.resolves(environment); + await fs.remove(lockPath); + + await waitForStubCall(resolveVenvStub); + await waitForCondition( + async () => (await manager.getEnvironments('all')).length === 1, + 'Expected the extended retry to publish the completed build', + ); + assert.deepStrictEqual(await manager.getEnvironments('all'), [environment]); + }); + + test('explicit refresh does not schedule a delayed follow-up after an uncertain pass', async () => { + const sidecar = await makeSidecar(); + await createOwnedEnvironment(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + resolveVenvStub.resolves(undefined); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').returns(0); + + await manager.refresh(undefined); + await new Promise((resolve) => setTimeout(resolve, 25)); + + assert.strictEqual(resolveVenvStub.callCount, 1); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + }); + + test('explicit refresh overlapping bootstrap cancels the later activation retry', async () => { + const sidecar = await makeSidecar(); + await createOwnedEnvironment(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + let resolveDiscovery: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.onFirstCall().callsFake( + () => + new Promise((resolve) => { + resolveDiscovery = resolve; + }), + ); + resolveVenvStub.onSecondCall().resolves(undefined); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').returns(0); + + manager.startActivationDiscovery(); + await waitForStubCall(resolveVenvStub); + + const refresh = manager.refresh(undefined); + resolveDiscovery!(undefined); + await refresh; + await new Promise((resolve) => setTimeout(resolve, 25)); + + assert.strictEqual(resolveVenvStub.callCount, 1); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + }); + + test('stops retrying after the bounded follow-up attempts', async () => { + const sidecar = await makeSidecar(); + await createOwnedEnvironment(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + resolveVenvStub.resolves(undefined); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').callsFake((attempt) => (attempt < 2 ? 0 : undefined)); + + manager.startActivationDiscovery(); + await waitForStubCallCount(resolveVenvStub, 3); + await new Promise((resolve) => setTimeout(resolve, 25)); + + assert.strictEqual(resolveVenvStub.callCount, 3); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + }); + + test('refresh fires remove events when a discovered cache entry becomes invalid', async () => { + const environment = await createOwnedEnvironment(); + let sidecarResult: cacheLayout.InlineScriptMetaReadResult = { + kind: 'valid', + metadata: await makeSidecar(), + }; + inspectMetaStub.callsFake(async () => sidecarResult); + setResolvedVenvs([environment]); + await manager.refresh(undefined); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + sidecarResult = { kind: 'invalid' }; + + await manager.refresh(undefined); + + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + assert.strictEqual(listener.callCount, 1); + assert.deepStrictEqual(listener.firstCall.args[0], [ + { kind: EnvironmentChangeKind.remove, environment }, + ]); + }); + + test('does not publish discovery results after disposal while refresh is in flight', async () => { + const environment = await createOwnedEnvironment(); + const sidecar = await makeSidecar(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + let resolveDiscovery: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolveDiscovery = resolve; + }), + ); + const listener = sinon.spy(); + manager.onDidChangeEnvironments(listener); + + const refresh = manager.refresh(undefined); + await waitForStubCall(resolveVenvStub); + manager.dispose(); + resolveDiscovery!(environment); + await refresh; + + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + assert.strictEqual(listener.callCount, 0); + }); + + test('dispose cancels a pending discovery retry', async () => { + const sidecar = await makeSidecar(); + await createOwnedEnvironment(); + setSidecarResults({ [CACHE_KEY]: { kind: 'valid', metadata: sidecar } }); + resolveVenvStub.resolves(undefined); + const retryManager = manager as unknown as { + getDiscoveryRetryDelayMs(attempt: number): number | undefined; + }; + sinon.stub(retryManager, 'getDiscoveryRetryDelayMs').returns(25); + + manager.startActivationDiscovery(); + await waitForStubCall(resolveVenvStub); + manager.dispose(); + await new Promise((resolve) => setTimeout(resolve, 40)); + + assert.strictEqual(resolveVenvStub.callCount, 1); + }); + }); + suite('events and disposal', () => { test('create does not establish an association or fire later-phase events', async () => { const environmentsListener = sinon.spy(); @@ -1760,6 +2295,28 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(listener.callCount, 0); }); + test('preserves a warm association when its lock probe reports EIO', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + await fs.remove(environment.environmentPath.fsPath); + const lockPath = `${path.resolve(environment.sysPrefix)}.lock`; + sinon + .stub(fsExtra, 'lstat') + .callThrough() + .withArgs(lockPath) + .rejects(Object.assign(new Error('I/O error'), { code: 'EIO' })); + clock.tick(5_000); + + assert.strictEqual(await manager.get(uri), undefined); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + }); + assert.strictEqual(listener.callCount, 0); + }); + test('refreshes a warm association rebuilt at the same cache path', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); diff --git a/src/test/managers/builtin/inlineScript/main.unit.test.ts b/src/test/managers/builtin/inlineScript/main.unit.test.ts index d109e318..3531930f 100644 --- a/src/test/managers/builtin/inlineScript/main.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/main.unit.test.ts @@ -7,6 +7,7 @@ import { Disposable, LogOutputChannel, Uri } from 'vscode'; import { EnvironmentManager, PythonEnvironmentApi } from '../../../../api'; import * as pythonApi from '../../../../features/pythonApi'; import * as helpers from '../../../../helpers'; +import { InlineScriptEnvManager } from '../../../../managers/builtin/inlineScript/envManager'; import { registerInlineScriptFeatures } from '../../../../managers/builtin/inlineScript/main'; import { NativePythonFinder } from '../../../../managers/common/nativePythonFinder'; @@ -27,10 +28,15 @@ function makeFakeLog(): LogOutputChannel { } as unknown as LogOutputChannel; } +function nextTurn(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + suite('registerInlineScriptFeatures (feature-flag gate)', () => { let isEnabledStub: sinon.SinonStub; let getPythonApiStub: sinon.SinonStub; let registerEnvironmentManagerStub: sinon.SinonStub; + let startActivationDiscoveryStub: sinon.SinonStub; const nativeFinder = {} as NativePythonFinder; const baseManager = {} as EnvironmentManager; const globalStorageUri = Uri.file('inline-script-global-storage'); @@ -38,6 +44,7 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { setup(() => { isEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled'); registerEnvironmentManagerStub = sinon.stub<[unknown], Disposable>().returns({ dispose: () => undefined }); + startActivationDiscoveryStub = sinon.stub(InlineScriptEnvManager.prototype, 'startActivationDiscovery'); getPythonApiStub = sinon.stub(pythonApi, 'getPythonApi').resolves({ registerEnvironmentManager: registerEnvironmentManagerStub, } as unknown as PythonEnvironmentApi); @@ -74,5 +81,25 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { 'registration disposable should be disposed', ); assert.strictEqual(typeof manager.create, 'function'); + await nextTurn(); + disposables.forEach((disposable) => disposable.dispose()); + }); + + test('when the feature flag is TRUE: defers activation-time discovery to the next turn', async () => { + isEnabledStub.returns(true); + const disposables: Disposable[] = []; + + await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); + + assert.strictEqual( + startActivationDiscoveryStub.callCount, + 0, + 'activation should not synchronously start bootstrap discovery', + ); + + await nextTurn(); + + sinon.assert.calledOnceWithExactly(startActivationDiscoveryStub); + disposables.forEach((disposable) => disposable.dispose()); }); });