diff --git a/src/common/constants.ts b/src/common/constants.ts index 1509f39c..31894953 100644 --- a/src/common/constants.ts +++ b/src/common/constants.ts @@ -4,6 +4,7 @@ export const ENVS_EXTENSION_ID = 'ms-python.vscode-python-envs'; export const PYTHON_EXTENSION_ID = 'ms-python.python'; export const CONDA_MANAGER_ID = `${PYTHON_EXTENSION_ID}:conda`; export const INLINE_SCRIPT_MANAGER_ID = `${PYTHON_EXTENSION_ID}:inline-script`; +export const PYENV_MANAGER_ID = `${PYTHON_EXTENSION_ID}:pyenv`; export const JUPYTER_EXTENSION_ID = 'ms-toolsai.jupyter'; export const EXTENSION_ROOT_DIR = path.dirname(__dirname); export const ISSUES_URL = 'https://github.com/microsoft/vscode-python-environments/issues'; diff --git a/src/common/inlineScriptCacheLayout.ts b/src/common/inlineScriptCacheLayout.ts index e5b410fb..da714143 100644 --- a/src/common/inlineScriptCacheLayout.ts +++ b/src/common/inlineScriptCacheLayout.ts @@ -8,6 +8,7 @@ import { Uri } from 'vscode'; import type { PythonEnvironment } from '../api'; import { INLINE_SCRIPT_MANAGER_ID } from './constants'; import { traceWarn } from './logging'; +import { isFileNotFoundError } from './utils/filesystem'; import { normalizePath } from './utils/pathUtils'; import { isWindows } from './utils/platformUtils'; import { getVenvPythonPath } from './utils/virtualEnvironment'; @@ -273,10 +274,6 @@ function parsePyvenvHome(raw: string): string | undefined { return undefined; } -function isFileNotFoundError(err: unknown): boolean { - return typeof err === 'object' && err !== null && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT'; -} - function isDescendantPath(rootPath: string, candidatePath: string): boolean { const relative = path.relative(rootPath, candidatePath); return ( diff --git a/src/common/utils/filesystem.ts b/src/common/utils/filesystem.ts new file mode 100644 index 00000000..ae774563 --- /dev/null +++ b/src/common/utils/filesystem.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +export function isFileNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/src/extension.ts b/src/extension.ts index e574eeab..fad12bb2 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -657,7 +657,16 @@ export async function activate(context: ExtensionContext): Promise; + readonly metadata: InlineScriptMetadata; + readonly selectedBase: SelectedBaseInterpreter; +} + +interface BuildCacheEntryResult { + readonly environment?: PythonEnvironment; + readonly retainLock?: boolean; +} + +type CacheEntryInspection = + | { readonly kind: 'absent' | 'stale' | 'uncertain' } + | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; + +/** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { + private readonly pendingCreations = new Map>(); + private readonly _onDidChangeEnvironments = new EventEmitter(); public readonly onDidChangeEnvironments: Event = this._onDidChangeEnvironments.event; @@ -39,7 +96,74 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); public readonly iconPath: IconPath = new ThemeIcon('file-code'); - constructor(public readonly log: LogOutputChannel) {} + constructor( + private readonly nativeFinder: NativePythonFinder, + private readonly api: PythonEnvironmentApi, + private readonly baseManager: EnvironmentManager, + private readonly globalStorageUri: Uri, + public readonly log: LogOutputChannel, + ) {} + + async create( + scope: CreateEnvironmentScope, + options?: CreateEnvironmentOptions, + ): Promise { + try { + const scriptUri = this.getScriptUri(scope); + if (!scriptUri) { + this.log.warn('Inline-script environment creation requires exactly one local file URI.'); + return undefined; + } + + const metadata = await readInlineScriptMetadataFromFile(scriptUri); + if (!metadata) { + this.log.warn(`No valid PEP 723 metadata found in ${scriptUri.fsPath}.`); + return undefined; + } + + const packages = [ + ...(metadata.dependencies ?? []), + ...(options?.additionalPackages ?? []), + ].map((value) => value.trim()); + if (packages.some((value) => value.length === 0)) { + this.log.warn(`Inline-script dependencies must not contain empty entries: ${scriptUri.fsPath}.`); + return undefined; + } + + const selectedBase = await this.selectBaseInterpreter(metadata); + if (!selectedBase) { + this.log.warn(`No installed Python satisfies the inline-script requirements for ${scriptUri.fsPath}.`); + return undefined; + } + + const cacheKey = computeCacheKey({ + dependencies: packages, + interpreterPath: selectedBase.canonicalPath, + }); + const pending = this.pendingCreations.get(cacheKey); + if (pending) { + return await pending; + } + + const creation = this.createOrReuseEnvironment({ + cacheKey, + packages, + metadata, + selectedBase, + }); + this.pendingCreations.set(cacheKey, creation); + try { + return await creation; + } finally { + if (this.pendingCreations.get(cacheKey) === creation) { + this.pendingCreations.delete(cacheKey); + } + } + } catch (error) { + this.log.error(`Failed to set up inline-script environment: ${this.errorMessage(error)}`); + return undefined; + } + } async refresh(_scope: RefreshEnvironmentsScope): Promise { return; @@ -61,6 +185,281 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } + 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; + } + + private async selectBaseInterpreter(metadata: InlineScriptMetadata): Promise { + const globalEnvironments = await this.api.getEnvironments('global'); + const reported = globalEnvironments.filter( + (environment) => + BASE_INTERPRETER_MANAGER_IDS.has(environment.envId.managerId) && + (environment.envId.managerId !== CONDA_MANAGER_ID || environment.name === 'base'), + ); + const derivedChecks = await Promise.all( + reported.map(async (environment) => { + if (!path.isAbsolute(environment.sysPrefix)) { + this.log.warn( + `Skipping base interpreter with a non-absolute sysPrefix: ${environment.sysPrefix || ''}.`, + ); + return { environment, derived: true }; + } + return { + environment, + derived: await fs.pathExists(path.join(environment.sysPrefix, 'pyvenv.cfg')), + }; + }), + ); + let candidates = derivedChecks + .filter((candidate) => !candidate.derived) + .map((candidate) => candidate.environment); + + while (candidates.length > 0) { + const environment = pickCompatibleInterpreter(candidates, metadata.requiresPython); + if (!environment) { + return undefined; + } + candidates = candidates.filter((candidate) => candidate !== environment); + + const executable = environment.execInfo?.run.executable; + if (!executable) { + continue; + } + try { + return { environment, canonicalPath: await fs.realpath(executable) }; + } catch (error) { + this.log.warn( + `Skipping base interpreter that cannot be resolved at ${executable}: ${this.errorMessage(error)}`, + ); + } + } + + return undefined; + } + + private async createOrReuseEnvironment({ + cacheKey, + packages, + metadata, + selectedBase, + }: CreateOrReuseEnvironmentOptions): Promise { + const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); + const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); + await fs.ensureDir(cacheRoot.fsPath); + + let lock: AcquiredFileLock | undefined; + try { + lock = await acquireFileLock(envDir.fsPath, { + timeoutMs: CACHE_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_LOCK_RETRY_MS, + }); + + const cached = await this.inspectCacheEntry(cacheRoot, envDir, metadata, selectedBase); + if (cached.kind === 'reusable') { + return cached.environment; + } + if (cached.kind === 'uncertain') { + this.log.warn( + `Preserving an inline-script cache entry that could not be safely inspected: ${envDir.fsPath}`, + ); + return undefined; + } + if (cached.kind === 'stale') { + if (!(await this.removeCacheEntry(envDir))) { + return undefined; + } + } + + const build = await this.buildCacheEntry(envDir, cacheRoot, packages, selectedBase); + if (build.retainLock) { + try { + await lock.retain(); + } catch (error) { + this.log.error( + `Failed to mark the inline-script cache lock as retained: ${this.errorMessage(error)}`, + ); + } + } + return build.environment; + } catch (error) { + this.log.error(`Failed to create or reuse inline-script cache entry: ${this.errorMessage(error)}`); + return undefined; + } finally { + if (lock) { + try { + await lock.release(); + } catch (error) { + this.log.warn(`Failed to release inline-script cache lock: ${this.errorMessage(error)}`); + } + } + } + } + + private async inspectCacheEntry( + cacheRoot: Uri, + envDir: Uri, + metadata: InlineScriptMetadata, + selectedBase: SelectedBaseInterpreter, + ): Promise { + try { + const stat = await fs.lstat(envDir.fsPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { kind: 'uncertain' }; + } + } catch (error) { + return isFileNotFoundError(error) ? { kind: 'absent' } : { kind: 'uncertain' }; + } + + let resolvedEntry: string | undefined; + try { + resolvedEntry = await resolveCacheEntryPath(cacheRoot, envDir); + } catch (error) { + this.log.warn(`Failed to resolve inline-script cache entry: ${this.errorMessage(error)}`); + return { kind: 'uncertain' }; + } + if (!resolvedEntry) { + return { kind: 'uncertain' }; + } + + let sidecarResult; + try { + sidecarResult = await inspectMetaJson(envDir); + } catch { + return { kind: 'uncertain' }; + } + if (sidecarResult.kind !== 'valid') { + return { kind: sidecarResult.kind === 'unavailable' ? 'uncertain' : 'stale' }; + } + const sidecar = sidecarResult.metadata; + if ( + normalizePath(sidecar.baseInterpreterPath) !== normalizePath(selectedBase.canonicalPath) || + sidecar.baseInterpreterVersion !== selectedBase.environment.version + ) { + return { kind: 'stale' }; + } + + const baseInterpreterStatus = await getBaseInterpreterStatus(envDir); + if (baseInterpreterStatus !== 'available') { + return { kind: baseInterpreterStatus === 'missing' ? 'stale' : 'uncertain' }; + } + + const environment = await resolveVenvPythonEnvironmentPath( + getVenvPythonPath(envDir.fsPath), + this.nativeFinder, + this.api, + this, + this.baseManager, + ); + if (!environment) { + return { kind: 'uncertain' }; + } + const environmentStatus = await inspectOwnedCacheEntry(environment, cacheRoot, envDir); + if (environmentStatus !== 'expected') { + return { kind: environmentStatus }; + } + if (!this.areEqualPythonReleases(environment.version, selectedBase.environment.version)) { + return { kind: 'stale' }; + } + const requiresPython = metadata.requiresPython?.trim(); + if (requiresPython && !matchesPythonVersion(requiresPython, environment.version)) { + return { kind: 'stale' }; + } + + try { + await writeMetaJson(envDir, { ...sidecar, lastUsedAt: new Date().toISOString() }); + } catch (error) { + this.log.warn(`Failed to update inline-script cache metadata: ${this.errorMessage(error)}`); + } + return { kind: 'reusable', environment }; + } + + private async buildCacheEntry( + envDir: Uri, + cacheRoot: Uri, + packages: ReadonlyArray, + selectedBase: SelectedBaseInterpreter, + ): Promise { + let result; + try { + result = await createWithProgress( + this.nativeFinder, + this.api, + this.log, + this, + selectedBase.environment, + cacheRoot, + envDir.fsPath, + { install: [...packages], uninstall: [] }, + false, // trackUvEnvironment + ); + } catch (error) { + this.log.error(`Failed to build inline-script environment: ${this.errorMessage(error)}`); + await this.removeCacheEntry(envDir); + return {}; + } + + if (result?.pkgInstallationCancelled) { + this.log.warn( + 'Inline-script package installation was cancelled; retaining the cache lock until explicit cleanup.', + ); + return { retainLock: true }; + } + if (!result?.environment || result.envCreationErr || result.pkgInstallationErr) { + const error = + result?.envCreationErr ?? result?.pkgInstallationErr ?? 'environment creation returned no result'; + this.log.error(`Failed to build inline-script environment: ${error}`); + await this.removeCacheEntry(envDir); + return {}; + } + if ( + !this.areEqualPythonReleases(result.environment.version, selectedBase.environment.version) || + (await inspectOwnedCacheEntry(result.environment, cacheRoot, envDir)) !== 'expected' + ) { + this.log.error('Created inline-script environment does not match the requested cache entry.'); + await this.removeCacheEntry(envDir); + return {}; + } + + try { + await writeMetaJson(envDir, { + schemaVersion: META_SCHEMA_VERSION, + baseInterpreterPath: selectedBase.canonicalPath, + baseInterpreterVersion: selectedBase.environment.version, + lastUsedAt: new Date().toISOString(), + }); + } catch (error) { + this.log.error(`Failed to record inline-script cache metadata: ${this.errorMessage(error)}`); + await this.removeCacheEntry(envDir); + return {}; + } + + return { environment: result.environment }; + } + + private async removeCacheEntry(envDir: Uri): Promise { + try { + await fs.remove(envDir.fsPath); + return true; + } catch (error) { + this.log.error(`Failed to remove incomplete inline-script environment: ${this.errorMessage(error)}`); + return false; + } + } + + private areEqualPythonReleases(actual: string, expected: string): boolean { + const actualRelease = parseReleaseSegments(actual); + const expectedRelease = parseReleaseSegments(expected); + if (actualRelease === undefined || expectedRelease === undefined) { + return false; + } + return compareReleaseSegments(actualRelease, expectedRelease) === 0; + } + + private errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + dispose(): void { this._onDidChangeEnvironments.dispose(); this._onDidChangeEnvironment.dispose(); diff --git a/src/managers/builtin/inlineScriptMain.ts b/src/managers/builtin/inlineScriptMain.ts index fcca5643..6b53c058 100644 --- a/src/managers/builtin/inlineScriptMain.ts +++ b/src/managers/builtin/inlineScriptMain.ts @@ -1,11 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import { Disposable, LogOutputChannel } from 'vscode'; -import { PythonEnvironmentApi } from '../../api'; +import { Disposable, LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi } from '../../api'; import { traceInfo, traceVerbose } from '../../common/logging'; import { getPythonApi } from '../../features/pythonApi'; import { isInlineScriptsFeatureEnabled } from '../../helpers'; +import { NativePythonFinder } from '../common/nativePythonFinder'; import { InlineScriptEnvManager } from './inlineScriptEnvManager'; /** @@ -13,14 +14,20 @@ import { InlineScriptEnvManager } from './inlineScriptEnvManager'; * `python-envs.inlineScripts.enabled` flag is true. The flag is * undeclared in `package.json`, so default users see nothing. */ -export async function registerInlineScriptFeatures(disposables: Disposable[], log: LogOutputChannel): Promise { +export async function registerInlineScriptFeatures( + nativeFinder: NativePythonFinder, + disposables: Disposable[], + log: LogOutputChannel, + baseManager: EnvironmentManager, + globalStorageUri: Uri, +): Promise { if (!isInlineScriptsFeatureEnabled()) { traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); return; } const api: PythonEnvironmentApi = await getPythonApi(); - const mgr = new InlineScriptEnvManager(log); + 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)'); } diff --git a/src/managers/pyenv/pyenvManager.ts b/src/managers/pyenv/pyenvManager.ts index 5602136f..c5dc9828 100644 --- a/src/managers/pyenv/pyenvManager.ts +++ b/src/managers/pyenv/pyenvManager.ts @@ -15,6 +15,7 @@ import { ResolveEnvironmentContext, SetEnvironmentScope, } from '../../api'; +import { PYENV_MANAGER_ID } from '../../common/constants'; import { PyenvStrings } from '../../common/localize'; import { traceError, traceInfo } from '../../common/logging'; import { StopWatch } from '../../common/stopWatch'; @@ -120,7 +121,7 @@ export class PyEnvManager implements EnvironmentManager, Disposable { if (toolSource === 'none') { result = 'tool_not_found'; if (this.projectManager) { - await notifyMissingManagerIfDefault('ms-python.python:pyenv', this.projectManager, this.api); + await notifyMissingManagerIfDefault(PYENV_MANAGER_ID, this.projectManager, this.api); } } } catch (ex) { diff --git a/src/test/common/filesystem.unit.test.ts b/src/test/common/filesystem.unit.test.ts new file mode 100644 index 00000000..e95a10cb --- /dev/null +++ b/src/test/common/filesystem.unit.test.ts @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import assert from 'assert'; +import { isFileNotFoundError } from '../../common/utils/filesystem'; + +suite('filesystem utilities', () => { + test('recognizes ENOENT errors', () => { + const error = Object.assign(new Error('missing'), { code: 'ENOENT' }); + + assert.strictEqual(isFileNotFoundError(error), true); + }); + + test('rejects other errors and non-errors', () => { + assert.strictEqual(isFileNotFoundError(Object.assign(new Error('not a directory'), { code: 'ENOTDIR' })), false); + assert.strictEqual(isFileNotFoundError(new Error('missing code')), false); + assert.strictEqual(isFileNotFoundError(undefined), false); + assert.strictEqual(isFileNotFoundError('ENOENT'), false); + }); +}); diff --git a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts index 91e1bd84..41ccb44d 100644 --- a/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScriptEnvManager.unit.test.ts @@ -2,160 +2,832 @@ // Licensed under the MIT License. import assert from 'assert'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; import * as sinon from 'sinon'; import { LogOutputChannel, Uri } from 'vscode'; -import { EnvironmentManager, PythonEnvironment } from '../../../api'; +import { EnvironmentManager, PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as cacheKey from '../../../common/inlineScriptCacheKey'; +import * as cacheLayout from '../../../common/inlineScriptCacheLayout'; +import * as lockfileApis from '../../../common/lockfile.apis'; +import * as metadataReader from '../../../common/inlineScriptMetadata'; +import { isWindows } from '../../../common/utils/platformUtils'; +import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; import { InlineScriptEnvManager } from '../../../managers/builtin/inlineScriptEnvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; + +const CACHE_KEY = '0123456789abcdef'; +const NOW = new Date('2026-07-21T12:00:00.000Z'); +const VALID_METADATA: metadataReader.InlineScriptMetadata = { + requiresPython: '>=3.11', + dependencies: ['requests'], + range: { start: 0, end: 40 }, +}; function makeFakeLog(): LogOutputChannel { - return sinon.createStubInstance( - class { - info() {} - warn() {} - error() {} - debug() {} - trace() {} - show() {} - dispose() {} - append() {} - appendLine() {} - replace() {} - clear() {} - hide() {} - }, - ) as unknown as LogOutputChannel; + return { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + trace: sinon.stub(), + show: sinon.stub(), + dispose: sinon.stub(), + append: sinon.stub(), + appendLine: sinon.stub(), + replace: sinon.stub(), + clear: sinon.stub(), + hide: sinon.stub(), + } as unknown as LogOutputChannel; } -function makeEnv(): PythonEnvironment { +function makeEnvironment( + managerId: string, + version: string, + executable: string, + sysPrefix: string = path.dirname(executable), + name: string = `Python ${version}`, +): PythonEnvironment { return { - envId: { id: 'fake', managerId: 'ms-python.python:inline-script' }, - name: 'fake', - displayName: 'fake', - displayPath: '/fake', - version: '3.12.0', - environmentPath: Uri.file('/fake'), - execInfo: { run: { executable: '/fake' } }, - sysPrefix: '/fake', + envId: { id: `${managerId}-${version}-${executable}`, managerId }, + name, + displayName: `Python ${version}`, + displayPath: executable, + version, + environmentPath: Uri.file(executable), + execInfo: { run: { executable } }, + sysPrefix, }; } -suite('InlineScriptEnvManager (skeleton)', () => { - let mgr: InlineScriptEnvManager; +const venvPythonPath = getVenvPythonPath; + +suite('InlineScriptEnvManager', () => { + let api: PythonEnvironmentApi; + let apiGetEnvironmentsStub: sinon.SinonStub; + let baseEnvironment: PythonEnvironment; + let baseExecutable: string; + let baseManager: EnvironmentManager; + let computeCacheKeyStub: sinon.SinonStub; + let createWithProgressStub: sinon.SinonStub; + let globalStorageUri: Uri; + let lockStub: sinon.SinonStub; + let manager: InlineScriptEnvManager; + let nativeFinder: NativePythonFinder; + let readMetadataStub: sinon.SinonStub; + let inspectMetaStub: sinon.SinonStub; + let retainLockStub: sinon.SinonStub; + let releaseLockStub: sinon.SinonStub; + let resolveVenvStub: sinon.SinonStub; + let tempRoot: string; + let baseInterpreterStatusStub: sinon.SinonStub; + let writeMetaStub: sinon.SinonStub; + + setup(async () => { + tempRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'inline-script-manager-'))); + globalStorageUri = Uri.file(path.join(tempRoot, 'global-storage')); + baseExecutable = path.join(tempRoot, 'base-python', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(baseExecutable, ''); + baseEnvironment = makeEnvironment('ms-python.python:system', '3.12.4', baseExecutable); + + apiGetEnvironmentsStub = sinon.stub().resolves([baseEnvironment]); + api = { getEnvironments: apiGetEnvironmentsStub } as unknown as PythonEnvironmentApi; + nativeFinder = {} as NativePythonFinder; + baseManager = {} as EnvironmentManager; + + readMetadataStub = sinon.stub(metadataReader, 'readInlineScriptMetadataFromFile').resolves(VALID_METADATA); + computeCacheKeyStub = sinon.stub(cacheKey, 'computeCacheKey').returns(CACHE_KEY); + inspectMetaStub = sinon.stub(cacheLayout, 'inspectMetaJson').resolves({ kind: 'missing' }); + baseInterpreterStatusStub = sinon.stub(cacheLayout, 'getBaseInterpreterStatus').resolves('available'); + writeMetaStub = sinon.stub(cacheLayout, 'writeMetaJson').resolves(); + retainLockStub = sinon.stub().resolves(); + releaseLockStub = sinon.stub().resolves(); + lockStub = sinon + .stub(lockfileApis, 'acquireFileLock') + .resolves({ release: releaseLockStub, retain: retainLockStub }); + resolveVenvStub = sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(undefined); + createWithProgressStub = sinon.stub(venvUtils, 'createWithProgress').callsFake(async (...args: unknown[]) => { + const envDir = args[6] as string; + await fs.outputFile(getVenvPythonPath(envDir), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + getVenvPythonPath(envDir), + envDir, + ), + }; + }); - setup(() => { - mgr = new InlineScriptEnvManager(makeFakeLog()); + sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }); + manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); }); - teardown(() => { - mgr.dispose(); + teardown(async () => { + manager.dispose(); sinon.restore(); + await fs.remove(tempRoot); }); - suite('static metadata', () => { - test('name is "inline-script"', () => { - assert.strictEqual(mgr.name, 'inline-script'); + function scriptUri(name = 'script.py'): Uri { + return Uri.file(path.join(tempRoot, name)); + } + + function envDir(): Uri { + return cacheLayout.getScriptEnvDir(globalStorageUri, CACHE_KEY); + } + + function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta): void { + inspectMetaStub.resolves({ kind: 'valid', metadata }); + } + + suite('static metadata and deferred methods', () => { + test('exposes creation but leaves later-phase methods empty', async () => { + const asInterface: EnvironmentManager = manager; + assert.strictEqual(typeof asInterface.create, 'function'); + assert.strictEqual(asInterface.remove, undefined); + assert.strictEqual(asInterface.quickCreateConfig, undefined); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + assert.strictEqual(await manager.get(scriptUri()), undefined); + assert.strictEqual(await manager.resolve(scriptUri()), undefined); }); - test('displayName is set (for the picker section header)', () => { - assert.ok(mgr.displayName); - assert.ok(mgr.displayName.length > 0); + test('retains inline-script manager presentation metadata', () => { + assert.strictEqual(manager.name, 'inline-script'); + assert.ok(manager.displayName); + assert.strictEqual(manager.preferredPackageManagerId, 'ms-python.python:pip'); + assert.ok(manager.iconPath); + assert.ok(manager.tooltip); }); + }); - test('preferredPackageManagerId is the standard pip manager id', () => { - assert.strictEqual(mgr.preferredPackageManagerId, 'ms-python.python:pip'); + suite('scope and metadata validation', () => { + test('rejects global, empty, multiple, and non-file scopes without reading metadata', async () => { + assert.strictEqual(await manager.create('global'), undefined); + assert.strictEqual(await manager.create([]), undefined); + assert.strictEqual(await manager.create([scriptUri('a.py'), scriptUri('b.py')]), undefined); + assert.strictEqual(await manager.create(Uri.parse('untitled:script.py')), undefined); + assert.strictEqual(readMetadataStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); }); - test('iconPath is defined (renders in the picker)', () => { - assert.ok(mgr.iconPath); + test('accepts a singleton URI array', async () => { + const uri = scriptUri(); + const result = await manager.create([uri]); + assert.ok(result); + assert.ok(readMetadataStub.calledOnceWithExactly(uri)); }); - test('tooltip is defined (shown on hover in the picker)', () => { - assert.ok(mgr.tooltip); + test('returns undefined without cache mutation when metadata is absent', async () => { + readMetadataStub.resolves(undefined); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(apiGetEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); + }); + + test('rejects empty dependency entries before selecting or locking', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, dependencies: ['requests', ' '] }); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(apiGetEnvironmentsStub.callCount, 0); + assert.strictEqual(lockStub.callCount, 0); }); }); - suite('skeleton method behavior', () => { - test('getEnvironments("all") returns []', async () => { - assert.deepStrictEqual(await mgr.getEnvironments('all'), []); + suite('base interpreter selection', () => { + test('excludes derived managers even when they report newer global environments', async () => { + const pipenv = makeEnvironment('ms-python.python:pipenv', '3.14.0', baseExecutable); + apiGetEnvironmentsStub.resolves([pipenv, baseEnvironment]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); }); - test('getEnvironments("global") returns []', async () => { - assert.deepStrictEqual(await mgr.getEnvironments('global'), []); + test('excludes named conda environments even when they are newer than conda base', async () => { + const condaNamed = makeEnvironment( + 'ms-python.python:conda', + '3.14.0', + baseExecutable, + undefined, + 'project-env', + ); + const condaBase = makeEnvironment( + 'ms-python.python:conda', + '3.11.9', + baseExecutable, + undefined, + 'base', + ); + apiGetEnvironmentsStub.resolves([condaNamed, condaBase]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], condaBase); }); - test('getEnvironments(Uri) returns []', async () => { - assert.deepStrictEqual(await mgr.getEnvironments(Uri.file('/tmp/script.py')), []); + test('falls back when the newest compatible interpreter cannot be canonicalized', async () => { + const missingExecutable = path.join(tempRoot, 'missing', 'python'); + const newest = makeEnvironment('ms-python.python:system', '3.13.0', missingExecutable); + apiGetEnvironmentsStub.resolves([baseEnvironment, newest]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); }); - test('get(undefined) returns undefined', async () => { - assert.strictEqual(await mgr.get(undefined), undefined); + test('excludes pyenv virtual environments reported in global scope', async () => { + const pyenvVenvRoot = path.join(tempRoot, 'pyenv', 'versions', 'project-env'); + const pyenvVenvExecutable = venvPythonPath(pyenvVenvRoot); + await fs.outputFile(pyenvVenvExecutable, ''); + await fs.outputFile(path.join(pyenvVenvRoot, 'pyvenv.cfg'), 'home = base'); + const pyenvVenv = makeEnvironment( + 'ms-python.python:pyenv', + '3.13.0', + pyenvVenvExecutable, + pyenvVenvRoot, + ); + apiGetEnvironmentsStub.resolves([pyenvVenv, baseEnvironment]); + + await manager.create(scriptUri()); + + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); }); - test('get(Uri) returns undefined', async () => { - assert.strictEqual(await mgr.get(Uri.file('/tmp/script.py')), undefined); + test('does not invoke creation when no installed base satisfies requires-python', async () => { + readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '>=3.13' }); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(lockStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); }); - test('set(scope, env) is a no-op and does not throw', async () => { - await assert.doesNotReject(mgr.set(Uri.file('/tmp/script.py'), makeEnv())); - await assert.doesNotReject(mgr.set(undefined, undefined)); + test('skips base records with empty or relative sysPrefix values', async () => { + const emptyPrefixExecutable = path.join(tempRoot, 'empty-prefix-python'); + const relativePrefixExecutable = path.join(tempRoot, 'relative-prefix-python'); + await fs.outputFile(emptyPrefixExecutable, ''); + await fs.outputFile(relativePrefixExecutable, ''); + apiGetEnvironmentsStub.resolves([ + makeEnvironment('ms-python.python:system', '3.14.0', emptyPrefixExecutable, ''), + makeEnvironment('ms-python.python:system', '3.13.0', relativePrefixExecutable, 'relative-prefix'), + baseEnvironment, + ]); + + assert.ok(await manager.create(scriptUri())); + sinon.assert.calledWith(computeCacheKeyStub, { + dependencies: ['requests'], + interpreterPath: await fs.realpath(baseExecutable), + }); }); + }); - test('refresh(scope) is a no-op and does not throw', async () => { - await assert.doesNotReject(mgr.refresh(undefined)); - await assert.doesNotReject(mgr.refresh(Uri.file('/tmp/script.py'))); + suite('cache creation', () => { + test('hashes and installs metadata plus additional packages, then writes the sidecar', async () => { + const result = await manager.create(scriptUri(), { additionalPackages: ['pytest'] }); + + assert.ok(result); + assert.deepStrictEqual(computeCacheKeyStub.firstCall.args[0], { + dependencies: ['requests', 'pytest'], + interpreterPath: baseExecutable, + }); + assert.strictEqual(createWithProgressStub.firstCall.args[0], nativeFinder); + assert.strictEqual(createWithProgressStub.firstCall.args[1], api); + assert.strictEqual(createWithProgressStub.firstCall.args[3], manager); + assert.strictEqual(createWithProgressStub.firstCall.args[4], baseEnvironment); + assert.strictEqual(createWithProgressStub.firstCall.args[5].fsPath, cacheLayout.getScriptEnvCacheRoot(globalStorageUri).fsPath); + assert.strictEqual(createWithProgressStub.firstCall.args[6], envDir().fsPath); + assert.deepStrictEqual(createWithProgressStub.firstCall.args[7], { + install: ['requests', 'pytest'], + uninstall: [], + }); + assert.deepStrictEqual(writeMetaStub.firstCall.args, [ + envDir(), + { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }, + ]); + assert.strictEqual( + createWithProgressStub.firstCall.args[8], + false, + 'inline-script cache entries must not be tracked as workspace uv environments', + ); + assert.ok(releaseLockStub.calledOnce); }); - test('resolve(Uri) returns undefined', async () => { - assert.strictEqual(await mgr.resolve(Uri.file('/tmp/script.py')), undefined); + 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); }); - test('does not implement optional create / remove / quickCreateConfig', () => { - // Cast via the interface to probe optional methods (the concrete class type doesn't declare them). - const asInterface: EnvironmentManager = mgr; - assert.strictEqual(asInterface.create, undefined); - assert.strictEqual(asInterface.remove, undefined); - assert.strictEqual(asInterface.quickCreateConfig, undefined); + test('coalesces simultaneous same-key creation within one extension host', async () => { + let continueCreation: (() => void) | undefined; + let creationStarted: (() => void) | undefined; + let secondCallHashed: (() => void) | undefined; + const started = new Promise((resolve) => { + creationStarted = resolve; + }); + const secondHashed = new Promise((resolve) => { + secondCallHashed = resolve; + }); + const gate = new Promise((resolve) => { + continueCreation = resolve; + }); + computeCacheKeyStub.callsFake(() => { + if (computeCacheKeyStub.callCount === 2) { + secondCallHashed!(); + } + return CACHE_KEY; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + creationStarted!(); + await gate; + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + }; + }); + + const first = manager.create(scriptUri('a.py')); + await started; + const second = manager.create(scriptUri('b.py')); + await secondHashed; + continueCreation!(); + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.strictEqual(firstResult, secondResult); + assert.strictEqual(lockStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('returns undefined without building when the cache lock cannot be acquired', async () => { + lockStub.rejects(Object.assign(new Error('already locked'), { code: 'ELOCKED' })); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(createWithProgressStub.callCount, 0); }); }); - suite('events', () => { - test('onDidChangeEnvironments is exposed and subscribable', () => { - const disposable = mgr.onDidChangeEnvironments(() => undefined); - assert.ok(disposable); - disposable.dispose(); + suite('cache reuse', () => { + test('returns a valid cached environment and refreshes lastUsedAt', async () => { + await fs.ensureDir(envDir().fsPath); + const sidecar: cacheLayout.InlineScriptEnvMeta = { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: '2026-07-01T00:00:00.000Z', + }; + const cached = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ); + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + setSidecar(sidecar); + resolveVenvStub.resolves(cached); + + const result = await manager.create(scriptUri()); + + assert.strictEqual(result, cached); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.ok(baseInterpreterStatusStub.calledOnceWithExactly(envDir())); + assert.strictEqual(resolveVenvStub.firstCall.args[0], venvPythonPath(envDir().fsPath)); + assert.deepStrictEqual(writeMetaStub.firstCall.args, [ + envDir(), + { ...sidecar, lastUsedAt: NOW.toISOString() }, + ]); + }); + + test('returns a valid hit even when the last-used timestamp cannot be updated', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: '2026-07-01T00:00:00.000Z', + }); + const cached = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ); + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + resolveVenvStub.resolves(cached); + writeMetaStub.rejects(new Error('read-only filesystem')); + + assert.strictEqual(await manager.create(scriptUri()), cached); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('preserves a valid cache entry when its environment cannot be resolved', async () => { + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves(undefined); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(resolveVenvStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + + test('removes and rebuilds a cache entry whose sidecar names another base', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: path.join(tempRoot, 'different-python'), + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + + const result = await manager.create(scriptUri()); + + assert.ok(result); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 1); }); - test('onDidChangeEnvironment is exposed and subscribable', () => { - const disposable = mgr.onDidChangeEnvironment(() => undefined); - assert.ok(disposable); - disposable.dispose(); + test('rebuilds when the base version changed at the same canonical path', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: '3.11.9', + lastUsedAt: NOW.toISOString(), + }); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 1); }); - test('skeleton methods do not fire any events', async () => { - const envsListener = sinon.spy(); - const envListener = sinon.spy(); - mgr.onDidChangeEnvironments(envsListener); - mgr.onDidChangeEnvironment(envListener); + test('preserves the entry when resolution returns an environment owned by another manager', async () => { + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:system', + '3.11.9', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); - await mgr.getEnvironments('all'); - await mgr.get(undefined); - await mgr.set(Uri.file('/tmp/script.py'), makeEnv()); - await mgr.refresh(undefined); - await mgr.resolve(Uri.file('/tmp/script.py')); + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + + test('rebuilds an inline-owned entry whose resolved version is unparseable', async () => { + await fs.outputFile(getVenvPythonPath(envDir().fsPath), ''); + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:inline-script', + 'Unknown', + getVenvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); - assert.strictEqual(envsListener.callCount, 0, 'getEnvironments/refresh must not fire envs event'); - assert.strictEqual(envListener.callCount, 0, 'set must not fire env event in the skeleton'); + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(await fs.pathExists(markerPath), false); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('rebuilds when the resolved environment no longer satisfies requires-python', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:inline-script', + '3.10.0', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('rebuilds when the cached Python differs from the selected base but still satisfies the script', async () => { + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + resolveVenvStub.resolves( + makeEnvironment( + 'ms-python.python:inline-script', + '3.11.9', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + ); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + for (const metadataKind of ['missing', 'invalid'] as const) { + test(`rebuilds an existing cache entry when metadata is ${metadataKind}`, async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + inspectMetaStub.resolves({ kind: metadataKind }); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(await fs.pathExists(markerPath), false); + assert.strictEqual(createWithProgressStub.callCount, 1); + assert.strictEqual(writeMetaStub.callCount, 1); + }); + } + + test('preserves an existing cache entry when metadata is unavailable', async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + inspectMetaStub.resolves({ kind: 'unavailable' }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + + test('preserves an existing cache entry when metadata inspection rejects', async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + inspectMetaStub.rejects(new Error('transient read failure')); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('preserves an existing cache entry when its base interpreter cannot be inspected', async () => { + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + baseInterpreterStatusStub.resolves('unavailable'); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('rebuilds an existing cache entry when its base interpreter is definitively missing', async () => { + await fs.ensureDir(envDir().fsPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }); + baseInterpreterStatusStub.resolves('missing'); + + assert.ok(await manager.create(scriptUri())); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + + test('does not inspect or modify an entry resolving outside the physical cache root', async function () { + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + const externalEnv = path.join(tempRoot, 'external-env'); + const markerPath = path.join(externalEnv, 'keep.txt'); + await fs.ensureDir(cacheRoot.fsPath); + await fs.outputFile(markerPath, 'keep'); + try { + await fs.symlink(externalEnv, envDir().fsPath, isWindows() ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + return; + } + throw error; + } + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual((await fs.lstat(envDir().fsPath)).isSymbolicLink(), true); + assert.strictEqual(inspectMetaStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + + test('does not inspect or modify an entry aliasing another hash directory', async function () { + const cacheRoot = cacheLayout.getScriptEnvCacheRoot(globalStorageUri); + const otherEnv = path.join(cacheRoot.fsPath, 'fedcba9876543210'); + const markerPath = path.join(otherEnv, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + try { + await fs.symlink(otherEnv, envDir().fsPath, isWindows() ? 'junction' : 'dir'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + this.skip(); + return; + } + throw error; + } + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.readFile(markerPath, 'utf8'), 'keep'); + assert.strictEqual((await fs.lstat(envDir().fsPath)).isSymbolicLink(), true); + assert.strictEqual(inspectMetaStub.callCount, 0); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); + }); + + suite('transaction rollback', () => { + test('retains the partial environment and lock when package installation is cancelled', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + pkgInstallationErr: 'Canceled', + pkgInstallationCancelled: true, + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), true); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.ok(retainLockStub.calledOnce); + assert.ok(releaseLockStub.calledOnce); + }); + + test('keeps a failed lock-retain transition fail-closed', async () => { + createWithProgressStub.resolves({ + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(envDir().fsPath), + envDir().fsPath, + ), + pkgInstallationErr: 'Canceled', + pkgInstallationCancelled: true, + }); + retainLockStub.rejects(Object.assign(new Error('retention failed'), { code: 'EACCES' })); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.ok(retainLockStub.calledOnce); + assert.ok(releaseLockStub.calledOnce); + }); + + test('removes the partial environment when package installation fails', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.ensureDir(target); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ), + pkgInstallationErr: 'network failure', + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.ok(releaseLockStub.calledOnce); + }); + + test('removes the new environment when sidecar writing fails', async () => { + writeMetaStub.rejects(new Error('disk full')); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.ok(releaseLockStub.calledOnce); + }); + + test('removes a partial environment when createWithProgress throws', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + await fs.ensureDir(args[6] as string); + throw new Error('unexpected create failure'); + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.ok(releaseLockStub.calledOnce); + }); + + test('rejects and removes a created environment with a different Python release', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.11.9', + venvPythonPath(target), + target, + ), + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(writeMetaStub.callCount, 0); + }); + + test('rejects and removes a created environment outside the expected cache directory', async () => { + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + const otherRoot = path.join(tempRoot, 'unexpected-env'); + const otherPython = venvPythonPath(otherRoot); + await fs.outputFile(venvPythonPath(target), ''); + await fs.outputFile(otherPython, ''); + return { + environment: makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + otherPython, + otherRoot, + ), + }; + }); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(envDir().fsPath), false); + assert.strictEqual(writeMetaStub.callCount, 0); }); }); - suite('disposal', () => { - test('dispose() does not throw', () => { - assert.doesNotThrow(() => mgr.dispose()); + suite('events and disposal', () => { + test('create does not establish an association or fire later-phase events', async () => { + const environmentsListener = sinon.spy(); + const environmentListener = sinon.spy(); + manager.onDidChangeEnvironments(environmentsListener); + manager.onDidChangeEnvironment(environmentListener); + + assert.ok(await manager.create(scriptUri())); + assert.deepStrictEqual(await manager.getEnvironments('all'), []); + assert.strictEqual(await manager.get(scriptUri()), undefined); + assert.strictEqual(environmentsListener.callCount, 0); + assert.strictEqual(environmentListener.callCount, 0); }); - test('dispose() is idempotent', () => { - mgr.dispose(); - assert.doesNotThrow(() => mgr.dispose()); + test('dispose is idempotent', () => { + manager.dispose(); + assert.doesNotThrow(() => manager.dispose()); }); }); }); diff --git a/src/test/managers/builtin/inlineScriptMain.unit.test.ts b/src/test/managers/builtin/inlineScriptMain.unit.test.ts index 4d5ac3a3..d1e99edb 100644 --- a/src/test/managers/builtin/inlineScriptMain.unit.test.ts +++ b/src/test/managers/builtin/inlineScriptMain.unit.test.ts @@ -3,11 +3,12 @@ import assert from 'assert'; import * as sinon from 'sinon'; -import { Disposable, LogOutputChannel } from 'vscode'; -import { PythonEnvironmentApi } from '../../../api'; +import { Disposable, LogOutputChannel, Uri } from 'vscode'; +import { EnvironmentManager, PythonEnvironmentApi } from '../../../api'; import * as pythonApi from '../../../features/pythonApi'; import * as helpers from '../../../helpers'; import { registerInlineScriptFeatures } from '../../../managers/builtin/inlineScriptMain'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; function makeFakeLog(): LogOutputChannel { return { @@ -30,6 +31,9 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { let isEnabledStub: sinon.SinonStub; let getPythonApiStub: sinon.SinonStub; let registerEnvironmentManagerStub: sinon.SinonStub; + const nativeFinder = {} as NativePythonFinder; + const baseManager = {} as EnvironmentManager; + const globalStorageUri = Uri.file('inline-script-global-storage'); setup(() => { isEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled'); @@ -47,7 +51,7 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(false); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(disposables, makeFakeLog()); + 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'); @@ -58,10 +62,17 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(true); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(disposables, makeFakeLog()); + 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.ok(disposables.includes(manager), 'manager itself should be disposed'); + assert.ok( + disposables.includes(registerEnvironmentManagerStub.firstCall.returnValue), + 'registration disposable should be disposed', + ); + assert.strictEqual(typeof manager.create, 'function'); }); });