From a8b3813bc752ef88743d04d3a90d135c14c8f76b Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 10:03:25 -0700 Subject: [PATCH 1/2] Route PEP 723 scripts to inline environments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cb82ae9-7424-40a4-9156-8c54ac6e0895 --- src/common/inlineScript/cacheLayout.ts | 76 +- src/common/inlineScript/routingRegistry.ts | 195 ++ src/extension.ts | 9 +- src/features/envManagers.ts | 204 +- src/features/inlineScript/lazyDetector.ts | 58 +- .../builtin/inlineScript/envManager.ts | 1296 ++++++++- src/managers/builtin/inlineScript/main.ts | 4 +- .../inlineScript/cacheLayout.unit.test.ts | 70 +- .../envManagers.lastKnown.unit.test.ts | 202 +- .../inlineScript/lazyDetector.unit.test.ts | 344 ++- src/test/features/pythonApi.unit.test.ts | 102 +- .../inlineScript/envManager.unit.test.ts | 2338 ++++++++++++++++- .../builtin/inlineScript/main.unit.test.ts | 20 +- 13 files changed, 4478 insertions(+), 440 deletions(-) create mode 100644 src/common/inlineScript/routingRegistry.ts diff --git a/src/common/inlineScript/cacheLayout.ts b/src/common/inlineScript/cacheLayout.ts index 1371040b2..ddc593380 100644 --- a/src/common/inlineScript/cacheLayout.ts +++ b/src/common/inlineScript/cacheLayout.ts @@ -22,6 +22,8 @@ export const META_JSON_FILENAME = '.meta.json'; * Schema version embedded in every {@link InlineScriptEnvMeta}. */ export const META_SCHEMA_VERSION = 1 as const; +export const SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH = 64; +export const MAX_SOURCE_METADATA_IDENTITY_HASHES = 8; const MAX_META_JSON_BYTES = 1024 * 1024; @@ -38,11 +40,13 @@ export interface InlineScriptEnvMeta { readonly baseInterpreterVersion: string; /** Last successful use as a canonical UTC string produced by `Date.toISOString()`. */ readonly lastUsedAt: string; + /** Bounded SHA-256 hashes of metadata identities proven for this cache entry. */ + readonly sourceMetadataIdentityHashes?: readonly string[]; } export type InlineScriptMetaReadResult = | { readonly kind: 'valid'; readonly metadata: InlineScriptEnvMeta } - | { readonly kind: 'missing' | 'invalid' | 'unavailable' }; + | { readonly kind: 'missing' | 'invalid' | 'unsupported' | 'unavailable' }; export type BaseInterpreterStatus = 'available' | 'missing' | 'unavailable'; export type CacheEnvironmentInspection = 'expected' | 'stale' | 'uncertain'; @@ -160,6 +164,10 @@ export async function inspectMetaJson(envDir: Uri): Promise undefined); + await fsapi.move(tmpPath, finalPath, { overwrite: true }); + } } catch (err) { await fsapi.remove(tmpPath).catch(() => undefined); throw err; } } +export function hashSourceMetadataIdentity(identity: string): string { + return crypto.createHash('sha256').update(identity, 'utf8').digest('hex'); +} + +export function mergeSourceMetadataIdentityHashes( + existing: readonly string[] | undefined, + current: string | undefined, +): readonly string[] | undefined { + const ordered = [...(existing ?? [])]; + if (current && !ordered.includes(current)) { + ordered.push(current); + } + if (ordered.length === 0) { + return undefined; + } + return Object.freeze(ordered.slice(-MAX_SOURCE_METADATA_IDENTITY_HASHES)); +} + /** * Pure selector: returns the env-dir paths whose age exceeds `ttlMs`. */ @@ -285,11 +320,17 @@ function isNonEmptyTrimmedString(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.trim() === value; } -function validateMeta(value: unknown): InlineScriptEnvMeta | undefined { +function validateMeta(value: unknown): InlineScriptEnvMeta | 'unsupported' | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return undefined; } const obj = value as Record; + if (typeof obj.schemaVersion !== 'number') { + return undefined; + } + if (obj.schemaVersion > META_SCHEMA_VERSION) { + return 'unsupported'; + } if (obj.schemaVersion !== META_SCHEMA_VERSION) { return undefined; } @@ -302,15 +343,44 @@ function validateMeta(value: unknown): InlineScriptEnvMeta | undefined { if (!isCanonicalIsoTimestamp(obj.lastUsedAt)) { return undefined; } + const sourceMetadataIdentityHashes = validateSourceMetadataIdentityHashes(obj.sourceMetadataIdentityHashes); + if (obj.sourceMetadataIdentityHashes !== undefined && sourceMetadataIdentityHashes === undefined) { + return undefined; + } return { schemaVersion: META_SCHEMA_VERSION, baseInterpreterPath: obj.baseInterpreterPath, baseInterpreterVersion: obj.baseInterpreterVersion, lastUsedAt: obj.lastUsedAt, + ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), }; } +function validateSourceMetadataIdentityHashes(value: unknown): readonly string[] | undefined { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SOURCE_METADATA_IDENTITY_HASHES) { + return undefined; + } + const hashes: string[] = []; + const seen = new Set(); + for (const item of value) { + if ( + typeof item !== 'string' || + item.length !== SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH || + !/^[0-9a-f]+$/.test(item) || + seen.has(item) + ) { + return undefined; + } + seen.add(item); + hashes.push(item); + } + return Object.freeze(hashes); +} + function isCanonicalIsoTimestamp(value: unknown): value is string { if (typeof value !== 'string') { return false; diff --git a/src/common/inlineScript/routingRegistry.ts b/src/common/inlineScript/routingRegistry.ts new file mode 100644 index 000000000..36d7b7046 --- /dev/null +++ b/src/common/inlineScript/routingRegistry.ts @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; +import { Disposable, Event, EventEmitter, Uri } from 'vscode'; +import { normalizeDependency } from './cacheKey'; +import { InlineScriptMetadata } from './metadata'; +import { normalizePath } from '../utils/pathUtils'; + +export interface InlineScriptRouteabilityChangeEvent { + readonly uri: Uri; + readonly previousRouteable: boolean; + readonly routeable: boolean; +} + +export interface InlineScriptMetadataChangeEvent { + readonly uri: Uri; + readonly metadata: InlineScriptMetadata | undefined; + readonly metadataIdentity: string | undefined; + readonly metadataRevision: number; +} + +interface ScriptRoutingState { + readonly uri?: Uri; + readonly metadata?: InlineScriptMetadata; + readonly metadataIdentity?: string; + readonly metadataRevision: number; + readonly validatedAssociation: boolean; +} + +export class InlineScriptRoutingRegistry implements Disposable { + private readonly states = new Map(); + private readonly _onDidChangeRouteability = new EventEmitter(); + private readonly _onDidChangeMetadata = new EventEmitter(); + + public readonly onDidChangeRouteability: Event = + this._onDidChangeRouteability.event; + + public readonly onDidChangeMetadata: Event = this._onDidChangeMetadata.event; + + public setMetadata(uri: Uri, metadata: InlineScriptMetadata | undefined): void { + const scriptPath = getInlineScriptRoutingKey(uri); + if (!scriptPath) { + return; + } + const metadataIdentity = getInlineScriptMetadataRoutingIdentity(metadata); + this.update( + scriptPath, + (state) => { + const currentRevision = state?.metadataRevision ?? 0; + return { + ...state, + uri, + metadata, + metadataIdentity, + metadataRevision: currentRevision + 1, + }; + }, + true, + ); + } + + public clearMetadata(uri: Uri): void { + const scriptPath = getInlineScriptRoutingKey(uri); + if (!scriptPath) { + return; + } + this.update( + scriptPath, + (state) => { + const currentRevision = state?.metadataRevision ?? 0; + return { + ...state, + uri, + metadata: undefined, + metadataIdentity: undefined, + metadataRevision: currentRevision + 1, + }; + }, + true, + ); + } + + public getMetadata(script: Uri | string): InlineScriptMetadata | undefined { + const scriptPath = getInlineScriptRoutingKey(script); + return scriptPath ? this.states.get(scriptPath)?.metadata : undefined; + } + + public getMetadataIdentity(script: Uri | string): string | undefined { + const scriptPath = getInlineScriptRoutingKey(script); + return scriptPath ? this.states.get(scriptPath)?.metadataIdentity : undefined; + } + + public getMetadataRevision(script: Uri | string): number { + const scriptPath = getInlineScriptRoutingKey(script); + return scriptPath ? (this.states.get(scriptPath)?.metadataRevision ?? 0) : 0; + } + + public getUri(script: Uri | string): Uri | undefined { + const scriptPath = getInlineScriptRoutingKey(script); + return scriptPath ? this.states.get(scriptPath)?.uri : undefined; + } + + public setValidatedAssociation(script: Uri | string, validatedAssociation: boolean): void { + const scriptPath = getInlineScriptRoutingKey(script); + if (!scriptPath) { + return; + } + this.update(scriptPath, (state) => ({ + ...state, + uri: script instanceof Uri ? script : state.uri, + validatedAssociation, + })); + } + + public hasValidatedAssociation(script: Uri | string): boolean { + const scriptPath = getInlineScriptRoutingKey(script); + return scriptPath ? this.states.get(scriptPath)?.validatedAssociation === true : false; + } + + public shouldRoute(uri: Uri): boolean { + const scriptPath = getInlineScriptRoutingKey(uri); + return scriptPath ? this.isRouteable(this.states.get(scriptPath)) : false; + } + + public dispose(): void { + this.states.clear(); + this._onDidChangeMetadata.dispose(); + this._onDidChangeRouteability.dispose(); + } + + private update( + scriptPath: string, + updater: (state: ScriptRoutingState) => ScriptRoutingState, + fireMetadataChange: boolean = false, + ): void { + const previous = this.states.get(scriptPath) ?? { metadataRevision: 0, validatedAssociation: false }; + const previousRouteable = this.isRouteable(previous); + const next = updater(previous); + + if (!next.metadata && !next.validatedAssociation) { + this.states.delete(scriptPath); + } else { + this.states.set(scriptPath, next); + } + + if (fireMetadataChange && next.uri) { + this._onDidChangeMetadata.fire({ + uri: next.uri, + metadata: next.metadata, + metadataIdentity: next.metadataIdentity, + metadataRevision: next.metadataRevision, + }); + } + + const routeable = this.isRouteable(next); + if (previousRouteable !== routeable && next.uri) { + this._onDidChangeRouteability.fire({ + uri: next.uri, + previousRouteable, + routeable, + }); + } + } + + private isRouteable(state: ScriptRoutingState | undefined): boolean { + return !!state?.metadata && state.validatedAssociation; + } +} + +export function getInlineScriptRoutingKey(script: Uri | string): string | undefined { + if (typeof script === 'string') { + return normalizePath(script); + } + if (script.scheme !== 'file') { + return undefined; + } + if (path.extname(script.fsPath).toLowerCase() !== '.py') { + return undefined; + } + return normalizePath(script.fsPath); +} + +export function getInlineScriptMetadataRoutingIdentity(metadata: InlineScriptMetadata | undefined): string | undefined { + if (!metadata) { + return undefined; + } + const normalizedDependencies = Array.from( + new Set((metadata.dependencies ?? []).map((dependency) => normalizeDependency(dependency)).filter(Boolean)), + ).sort(); + return JSON.stringify({ + requiresPython: metadata.requiresPython?.trim() ?? '', + dependencies: normalizedDependencies, + }); +} diff --git a/src/extension.ts b/src/extension.ts index 1d2d98aee..b929d3793 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -66,6 +66,7 @@ import { import { PythonEnvironmentManagers } from './features/envManagers'; import { EnvVarManager, PythonEnvVariableManager } from './features/execution/envVariableManager'; import { InlineScriptLazyDetector } from './features/inlineScript/lazyDetector'; +import { InlineScriptRoutingRegistry } from './common/inlineScript/routingRegistry'; import { applyInitialEnvironmentSelection, registerInterpreterSettingsChangeListener, @@ -181,10 +182,13 @@ export async function activate(context: ExtensionContext): Promise = new Map(); private _packageManagers: Map = new Map(); + private readonly subscriptions: Disposable[] = []; /** * The last environment announced as "active" for each scope. @@ -64,6 +69,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { * Only mutated by setEnvironment() / setEnvironments() / refreshEnvironment(). */ private readonly _activeSelection = new Map(); + private readonly _inlineRoutingOverrides = new Map(); private readonly _selectionRevisions = new Map(); private readonly _selectionOperationCounters = new Map(); @@ -92,7 +98,18 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { public onDidChangeActiveEnvironment: Event = this._onDidChangeActiveEnvironment.event; - constructor(private readonly pm: PythonProjectManager) {} + constructor( + private readonly pm: PythonProjectManager, + private readonly inlineScriptRouting: InlineScriptRoutingRegistry = new InlineScriptRoutingRegistry(), + ) { + this.subscriptions.push( + this.inlineScriptRouting.onDidChangeRouteability((e) => { + void this.handleInlineScriptRouteabilityChange(e).catch((error) => + traceError('Failed to refresh inline-script routing:', error), + ); + }), + ); + } public registerEnvironmentManager(manager: EnvironmentManager, options?: { extensionId?: string }): Disposable { const registrationStopWatch = new StopWatch(); @@ -185,6 +202,8 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { public dispose() { this._environmentManagers.clear(); this._packageManagers.clear(); + this._inlineRoutingOverrides.clear(); + this.subscriptions.forEach((subscription) => subscription.dispose()); this._onDidChangeEnvironmentManager.dispose(); this._onDidChangePackageManager.dispose(); this._onDidChangeEnvironments.dispose(); @@ -198,10 +217,11 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { * * Priority: * 1. Use an exact per-script project setting. - * 2. Use a cached per-script inline selection. - * 3. Use the containing project or default setting. - * 4. Fall back to the cached project/global environment's manager. - * 5. If context is a string or PythonEnvironment, return its manager directly. + * 2. Use an explicit in-session per-script override. + * 3. Use a recognized per-script inline association. + * 4. Use the containing project or default setting. + * 5. Fall back to the cached project/global environment's manager. + * 6. If context is a string or PythonEnvironment, return its manager directly. */ public getEnvironmentManager(context: EnvironmentManagerScope): InternalEnvironmentManager | undefined { if (this._environmentManagers.size === 0) { @@ -211,47 +231,23 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { if (context === undefined || context instanceof Uri) { const project = context ? this.pm.get(context) : undefined; - if ( - context instanceof Uri && - project && - normalizePath(project.uri.fsPath) === normalizePath(context.fsPath) - ) { - const exactManagerId = getProjectEnvironmentManagerSetting(this.pm, context); - const exactManager = exactManagerId - ? this._environmentManagers.get(exactManagerId) - : undefined; - if (exactManager) { - return exactManager; - } + const exactManager = + context instanceof Uri ? this.getExactProjectEnvironmentManager(context, project) : undefined; + if (exactManager) { + return exactManager; } if (context instanceof Uri) { - const inlineEnv = this._activeSelection.get(this.getInlineScriptSelectionKey(context)); - if (inlineEnv?.envId.managerId === INLINE_SCRIPT_MANAGER_ID) { - const inlineManager = this._environmentManagers.get(INLINE_SCRIPT_MANAGER_ID); - if (inlineManager) { - return inlineManager; - } + const overrideManager = this.getInlineRoutingOverrideManager(context); + if (overrideManager) { + return overrideManager; } - } - - const defaultEnvManagerId = getDefaultEnvManagerSetting(this.pm, context); - if (defaultEnvManagerId !== undefined) { - const settingsManager = this._environmentManagers.get(defaultEnvManagerId); - if (settingsManager) { - return settingsManager; + const inlineManager = this._environmentManagers.get(INLINE_SCRIPT_MANAGER_ID); + if (inlineManager && this.inlineScriptRouting.shouldRoute(context)) { + return inlineManager; } } - - const cachedEnv = this._activeSelection.get(project ? project.uri.toString() : 'global'); - if (cachedEnv) { - const cachedManager = this._environmentManagers.get(cachedEnv.envId.managerId); - if (cachedManager) { - return cachedManager; - } - } - - return undefined; + return this.getConfiguredOrCachedEnvironmentManager(context, project); } if (typeof context === 'string') { @@ -364,6 +360,8 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { const project = scope ? this.pm.get(scope) : undefined; const key = this.getActiveSelectionKey(scope, manager, project); const operation = this.beginSelectionOperation(key); + const publishInlineSelection = + !(scope instanceof Uri) || this.shouldPublishInlineSelectionImmediately(scope, manager); const inlineClearOperation = scope instanceof Uri && manager.id !== INLINE_SCRIPT_MANAGER_ID ? this.beginSelectionOperation(this.getInlineScriptSelectionKey(scope)) @@ -396,8 +394,12 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { } if (scope instanceof Uri) { + this.updateInlineRoutingOverride(scope, manager, environment); this.clearInlineActiveSelection(scope, manager, inlineClearOperation); } + if (!publishInlineSelection) { + return; + } if (!this.commitSelectionOperation(key, operation)) { return; } @@ -471,7 +473,11 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { await setAllManagerSettings(settings); } selections.forEach((selection) => { + this.updateInlineRoutingOverride(selection.scope, manager, environment); this.clearInlineActiveSelection(selection.scope, manager, selection.inlineClearOperation); + if (!selection.publishInlineSelection) { + return; + } if (!this.commitSelectionOperation(selection.key, selection.operation)) { return; } @@ -536,6 +542,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { await manager.set(uris); await Promise.all( selections.map(async (selection) => { + this.clearInlineRoutingOverride(selection.scope); const newEnv = await manager.get(selection.scope); if (!this.commitSelectionOperation(selection.key, selection.operation)) { return; @@ -693,6 +700,117 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { return `inline-script:${normalizePath(scope.fsPath)}`; } + private getExactProjectEnvironmentManager( + scope: Uri, + project: PythonProject | undefined, + ): InternalEnvironmentManager | undefined { + if (!project || normalizePath(project.uri.fsPath) !== normalizePath(scope.fsPath)) { + return undefined; + } + const exactManagerId = getProjectEnvironmentManagerSetting(this.pm, scope); + return exactManagerId ? this._environmentManagers.get(exactManagerId) : undefined; + } + + private getConfiguredOrCachedEnvironmentManager( + context: Uri | undefined, + project: PythonProject | undefined, + ): InternalEnvironmentManager | undefined { + const defaultEnvManagerId = getDefaultEnvManagerSetting(this.pm, context); + if (defaultEnvManagerId !== undefined) { + const settingsManager = this._environmentManagers.get(defaultEnvManagerId); + if (settingsManager) { + return settingsManager; + } + } + + const cachedEnv = this._activeSelection.get(this.getProjectSelectionKey(project)); + if (cachedEnv) { + const cachedManager = this._environmentManagers.get(cachedEnv.envId.managerId); + if (cachedManager) { + return cachedManager; + } + } + + return undefined; + } + + private getProjectSelectionKey(project: PythonProject | undefined): string { + return project ? project.uri.toString() : 'global'; + } + + private getInlineRoutingOverrideManager(scope: Uri): InternalEnvironmentManager | undefined { + const managerId = this._inlineRoutingOverrides.get(this.getInlineScriptSelectionKey(scope)); + return managerId ? this._environmentManagers.get(managerId) : undefined; + } + + private updateInlineRoutingOverride( + scope: Uri, + manager: InternalEnvironmentManager, + environment: PythonEnvironment | undefined, + ): void { + const key = this.getInlineScriptSelectionKey(scope); + if (!environment || manager.id === INLINE_SCRIPT_MANAGER_ID) { + this._inlineRoutingOverrides.delete(key); + return; + } + this._inlineRoutingOverrides.set(key, manager.id); + } + + private clearInlineRoutingOverride(scope: Uri): void { + this._inlineRoutingOverrides.delete(this.getInlineScriptSelectionKey(scope)); + } + + private async handleInlineScriptRouteabilityChange( + event: InlineScriptRouteabilityChangeEvent, + ): Promise { + const { uri, previousRouteable } = event; + const project = this.pm.get(uri); + const exactManager = this.getExactProjectEnvironmentManager(uri, project); + if (exactManager) { + if (exactManager.id === INLINE_SCRIPT_MANAGER_ID) { + await this.refreshEnvironment(uri); + } + return; + } + + if (this.getInlineRoutingOverrideManager(uri)) { + return; + } + + const manager = this.getEnvironmentManager(uri); + if (!manager) { + return; + } + + const refreshedProject = this.pm.get(uri); + const key = this.getActiveSelectionKey(uri, manager, refreshedProject); + const operation = this.beginSelectionOperation(key); + const newEnv = await manager.get(uri); + const latestProject = this.pm.get(uri); + if (this.getEnvironmentManager(uri) !== manager || !this.commitSelectionOperation(key, operation)) { + return; + } + + const inlineKey = this.getInlineScriptSelectionKey(uri); + const oldEnv = previousRouteable + ? this._activeSelection.get(inlineKey) + : this._activeSelection.get(this.getProjectSelectionKey(latestProject)); + + if (manager.id !== INLINE_SCRIPT_MANAGER_ID) { + this._activeSelection.delete(inlineKey); + } + this._activeSelection.set(key, newEnv); + if (!this.isSameEnvironment(oldEnv, newEnv)) { + await this.fireActiveEnvironmentEvents([ + { + uri: this.getActiveSelectionUri(uri, manager, latestProject), + old: oldEnv, + new: newEnv, + }, + ]); + } + } + private beginPendingSelection(scope: Uri, manager: InternalEnvironmentManager): PendingEnvironmentSelection { const project = this.pm.get(scope); const key = this.getActiveSelectionKey(scope, manager, project); @@ -701,6 +819,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { project, key, operation: this.beginSelectionOperation(key), + publishInlineSelection: this.shouldPublishInlineSelectionImmediately(scope, manager), inlineClearOperation: manager.id === INLINE_SCRIPT_MANAGER_ID ? undefined @@ -708,6 +827,10 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { }; } + private shouldPublishInlineSelectionImmediately(scope: Uri, manager: InternalEnvironmentManager): boolean { + return manager.id !== INLINE_SCRIPT_MANAGER_ID || this.inlineScriptRouting.shouldRoute(scope); + } + private clearInlineActiveSelection( scope: Uri, manager: InternalEnvironmentManager, @@ -797,5 +920,6 @@ interface PendingEnvironmentSelection { readonly project: PythonProject | undefined; readonly key: string; readonly operation: number; + readonly publishInlineSelection: boolean; readonly inlineClearOperation: number | undefined; } diff --git a/src/features/inlineScript/lazyDetector.ts b/src/features/inlineScript/lazyDetector.ts index fb9756e1f..5595928b6 100644 --- a/src/features/inlineScript/lazyDetector.ts +++ b/src/features/inlineScript/lazyDetector.ts @@ -2,16 +2,19 @@ // Licensed under the MIT License. import * as path from 'path'; -import { Disposable, TextDocument, TextDocumentChangeEvent, Uri } from 'vscode'; +import { Disposable, TextDocument, TextDocumentChangeEvent, TextDocumentContentChangeEvent, Uri } from 'vscode'; import { readInlineScriptMetadataFromFile } from '../../common/inlineScript/metadata'; +import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { traceVerbose, traceWarn } from '../../common/logging'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { getOpenTextDocuments, getWorkspaceFolder, + onDidDeleteFiles, onDidChangeTextDocument, onDidOpenTextDocument, + onDidRenameFiles, onDidSaveTextDocument, } from '../../common/workspace.apis'; @@ -58,6 +61,8 @@ export class InlineScriptLazyDetector implements Disposable { // already torn down. private disposed = false; + constructor(private readonly routingRegistry: InlineScriptRoutingRegistry = new InlineScriptRoutingRegistry()) {} + /** * Subscribe to workspace text-document events. Safe to call once * during extension activation. @@ -83,6 +88,8 @@ export class InlineScriptLazyDetector implements Disposable { onDidOpenTextDocument((doc) => this.handleDocument(doc, 'open')), onDidSaveTextDocument((doc) => this.handleDocument(doc, 'save')), onDidChangeTextDocument((e) => this.handleChange(e)), + onDidDeleteFiles((e) => e.files.forEach((uri) => this.clearRouteability(uri))), + onDidRenameFiles((e) => e.files.forEach((file) => this.clearRouteability(file.oldUri))), ); // Defer the catch-up pass so we observe `workspace.textDocuments` // AFTER VS Code finishes registering the document that triggered @@ -99,19 +106,13 @@ export class InlineScriptLazyDetector implements Disposable { * `handleDocument` keeps this safe to call repeatedly. */ private replayOpenDocuments(source: 'activate'): void { - // Restrict the replay to documents that the per-event handler - // would actually look at. This keeps the activation log - // proportional to the work the detector will do — on an - // editor with many tabs open we would otherwise dump every - // URI just to throw most of them away inside - // `handleDocument`. - const openDocs = getOpenTextDocuments().filter((d) => shouldHandleUri(d.uri)); + const openDocs = getOpenTextDocuments().filter((d) => shouldTrackRoutingUri(d.uri)); if (openDocs.length === 0) { - traceVerbose(`inlineScriptLazyDetector: ${source} replay found no candidate .py documents`); + traceVerbose(`inlineScriptLazyDetector: ${source} replay found no candidate local .py documents`); return; } traceVerbose( - `inlineScriptLazyDetector: ${source} replay over ${openDocs.length} candidate .py document(s): ` + + `inlineScriptLazyDetector: ${source} replay over ${openDocs.length} candidate local .py document(s): ` + openDocs.map((d) => d.uri.fsPath).join(', '), ); for (const doc of openDocs) { @@ -134,7 +135,7 @@ export class InlineScriptLazyDetector implements Disposable { // the `Trace` log level — to avoid flooding the default // `Info` channel. traceVerbose(`inlineScriptLazyDetector: event received (${trigger}) ${uri.toString()}`); - if (!shouldHandleUri(uri)) { + if (!shouldTrackRoutingUri(uri)) { traceVerbose( `inlineScriptLazyDetector: skipped (${trigger}) ${uri.toString()} ` + `(scheme='${uri.scheme}', extname='${path.extname(uri.fsPath).toLowerCase()}', ` + @@ -142,6 +143,11 @@ export class InlineScriptLazyDetector implements Disposable { ); return; } + if (trigger === 'open' && doc.isDirty) { + traceVerbose(`inlineScriptLazyDetector: withholding dirty document metadata for ${uri.toString()}`); + this.clearRouteability(uri); + return; + } const key = uri.toString(); const existing = this.inFlight.get(key); if (existing) { @@ -152,20 +158,21 @@ export class InlineScriptLazyDetector implements Disposable { await existing; return; } - const work = this.processOnce(uri, trigger).finally(() => { + const work = this.processOnce(uri, trigger, shouldHandleUri(uri)).finally(() => { this.inFlight.delete(key); }); this.inFlight.set(key, work); await work; } - private async processOnce(uri: Uri, trigger: 'open' | 'save'): Promise { + private async processOnce(uri: Uri, trigger: 'open' | 'save', shouldEmitTelemetry: boolean): Promise { try { const metadata = await readInlineScriptMetadataFromFile(uri); if (this.disposed) { return; } - if (metadata === undefined) { + this.routingRegistry.setMetadata(uri, metadata); + if (!shouldEmitTelemetry || metadata === undefined) { return; } const key = uri.toString(); @@ -209,6 +216,10 @@ export class InlineScriptLazyDetector implements Disposable { if (e.contentChanges.length === 0) { return; } + const metadata = this.routingRegistry.getMetadata(e.document.uri); + if (metadata && this.contentChangesMayAffectMetadata(e.contentChanges, metadata.range.end)) { + this.clearRouteability(e.document.uri); + } const key = e.document.uri.toString(); if (!this.detectedUris.has(key)) { return; @@ -224,6 +235,21 @@ export class InlineScriptLazyDetector implements Disposable { ); sendTelemetryEvent(EventNames.INLINE_SCRIPT_EDITED, duration); } + + private contentChangesMayAffectMetadata( + changes: readonly TextDocumentContentChangeEvent[], + metadataEnd: number, + ): boolean { + return changes.some((change) => change.rangeOffset < metadataEnd); + } + + private clearRouteability(uri: Uri): void { + if (!shouldTrackRoutingUri(uri)) { + return; + } + this.routingRegistry.clearMetadata(uri); + this.routingRegistry.setValidatedAssociation(uri, false); + } } /** @@ -244,3 +270,7 @@ export function shouldHandleUri(uri: Uri): boolean { } return true; } + +function shouldTrackRoutingUri(uri: Uri): boolean { + return getInlineScriptRoutingKey(uri) !== undefined; +} diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index d68d9dda6..81deac9c6 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -24,6 +24,9 @@ import { getErrorMessage } from '../../../common/errors/utils'; import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey'; import { CacheEnvironmentInspection, + InlineScriptEnvMeta, + hashSourceMetadataIdentity, + mergeSourceMetadataIdentityHashes, META_SCHEMA_VERSION, getBaseInterpreterStatus, getScriptEnvCacheRoot, @@ -35,6 +38,11 @@ import { } from '../../../common/inlineScript/cacheLayout'; import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter'; import { InlineScriptMetadata, readInlineScriptMetadataFromFile } from '../../../common/inlineScript/metadata'; +import { + getInlineScriptMetadataRoutingIdentity, + InlineScriptMetadataChangeEvent, + InlineScriptRoutingRegistry, +} from '../../../common/inlineScript/routingRegistry'; import { CONDA_MANAGER_ID, ENVS_EXTENSION_ID, @@ -48,6 +56,7 @@ import { isFileNotFoundError } from '../../../common/utils/filesystem'; import { normalizePath } from '../../../common/utils/pathUtils'; import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; +import { getOpenTextDocuments, onDidDeleteFiles, onDidRenameFiles } from '../../../common/workspace.apis'; import { NativePythonFinder } from '../../common/nativePythonFinder'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; @@ -64,6 +73,7 @@ const CACHE_LOCK_RETRY_MS = 500; const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000; /** Workspace-state key for PEP 723 script path to environment executable associations. */ export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`; +const PERSISTED_ASSOCIATION_SCHEMA_VERSION = 1 as const; interface SelectedBaseInterpreter { readonly environment: PythonEnvironment; @@ -75,6 +85,7 @@ interface CreateOrReuseEnvironmentOptions { readonly packages: ReadonlyArray; readonly metadata: InlineScriptMetadata; readonly selectedBase: SelectedBaseInterpreter; + readonly pendingCreation: PendingCreationContext; } interface BuildCacheEntryResult { @@ -82,21 +93,61 @@ interface BuildCacheEntryResult { readonly retainLock?: boolean; } +interface PendingCreationContext { + promise: Promise; + sourceMetadataIdentityHashes?: readonly string[]; + hasStartedRecordingSourceMetadataIdentityHashes: boolean; + recordedSourceMetadataIdentityHashes?: readonly string[]; +} + +interface MergeCacheEntrySourceMetadataIdentityHashResult { + readonly success: boolean; + readonly sourceMetadataIdentityHashes?: readonly string[]; +} + type CacheEntryInspection = | { readonly kind: 'absent' | 'stale' | 'uncertain' } | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; +interface PendingAssociationValidation { + readonly metadataIdentity: string; + readonly associationRevision: number; + readonly promise: Promise; +} + +interface PendingMetadataRefresh { + readonly metadataIdentity: string; + readonly metadataRevision: number; + readonly associationRevision: number; + readonly promise: Promise; +} + +interface ParsedPersistedAssociations { + readonly rawEntries: Record; + readonly records: PersistedInlineScriptEnvironments; + readonly invalidKeys: Set; +} + +interface SavedMetadataSnapshot { + readonly metadata?: InlineScriptMetadata; + readonly identity?: string; +} + /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingSetups = new Map>(); - private readonly pendingCreations = new Map>(); + private readonly pendingCreations = new Map(); private readonly directlyResolvedBaseInterpreters = new Map(); private baseInterpreterInstallationQueue: Promise = Promise.resolve(); - private readonly pendingRehydrations = new Map>(); + private readonly pendingRehydrations = new Map(); + private readonly pendingMetadataRefreshes = new Map(); private readonly fsPathToEnv = new Map(); - private readonly fsPathToPersistedEnvPath = new Map(); + private readonly fsPathToPersistedAssociation = new Map(); private readonly cachedAssociationValidatedAt = new Map(); + private readonly lastValidatedMetadataIdentities = new Map(); + private readonly lastValidatedMetadataIdentityProofs = new Map(); private readonly associationRevisions = new Map(); + private readonly subscriptions: Disposable[] = []; private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); @@ -123,7 +174,33 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly baseManager: EnvironmentManager, private readonly globalStorageUri: Uri, public readonly log: LogOutputChannel, - ) {} + private readonly routingRegistry: InlineScriptRoutingRegistry = new InlineScriptRoutingRegistry(), + ) { + this.subscriptions.push( + this.routingRegistry.onDidChangeMetadata((event) => { + void this.handleSavedMetadataChange(event).catch((error) => { + this.log.warn(`Failed to refresh inline-script routing state: ${getErrorMessage(error)}`); + }); + }), + onDidDeleteFiles((event) => { + void this.clearAssociationsForScripts(event.files).catch((error) => { + this.log.warn(`Failed to clear inline-script associations for deleted files: ${getErrorMessage(error)}`); + }); + }), + onDidRenameFiles((event) => { + void this.clearAssociationsForScripts(event.files.map((file) => file.oldUri)).catch((error) => { + this.log.warn(`Failed to clear inline-script associations for renamed files: ${getErrorMessage(error)}`); + }); + }), + ); + queueMicrotask(() => { + void this.initializePersistedAssociations().catch((error) => { + this.log.warn( + `Failed to prime inline-script environment associations: ${getErrorMessage(error)}`, + ); + }); + }); + } async create( scope: CreateEnvironmentScope, @@ -191,22 +268,47 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { dependencies: packages, interpreterPath: selectedBase.canonicalPath, }); + const metadataIdentity = getInlineScriptMetadataRoutingIdentity(metadata); + const sourceMetadataIdentityHash = metadataIdentity ? hashSourceMetadataIdentity(metadataIdentity) : undefined; const pending = this.pendingCreations.get(cacheKey); if (pending) { - return await pending; + const joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes = + pending.hasStartedRecordingSourceMetadataIdentityHashes; + this.addPendingCreationSourceMetadataIdentityHash(pending, sourceMetadataIdentityHash); + const environment = await pending.promise; + return await this.finalizeCreateForScript( + cacheKey, + environment, + sourceMetadataIdentityHash, + pending, + joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes, + ); } - + const pendingCreation: PendingCreationContext = { + promise: Promise.resolve(undefined), + sourceMetadataIdentityHashes: mergeSourceMetadataIdentityHashes(undefined, sourceMetadataIdentityHash), + hasStartedRecordingSourceMetadataIdentityHashes: false, + }; const creation = this.createOrReuseEnvironment({ cacheKey, packages, metadata, selectedBase, + pendingCreation, }); - this.pendingCreations.set(cacheKey, creation); + pendingCreation.promise = creation; + this.pendingCreations.set(cacheKey, pendingCreation); try { - return await creation; + const environment = await creation; + return await this.finalizeCreateForScript( + cacheKey, + environment, + sourceMetadataIdentityHash, + pendingCreation, + false, + ); } finally { - if (this.pendingCreations.get(cacheKey) === creation) { + if (this.pendingCreations.get(cacheKey) === pendingCreation) { this.pendingCreations.delete(cacheKey); } } @@ -227,6 +329,45 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ]); } + private addPendingCreationSourceMetadataIdentityHash( + pendingCreation: PendingCreationContext, + sourceMetadataIdentityHash: string | undefined, + ): void { + pendingCreation.sourceMetadataIdentityHashes = mergeSourceMetadataIdentityHashes( + pendingCreation.sourceMetadataIdentityHashes, + sourceMetadataIdentityHash, + ); + } + + private async finalizeCreateForScript( + cacheKey: string, + environment: PythonEnvironment | undefined, + sourceMetadataIdentityHash: string | undefined, + pendingCreation: PendingCreationContext, + joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes: boolean, + ): Promise { + if (!environment || !sourceMetadataIdentityHash) { + return environment; + } + if ( + pendingCreation.recordedSourceMetadataIdentityHashes?.includes(sourceMetadataIdentityHash) !== true && + joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes + ) { + const mergeResult = await this.mergeCacheEntrySourceMetadataIdentityHash( + cacheKey, + sourceMetadataIdentityHash, + ); + if (!mergeResult.success) { + this.log.warn( + `Failed to durably record inline-script cache provenance for ${cacheKey}; returning no environment to the caller.`, + ); + return undefined; + } + pendingCreation.recordedSourceMetadataIdentityHashes = mergeResult.sourceMetadataIdentityHashes; + } + return environment; + } + async refresh(_scope: RefreshEnvironmentsScope): Promise { return; } @@ -272,15 +413,22 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const updates: PendingScriptUpdate[] = []; for (const script of scripts) { const before = await this.getAssociationForMutation(script.scriptPath); - const hadPersistedAssociation = this.fsPathToPersistedEnvPath.has(script.scriptPath); - const hasSamePersistedEnvironment = - environmentPath !== undefined && - normalizePath(this.fsPathToPersistedEnvPath.get(script.scriptPath) ?? '') === - normalizePath(environmentPath); - const needsPersistence = environment ? !hasSamePersistedEnvironment : hadPersistedAssociation; + const persistedAssociation = this.getPersistedAssociationFromMemory(script.scriptPath); + const savedMetadata = environment ? await this.getSavedMetadataForPersistence(script.uri) : undefined; + const sourceMetadataIdentity = + environment && savedMetadata + ? await this.resolveVerifiedSourceMetadataIdentity(script, environment, savedMetadata) + : undefined; + const nextPersistedAssociation = environmentPath + ? this.createPersistedAssociationRecord(environmentPath, sourceMetadataIdentity, savedMetadata?.identity) + : undefined; + const needsPersistence = nextPersistedAssociation + ? !this.isSamePersistedAssociation(persistedAssociation, nextPersistedAssociation) + : persistedAssociation !== undefined; const shouldNotify = - (!this.isSameEnvironment(before, environment) && !hasSamePersistedEnvironment) || - (!environment && hadPersistedAssociation); + (!this.isSameEnvironment(before, environment) && + !this.isSamePersistedAssociation(persistedAssociation, nextPersistedAssociation)) || + (!environment && persistedAssociation !== undefined); const hasPendingRehydration = this.pendingRehydrations.has(script.scriptPath); const cached = this.fsPathToEnv.get(script.scriptPath); const needsMemoryUpdate = environment ? cached !== environment : cached !== undefined; @@ -288,6 +436,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { updates.push({ ...script, before, + persistedAssociation: nextPersistedAssociation, needsPersistence, shouldNotify, }); @@ -303,7 +452,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { await this.updatePersistedAssociations( persistenceUpdates.map((update) => ({ scriptPath: update.scriptPath, - environmentPath, + persistedAssociation: update.persistedAssociation, })), ); } @@ -315,14 +464,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { for (const update of updates) { this.bumpAssociationRevision(update.scriptPath); this.pendingRehydrations.delete(update.scriptPath); + this.pendingMetadataRefreshes.delete(update.scriptPath); if (environment) { this.fsPathToEnv.set(update.scriptPath, environment); - this.fsPathToPersistedEnvPath.set(update.scriptPath, environmentPath!); - this.cachedAssociationValidatedAt.set(update.scriptPath, Date.now()); + this.fsPathToPersistedAssociation.set(update.scriptPath, update.persistedAssociation!); + this.invalidateCachedAssociationValidation(update.scriptPath); } else { this.fsPathToEnv.delete(update.scriptPath); - this.fsPathToPersistedEnvPath.delete(update.scriptPath); - this.cachedAssociationValidatedAt.delete(update.scriptPath); + this.fsPathToPersistedAssociation.delete(update.scriptPath); + this.invalidateCachedAssociationValidation(update.scriptPath); } if (update.shouldNotify) { this._onDidChangeEnvironment.fire({ @@ -332,6 +482,16 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }); } } + + await Promise.all( + updates.map(async (update) => { + if (!environment) { + this.clearValidatedRouteableState(update.uri); + return; + } + await this.updateValidatedStateForSelection(update); + }), + ); } private async getInternal(scope: GetEnvironmentScope): Promise { @@ -346,15 +506,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - const environment = await this.getAssociation(normalizePath(scope.fsPath), scope); - if (!environment) { - return undefined; - } - - const requiresPython = metadata.requiresPython?.trim(); - return requiresPython && !this.matchesInstallConstraint(requiresPython, environment.version) - ? undefined - : environment; + return this.getAssociationForMetadata( + normalizePath(scope.fsPath), + scope, + metadata, + ); } private getScriptUris(scope: SetEnvironmentScope): ScriptReference[] { @@ -379,39 +535,72 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return scripts; } - private async getAssociation(scriptPath: string, scriptUri: Uri): Promise { + private async getAssociationForMetadata( + scriptPath: string, + scriptUri: Uri, + metadata: InlineScriptMetadata, + ): Promise { const pending = this.pendingRehydrations.get(scriptPath); - if (pending) { - return pending; - } - const cached = this.fsPathToEnv.get(scriptPath); const revision = this.associationRevisions.get(scriptPath) ?? 0; + const metadataIdentity = getInlineScriptMetadataRoutingIdentity(metadata)!; + const forceFreshValidation = + this.fsPathToPersistedAssociation.get(scriptPath)?.metadataBinding.kind === 'pending'; + if ( + pending && + pending.metadataIdentity === metadataIdentity && + pending.associationRevision === revision + ) { + return pending.promise; + } if (cached) { const validatedAt = this.cachedAssociationValidatedAt.get(scriptPath); if ( + !forceFreshValidation && validatedAt !== undefined && + this.lastValidatedMetadataIdentities.get(scriptPath) === metadataIdentity && Date.now() - validatedAt < CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS ) { return cached; } - const validation = this.validateCachedAssociation(scriptPath, scriptUri, cached, revision); - this.pendingRehydrations.set(scriptPath, validation); + const validation = this.validateCachedAssociation( + scriptPath, + scriptUri, + cached, + revision, + metadataIdentity, + metadata, + ); + this.pendingRehydrations.set(scriptPath, { + metadataIdentity, + associationRevision: revision, + promise: validation, + }); try { return await validation; } finally { - if (this.pendingRehydrations.get(scriptPath) === validation) { + if (this.pendingRehydrations.get(scriptPath)?.promise === validation) { this.pendingRehydrations.delete(scriptPath); } } } - const rehydration = this.rehydrateAssociation(scriptPath, scriptUri, revision); - this.pendingRehydrations.set(scriptPath, rehydration); + const rehydration = this.rehydrateAssociation( + scriptPath, + scriptUri, + revision, + metadataIdentity, + metadata, + ); + this.pendingRehydrations.set(scriptPath, { + metadataIdentity, + associationRevision: revision, + promise: rehydration, + }); try { return await rehydration; } finally { - if (this.pendingRehydrations.get(scriptPath) === rehydration) { + if (this.pendingRehydrations.get(scriptPath)?.promise === rehydration) { this.pendingRehydrations.delete(scriptPath); } } @@ -431,8 +620,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scriptUri: Uri, cached: PythonEnvironment, revision: number, + metadataIdentity: string, + metadata: InlineScriptMetadata, ): Promise { const environmentPath = cached.environmentPath.fsPath; + const expectedPersistedAssociation = this.fsPathToPersistedAssociation.get(scriptPath); const envDirPath = path.dirname(path.dirname(environmentPath)); const busy = await this.isCacheEntryBusy(envDirPath); if (!this.isCurrentAssociationRevision(scriptPath, revision)) { @@ -470,13 +662,35 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { environmentPath, revision, scriptUri, + expectedPersistedAssociation, ); return undefined; } if (ownership !== 'expected') { return undefined; } + const metadataMatch = this.inspectAssociationMetadata(scriptPath, metadataIdentity, true); + if (!this.isCurrentAssociationRevision(scriptPath, revision)) { + return this.fsPathToEnv.get(scriptPath); + } + if (metadataMatch === 'mismatched') { + return undefined; + } + const metadataIdentityProven = await this.currentCacheEntryProvesSourceMetadataIdentity( + resolved, + metadataIdentity, + metadata, + ); + if (!this.isCurrentAssociationRevision(scriptPath, revision)) { + return this.fsPathToEnv.get(scriptPath); + } + const current = this.fsPathToEnv.get(scriptPath); this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); + this.lastValidatedMetadataIdentities.set(scriptPath, metadataIdentity); + this.lastValidatedMetadataIdentityProofs.set(scriptPath, metadataIdentityProven); + if (current && this.isSameEnvironment(current, resolved)) { + return current; + } if (cached.version === resolved.version) { return cached; } @@ -494,6 +708,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { environmentPath, revision, scriptUri, + expectedPersistedAssociation, ); } } catch (error) { @@ -511,6 +726,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { environmentPath, revision, scriptUri, + expectedPersistedAssociation, ); } } else { @@ -526,14 +742,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scriptPath: string, scriptUri: Uri, revision: number, + metadataIdentity: string, + metadata: InlineScriptMetadata, ): Promise { - let environmentPath: string | undefined; + let persistedAssociation: PersistedAssociationRecord | undefined; try { - environmentPath = await this.getPersistedAssociation(scriptPath); + persistedAssociation = await this.getPersistedAssociation(scriptPath); } catch (error) { this.log.warn(`Failed to read inline-script environment association: ${getErrorMessage(error)}`); return undefined; } + const environmentPath = persistedAssociation?.environmentPath; if (!environmentPath) { return undefined; } @@ -541,7 +760,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return this.fsPathToEnv.get(scriptPath); } if (!path.isAbsolute(environmentPath)) { - await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); + await this.removeStalePersistedAssociation( + scriptPath, + environmentPath, + revision, + scriptUri, + persistedAssociation, + ); return undefined; } const envDirPath = path.dirname(path.dirname(environmentPath)); @@ -553,14 +778,26 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const stat = await fs.stat(environmentPath); if (!stat.isFile()) { if (!(await this.isCacheEntryBusy(envDirPath))) { - await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); + await this.removeStalePersistedAssociation( + scriptPath, + environmentPath, + revision, + scriptUri, + persistedAssociation, + ); } return undefined; } } catch (error) { if (this.isDefinitivelyStalePathError(error)) { if (!(await this.isCacheEntryBusy(envDirPath))) { - await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); + await this.removeStalePersistedAssociation( + scriptPath, + environmentPath, + revision, + scriptUri, + persistedAssociation, + ); } } else { this.log.warn( @@ -603,22 +840,66 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } if (ownership === 'stale') { - await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); + await this.removeStalePersistedAssociation( + scriptPath, + environmentPath, + revision, + scriptUri, + persistedAssociation, + ); return undefined; } if (ownership !== 'expected') { return undefined; } + const metadataMatch = this.inspectAssociationMetadata(scriptPath, metadataIdentity, true); + if (metadataMatch === 'mismatched') { + return undefined; + } + const metadataIdentityProven = await this.currentCacheEntryProvesSourceMetadataIdentity( + resolved, + metadataIdentity, + metadata, + ); + if (!this.isCurrentAssociationRevision(scriptPath, revision)) { + return this.fsPathToEnv.get(scriptPath); + } + const current = this.fsPathToEnv.get(scriptPath); + this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); + this.lastValidatedMetadataIdentities.set(scriptPath, metadataIdentity); + this.lastValidatedMetadataIdentityProofs.set(scriptPath, metadataIdentityProven); + if (current && this.isSameEnvironment(current, resolved)) { + return current; + } if (!this.isCurrentAssociationRevision(scriptPath, revision) || this.fsPathToEnv.has(scriptPath)) { return this.fsPathToEnv.get(scriptPath); } this.fsPathToEnv.set(scriptPath, resolved); - this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); this._onDidChangeEnvironment.fire({ uri: scriptUri, old: undefined, new: resolved }); return resolved; } + private inspectAssociationMetadata( + scriptPath: string, + metadataIdentity: string, + allowUnboundAssociation: boolean, + ): 'matched' | 'pending' | 'legacy' | 'mismatched' { + const persistedAssociation = this.fsPathToPersistedAssociation.get(scriptPath); + if (!persistedAssociation) { + return 'mismatched'; + } + if (persistedAssociation.metadataBinding.kind === 'matched') { + return persistedAssociation.metadataBinding.sourceIdentity === metadataIdentity ? 'matched' : 'mismatched'; + } + if (persistedAssociation.metadataBinding.kind === 'pending') { + return persistedAssociation.metadataBinding.sourceIdentity === metadataIdentity && allowUnboundAssociation + ? 'pending' + : 'mismatched'; + } + return allowUnboundAssociation ? 'legacy' : 'mismatched'; + } + private async inspectAssociationOwnership(environment: PythonEnvironment): Promise { if (environment.envId.managerId !== INLINE_SCRIPT_MANAGER_ID || !path.isAbsolute(environment.sysPrefix)) { return 'uncertain'; @@ -639,33 +920,445 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); } - private async getPersistedAssociation(scriptPath: string): Promise { + private async handleSavedMetadataChange(event: InlineScriptMetadataChangeEvent): Promise { + if (event.metadata === undefined) { + this.clearValidatedRouteableState(event.uri); + return; + } + await this.refreshValidatedAssociationForMetadata( + event.uri, + event.metadata, + event.metadataIdentity ?? getInlineScriptMetadataRoutingIdentity(event.metadata)!, + event.metadataRevision, + ); + } + + private async refreshValidatedAssociationForMetadata( + uri: Uri, + metadata: InlineScriptMetadata, + metadataIdentity: string, + metadataRevision: number, + ): Promise { + const scriptPath = normalizePath(uri.fsPath); + const associationRevision = this.associationRevisions.get(scriptPath) ?? 0; + const pendingRefresh = this.pendingMetadataRefreshes.get(scriptPath); + if ( + pendingRefresh && + pendingRefresh.metadataIdentity === metadataIdentity && + pendingRefresh.metadataRevision === metadataRevision && + pendingRefresh.associationRevision === associationRevision + ) { + return pendingRefresh.promise; + } + const refresh = this.refreshValidatedAssociationForMetadataInternal( + scriptPath, + uri, + metadata, + metadataIdentity, + metadataRevision, + associationRevision, + ); + this.pendingMetadataRefreshes.set(scriptPath, { + metadataIdentity, + metadataRevision, + associationRevision, + promise: refresh, + }); + try { + await refresh; + } finally { + if (this.pendingMetadataRefreshes.get(scriptPath)?.promise === refresh) { + this.pendingMetadataRefreshes.delete(scriptPath); + } + } + } + + private async refreshValidatedAssociationForMetadataInternal( + scriptPath: string, + uri: Uri, + metadata: InlineScriptMetadata, + metadataIdentity: string, + metadataRevision: number, + associationRevision: number, + ): Promise { + const environment = await this.getAssociationForMetadata(scriptPath, uri, metadata); + if (!this.isCurrentMetadataRefreshTask(uri, metadataIdentity, metadataRevision, scriptPath, associationRevision)) { + return; + } + if (!environment) { + this.clearValidatedRouteableState(uri); + return; + } + let metadataIdentityProven = this.lastValidatedMetadataIdentityProofs.get(scriptPath); + if ( + this.lastValidatedMetadataIdentities.get(scriptPath) !== metadataIdentity || + metadataIdentityProven === undefined + ) { + metadataIdentityProven = await this.currentCacheEntryProvesSourceMetadataIdentity( + environment, + metadataIdentity, + metadata, + ); + if ( + !this.isCurrentMetadataRefreshTask( + uri, + metadataIdentity, + metadataRevision, + scriptPath, + associationRevision, + ) + ) { + return; + } + this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); + this.lastValidatedMetadataIdentities.set(scriptPath, metadataIdentity); + this.lastValidatedMetadataIdentityProofs.set(scriptPath, metadataIdentityProven); + } + if (metadataIdentityProven !== true) { + this.clearValidatedRouteableState(uri); + return; + } + const metadataMatch = this.inspectAssociationMetadata(scriptPath, metadataIdentity, true); + if (metadataMatch === 'pending') { + let bindResult = await this.bindPendingMetadataIdentity( + scriptPath, + environment.environmentPath.fsPath, + metadataIdentity, + metadataRevision, + associationRevision, + uri, + ); + if (!this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision)) { + return; + } + if ( + bindResult === 'stale' && + !this.isCurrentAssociationRevision(scriptPath, associationRevision) + ) { + const currentAssociation = this.fsPathToPersistedAssociation.get(scriptPath); + const currentAssociationRevision = this.associationRevisions.get(scriptPath) ?? 0; + if ( + currentAssociation?.metadataBinding.kind === 'pending' && + currentAssociation.metadataBinding.sourceIdentity === metadataIdentity && + normalizePath(currentAssociation.environmentPath) === + normalizePath(environment.environmentPath.fsPath) + ) { + bindResult = await this.bindPendingMetadataIdentity( + scriptPath, + environment.environmentPath.fsPath, + metadataIdentity, + metadataRevision, + currentAssociationRevision, + uri, + ); + if ( + !this.isCurrentMetadataRefreshTask( + uri, + metadataIdentity, + metadataRevision, + scriptPath, + currentAssociationRevision, + ) + ) { + return; + } + } + } else if (!this.isCurrentAssociationRevision(scriptPath, associationRevision)) { + return; + } + if (bindResult !== 'bound') { + const currentAssociation = this.fsPathToPersistedAssociation.get(scriptPath); + if ( + currentAssociation?.metadataBinding.kind === 'pending' && + currentAssociation.metadataBinding.sourceIdentity === metadataIdentity && + normalizePath(currentAssociation.environmentPath) === + normalizePath(environment.environmentPath.fsPath) + ) { + this.invalidateCachedAssociationValidation(scriptPath); + } + return; + } + } else if (metadataMatch !== 'matched') { + this.clearValidatedRouteableState(uri); + return; + } + this.routingRegistry.setValidatedAssociation(uri, true); + } + + private async updateValidatedStateForSelection(script: ScriptReference): Promise { + const savedMetadata = await this.getSavedMetadataForPersistence(script.uri); + if (!savedMetadata.identity) { + this.clearValidatedRouteableState(script.uri); + return; + } + if (this.inspectAssociationMetadata(script.scriptPath, savedMetadata.identity, false) !== 'matched') { + this.clearValidatedRouteableState(script.uri); + return; + } + this.cachedAssociationValidatedAt.set(script.scriptPath, Date.now()); + this.lastValidatedMetadataIdentities.set(script.scriptPath, savedMetadata.identity); + this.routingRegistry.setValidatedAssociation( + script.uri, + this.routingRegistry.getMetadataIdentity(script.uri) === savedMetadata.identity, + ); + } + + private async getSavedMetadataForPersistence(uri: Uri): Promise { + for (const document of getOpenTextDocuments()) { + if (document.uri.toString() === uri.toString() && document.isDirty) { + return {}; + } + } + return this.readSavedMetadataSnapshot(uri); + } + + private async readSavedMetadataSnapshot(uri: Uri): Promise { + const metadata = await readInlineScriptMetadataFromFile(uri); + return { + metadata, + identity: getInlineScriptMetadataRoutingIdentity(metadata), + }; + } + + private async currentCacheEntryProvesSourceMetadataIdentity( + environment: PythonEnvironment, + metadataIdentity: string, + metadata: InlineScriptMetadata, + ): Promise { + const sidecar = await this.readCurrentCacheEntrySidecar(environment); + return !!sidecar && this.cacheEntryProvesSourceMetadataIdentity(sidecar, environment, metadataIdentity, metadata); + } + + private async readCurrentCacheEntrySidecar(environment: PythonEnvironment): Promise { + let sidecarResult; + try { + sidecarResult = await inspectMetaJson(Uri.file(environment.sysPrefix)); + } catch { + return undefined; + } + return sidecarResult.kind === 'valid' ? sidecarResult.metadata : undefined; + } + + private cacheEntryProvesSourceMetadataIdentity( + sidecar: InlineScriptEnvMeta, + environment: PythonEnvironment, + metadataIdentity: string, + metadata: InlineScriptMetadata, + ): boolean { + return ( + this.sidecarProvesSourceMetadataIdentity(sidecar, metadataIdentity) || + this.isMetadataOnlyCacheEntryForMetadata(sidecar, environment, metadata) + ); + } + + private async resolveVerifiedSourceMetadataIdentity( + script: ScriptReference, + environment: PythonEnvironment, + savedMetadata: SavedMetadataSnapshot, + ): Promise { + if (savedMetadata.identity) { + return savedMetadata.metadata && + (await this.currentCacheEntryProvesSourceMetadataIdentity( + environment, + savedMetadata.identity, + savedMetadata.metadata, + )) + ? savedMetadata.identity + : undefined; + } + + const persistedSourceMetadataIdentity = this.getPersistedSourceMetadataIdentity( + script.scriptPath, + environment.environmentPath.fsPath, + ); + if (persistedSourceMetadataIdentity) { + const sidecar = await this.readCurrentCacheEntrySidecar(environment); + if (sidecar && this.sidecarProvesSourceMetadataIdentity(sidecar, persistedSourceMetadataIdentity)) { + return persistedSourceMetadataIdentity; + } + } + + const savedSourceMetadata = await this.readSavedMetadataSnapshot(script.uri); + if (!savedSourceMetadata.identity || !savedSourceMetadata.metadata) { + return undefined; + } + return (await this.currentCacheEntryProvesSourceMetadataIdentity( + environment, + savedSourceMetadata.identity, + savedSourceMetadata.metadata, + )) + ? savedSourceMetadata.identity + : undefined; + } + + private sidecarProvesSourceMetadataIdentity( + sidecar: InlineScriptEnvMeta, + metadataIdentity: string, + ): boolean { + if (sidecar.sourceMetadataIdentityHashes === undefined) { + return false; + } + const expectedHash = hashSourceMetadataIdentity(metadataIdentity); + return sidecar.sourceMetadataIdentityHashes.includes(expectedHash); + } + + private isMetadataOnlyCacheEntryForMetadata( + sidecar: InlineScriptEnvMeta, + environment: PythonEnvironment, + metadata: InlineScriptMetadata, + ): boolean { + if (sidecar.sourceMetadataIdentityHashes !== undefined) { + return false; + } + const expectedCacheKey = computeCacheKey({ + dependencies: metadata.dependencies ?? [], + interpreterPath: sidecar.baseInterpreterPath, + }); + if ( + normalizePath(getScriptEnvDir(this.globalStorageUri, expectedCacheKey).fsPath) !== + normalizePath(environment.sysPrefix) + ) { + return false; + } + const requiresPython = metadata.requiresPython?.trim(); + return !requiresPython || this.matchesInstallConstraint(requiresPython, environment.version); + } + + private getPersistedSourceMetadataIdentity(scriptPath: string, environmentPath: string): string | undefined { + const persistedAssociation = this.fsPathToPersistedAssociation.get(scriptPath); + return persistedAssociation && + normalizePath(persistedAssociation.environmentPath) === normalizePath(environmentPath) && + (persistedAssociation.metadataBinding.kind === 'matched' || + persistedAssociation.metadataBinding.kind === 'pending') + ? persistedAssociation.metadataBinding.sourceIdentity + : undefined; + } + + private async bindPendingMetadataIdentity( + scriptPath: string, + environmentPath: string, + metadataIdentity: string, + metadataRevision: number, + associationRevision: number, + uri: Uri, + ): Promise<'bound' | 'stale' | 'failed'> { + return this.enqueueSelection(async () => { + if ( + !this.isCurrentAssociationRevision(scriptPath, associationRevision) || + !this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision) + ) { + return 'stale'; + } + const expectedAssociation: PersistedAssociationRecord = { + environmentPath, + metadataBinding: { kind: 'pending', sourceIdentity: metadataIdentity }, + }; + const matchedAssociation: PersistedAssociationRecord = { + environmentPath, + metadataBinding: { kind: 'matched', sourceIdentity: metadataIdentity }, + }; + if (!this.isSamePersistedAssociation(this.fsPathToPersistedAssociation.get(scriptPath), expectedAssociation)) { + return 'stale'; + } + try { + await this.updatePersistedAssociations([ + { + scriptPath, + persistedAssociation: matchedAssociation, + expectedPersistedAssociation: expectedAssociation, + }, + ]); + } catch (error) { + this.log.warn(`Failed to bind inline-script metadata identity: ${getErrorMessage(error)}`); + return 'failed'; + } + if ( + !this.isCurrentAssociationRevision(scriptPath, associationRevision) || + !this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision) + ) { + return 'stale'; + } + return this.isSamePersistedAssociation(this.fsPathToPersistedAssociation.get(scriptPath), matchedAssociation) + ? 'bound' + : 'stale'; + }); + } + + private isCurrentMetadataRefreshTask( + uri: Uri, + metadataIdentity: string, + metadataRevision: number, + scriptPath: string, + associationRevision: number, + ): boolean { + return ( + this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision) && + this.isCurrentAssociationRevision(scriptPath, associationRevision) + ); + } + + private isCurrentRoutingMetadata(uri: Uri, metadataIdentity: string, metadataRevision: number): boolean { + return ( + this.routingRegistry.getMetadataIdentity(uri) === metadataIdentity && + this.routingRegistry.getMetadataRevision(uri) === metadataRevision + ); + } + + private clearValidatedRouteableState(script: Uri | string): void { + const scriptPath = typeof script === 'string' ? script : normalizePath(script.fsPath); + this.invalidateCachedAssociationValidation(scriptPath); + this.routingRegistry.setValidatedAssociation(script, false); + } + + private invalidateCachedAssociationValidation(scriptPath: string): void { + this.cachedAssociationValidatedAt.delete(scriptPath); + this.lastValidatedMetadataIdentities.delete(scriptPath); + this.lastValidatedMetadataIdentityProofs.delete(scriptPath); + } + + private initializePersistedAssociations(): Promise { + return this.enqueuePersistence(async (state) => { + const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); + const parsed = this.parsePersistedAssociations(rawAssociations); + this.applyPersistedAssociations(parsed?.records ?? {}); + }).then(async () => { + await Promise.all( + [...this.fsPathToPersistedAssociation.keys()].map(async (scriptPath) => { + const uri = this.routingRegistry.getUri(scriptPath); + const metadata = this.routingRegistry.getMetadata(scriptPath); + if (uri && metadata) { + await this.refreshValidatedAssociationForMetadata( + uri, + metadata, + getInlineScriptMetadataRoutingIdentity(metadata)!, + this.routingRegistry.getMetadataRevision(uri), + ); + } + }), + ); + }); + } + + private async getPersistedAssociation(scriptPath: string): Promise { await this.persistenceQueue; const state = await getWorkspacePersistentState(); - const raw = await state.get(INLINE_SCRIPT_ENVS_KEY); - if (raw === undefined) { - this.fsPathToPersistedEnvPath.delete(scriptPath); + const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); + if (rawAssociations === undefined) { + this.applyPersistedAssociations({}); return undefined; } - const associations = this.asPersistedAssociations(raw); - if (!associations) { + const parsed = this.parsePersistedAssociations(rawAssociations); + if (!parsed) { await this.removeInvalidPersistedAssociation(scriptPath); - this.fsPathToPersistedEnvPath.delete(scriptPath); - return undefined; + return this.getPersistedAssociationFromMemory(scriptPath); } - const rawValue = (raw as Record)[scriptPath]; - if (rawValue !== undefined && (typeof rawValue !== 'string' || rawValue.length === 0)) { + const rawValue = (rawAssociations as Record)[scriptPath]; + if (rawValue !== undefined && this.parsePersistedAssociationValue(rawValue).kind === 'invalid') { await this.removeInvalidPersistedAssociation(scriptPath); - this.fsPathToPersistedEnvPath.delete(scriptPath); - return undefined; - } - const environmentPath = associations[scriptPath]; - if (environmentPath) { - this.fsPathToPersistedEnvPath.set(scriptPath, environmentPath); - } else { - this.fsPathToPersistedEnvPath.delete(scriptPath); + return this.getPersistedAssociationFromMemory(scriptPath); } - return environmentPath; + this.applyPersistedAssociations(parsed.records); + return this.getPersistedAssociationFromMemory(scriptPath); } private async removeStalePersistedAssociation( @@ -673,23 +1366,31 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { expectedEnvironmentPath: string, revision: number, scriptUri?: Uri, + expectedPersistedAssociation?: PersistedAssociationRecord, ): Promise { await this.enqueueSelection(async () => { if (!this.isCurrentAssociationRevision(scriptPath, revision)) { return; } try { - await this.updatePersistedAssociations([{ scriptPath, expectedEnvironmentPath }]); + const persistedPathBeforeUpdate = this.fsPathToPersistedAssociation.get(scriptPath)?.environmentPath; + await this.updatePersistedAssociations([ + { + scriptPath, + expectedEnvironmentPath, + expectedPersistedAssociation, + }, + ]); if ( - normalizePath(this.fsPathToPersistedEnvPath.get(scriptPath) ?? '') === - normalizePath(expectedEnvironmentPath) && + normalizePath(persistedPathBeforeUpdate ?? '') === normalizePath(expectedEnvironmentPath) && + !this.fsPathToPersistedAssociation.has(scriptPath) && this.isCurrentAssociationRevision(scriptPath, revision) ) { const old = this.fsPathToEnv.get(scriptPath); this.bumpAssociationRevision(scriptPath); this.fsPathToEnv.delete(scriptPath); - this.fsPathToPersistedEnvPath.delete(scriptPath); - this.cachedAssociationValidatedAt.delete(scriptPath); + this.fsPathToPersistedAssociation.delete(scriptPath); + this.clearValidatedRouteableState(scriptPath); if (old && scriptUri) { this._onDidChangeEnvironment.fire({ uri: scriptUri, old, new: undefined }); } @@ -704,54 +1405,218 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private removeInvalidPersistedAssociation(scriptPath: string): Promise { return this.enqueuePersistence(async (state) => { - const raw = await state.get(INLINE_SCRIPT_ENVS_KEY); - if (raw === undefined) { + const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); + if (rawAssociations === undefined) { + this.applyPersistedAssociations({}); return; } - const associations = this.asPersistedAssociations(raw); - if (!associations) { + const parsed = this.parsePersistedAssociations(rawAssociations); + if (!parsed) { await state.set(INLINE_SCRIPT_ENVS_KEY, {}); + this.applyPersistedAssociations({}); return; } - const rawValue = (raw as Record)[scriptPath]; - if (rawValue !== undefined && (typeof rawValue !== 'string' || rawValue.length === 0)) { - delete associations[scriptPath]; - await state.set(INLINE_SCRIPT_ENVS_KEY, associations); + if (parsed.invalidKeys.has(scriptPath)) { + delete parsed.rawEntries[scriptPath]; + delete parsed.records[scriptPath]; + parsed.invalidKeys.delete(scriptPath); + await state.set(INLINE_SCRIPT_ENVS_KEY, parsed.rawEntries); } + this.applyPersistedAssociations(parsed.records); }); } private updatePersistedAssociations(changes: readonly PersistedAssociationChange[]): Promise { return this.enqueuePersistence(async (state) => { - const raw = await state.get(INLINE_SCRIPT_ENVS_KEY); - const associations = { ...(this.asPersistedAssociations(raw) ?? {}) }; + const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); + const parsed = this.parsePersistedAssociations(rawAssociations); + const rawEntries = { ...(parsed?.rawEntries ?? {}) }; + const associations = { ...(parsed?.records ?? {}) }; for (const change of changes) { const current = associations[change.scriptPath]; - if (change.environmentPath) { - associations[change.scriptPath] = change.environmentPath; + if (change.persistedAssociation) { + if ( + change.expectedPersistedAssociation && + !this.isSamePersistedAssociation(current, change.expectedPersistedAssociation) + ) { + continue; + } + associations[change.scriptPath] = change.persistedAssociation; + rawEntries[change.scriptPath] = this.serializePersistedAssociation(change.persistedAssociation); } else if ( - change.expectedEnvironmentPath === undefined || - (current !== undefined && - normalizePath(current) === normalizePath(change.expectedEnvironmentPath)) + (change.expectedPersistedAssociation && + this.isSamePersistedAssociation(current, change.expectedPersistedAssociation)) || + (change.expectedPersistedAssociation === undefined && + (change.expectedEnvironmentPath === undefined || + (current !== undefined && + normalizePath(current.environmentPath) === normalizePath(change.expectedEnvironmentPath)))) ) { delete associations[change.scriptPath]; + delete rawEntries[change.scriptPath]; } } - await state.set(INLINE_SCRIPT_ENVS_KEY, associations); + await state.set(INLINE_SCRIPT_ENVS_KEY, rawEntries); + this.applyPersistedAssociations(associations); }); } - private asPersistedAssociations(value: unknown): PersistedInlineScriptEnvironments | undefined { + private parsePersistedAssociations(value: unknown): ParsedPersistedAssociations | undefined { + if (value === undefined) { + return { + rawEntries: {}, + records: {}, + invalidKeys: new Set(), + }; + } if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } - const associations: PersistedInlineScriptEnvironments = {}; - for (const [scriptPath, environmentPath] of Object.entries(value)) { - if (typeof environmentPath === 'string' && environmentPath.length > 0) { - associations[scriptPath] = environmentPath; + const rawEntries = { ...(value as Record) }; + const records: PersistedInlineScriptEnvironments = {}; + const invalidKeys = new Set(); + for (const [scriptPath, association] of Object.entries(rawEntries)) { + const parsed = this.parsePersistedAssociationValue(association); + if (parsed.kind === 'valid') { + records[scriptPath] = parsed.record; + } else if (parsed.kind === 'invalid') { + invalidKeys.add(scriptPath); + } + } + return { rawEntries, records, invalidKeys }; + } + + private getPersistedAssociationFromMemory(scriptPath: string): PersistedAssociationRecord | undefined { + return this.fsPathToPersistedAssociation.get(scriptPath); + } + + private createPersistedAssociationRecord( + environmentPath: string, + sourceMetadataIdentity: string | undefined, + currentMetadataIdentity: string | undefined, + ): PersistedAssociationRecord { + if (!sourceMetadataIdentity) { + return { + environmentPath, + metadataBinding: { kind: 'legacy' }, + }; + } + return { + environmentPath, + metadataBinding: + currentMetadataIdentity === sourceMetadataIdentity + ? { kind: 'matched', sourceIdentity: sourceMetadataIdentity } + : { kind: 'pending', sourceIdentity: sourceMetadataIdentity }, + }; + } + + private isSamePersistedAssociation( + first: PersistedAssociationRecord | undefined, + second: PersistedAssociationRecord | undefined, + ): boolean { + if (first === second) { + return true; + } + if (!first || !second) { + return false; + } + if (normalizePath(first.environmentPath) !== normalizePath(second.environmentPath)) { + return false; + } + if (first.metadataBinding.kind !== second.metadataBinding.kind) { + return false; + } + if (first.metadataBinding.kind === 'matched' && second.metadataBinding.kind === 'matched') { + return first.metadataBinding.sourceIdentity === second.metadataBinding.sourceIdentity; + } + if (first.metadataBinding.kind === 'pending' && second.metadataBinding.kind === 'pending') { + return first.metadataBinding.sourceIdentity === second.metadataBinding.sourceIdentity; + } + return true; + } + + private parsePersistedAssociationValue(value: unknown): + | { readonly kind: 'valid'; readonly record: PersistedAssociationRecord } + | { readonly kind: 'future' } + | { readonly kind: 'invalid' } { + if (typeof value === 'string' && value.length > 0) { + return { + kind: 'valid', + record: { + environmentPath: value, + metadataBinding: { kind: 'legacy' }, + }, + }; + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { kind: 'invalid' }; + } + const association = value as Record; + const schemaVersion = association.schemaVersion; + if (typeof schemaVersion !== 'number') { + return { kind: 'invalid' }; + } + if (schemaVersion !== PERSISTED_ASSOCIATION_SCHEMA_VERSION) { + return { kind: 'future' }; + } + const environmentPath = association.environmentPath; + const metadataBinding = association.metadataBinding; + if (typeof environmentPath !== 'string' || environmentPath.length === 0) { + return { kind: 'invalid' }; + } + if (!metadataBinding || typeof metadataBinding !== 'object' || Array.isArray(metadataBinding)) { + return { kind: 'invalid' }; + } + const binding = metadataBinding as Record; + if (binding.kind === 'pending') { + if (typeof binding.sourceIdentity === 'string' && binding.sourceIdentity.trim().length > 0) { + return { + kind: 'valid', + record: { + environmentPath, + metadataBinding: { kind: 'pending', sourceIdentity: binding.sourceIdentity }, + }, + }; } + return { kind: 'invalid' }; } - return associations; + if (binding.kind === 'legacy') { + return { + kind: 'valid', + record: { environmentPath, metadataBinding: { kind: 'legacy' } }, + }; + } + if ( + binding.kind === 'matched' && + typeof binding.sourceIdentity === 'string' && + binding.sourceIdentity.trim().length > 0 + ) { + return { + kind: 'valid', + record: { + environmentPath, + metadataBinding: { + kind: 'matched', + sourceIdentity: binding.sourceIdentity, + }, + }, + }; + } + return { kind: 'invalid' }; + } + + private serializePersistedAssociation( + association: PersistedAssociationRecord, + ): PersistedInlineScriptAssociationValue { + return { + schemaVersion: PERSISTED_ASSOCIATION_SCHEMA_VERSION, + environmentPath: association.environmentPath, + metadataBinding: + association.metadataBinding.kind === 'matched' + ? { kind: 'matched', sourceIdentity: association.metadataBinding.sourceIdentity } + : association.metadataBinding.kind === 'pending' + ? { kind: 'pending', sourceIdentity: association.metadataBinding.sourceIdentity } + : { kind: association.metadataBinding.kind }, + }; } private enqueuePersistence(operation: (state: PersistentState) => Promise): Promise { @@ -769,6 +1634,37 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } + private clearAssociationsForScripts(scripts: readonly Uri[]): Promise { + return this.enqueueSelection(async () => { + const changes = scripts + .filter((uri) => uri.scheme === 'file') + .map((uri) => ({ + uri, + scriptPath: normalizePath(uri.fsPath), + })) + .filter((script, index, all) => all.findIndex((candidate) => candidate.scriptPath === script.scriptPath) === index) + .filter( + (script) => + this.fsPathToEnv.has(script.scriptPath) || + this.fsPathToPersistedAssociation.has(script.scriptPath), + ); + + if (changes.length === 0) { + return; + } + + await this.updatePersistedAssociations(changes.map(({ scriptPath }) => ({ scriptPath }))); + for (const change of changes) { + this.bumpAssociationRevision(change.scriptPath); + this.pendingRehydrations.delete(change.scriptPath); + this.pendingMetadataRefreshes.delete(change.scriptPath); + this.fsPathToEnv.delete(change.scriptPath); + this.fsPathToPersistedAssociation.delete(change.scriptPath); + this.clearValidatedRouteableState(change.uri); + } + }); + } + private async isCacheEntryBusy(envDirPath: string): Promise { return ( this.pendingCreations.has(path.basename(envDirPath)) || @@ -796,7 +1692,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } return ( first.envId.managerId === second.envId.managerId && - normalizePath(first.environmentPath.fsPath) === normalizePath(second.environmentPath.fsPath) + normalizePath(first.environmentPath.fsPath) === normalizePath(second.environmentPath.fsPath) && + first.version === second.version ); } @@ -1052,61 +1949,128 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return installedPath; } + private async withCacheEntryLock( + envDir: Uri, + action: (lock: AcquiredFileLock) => Promise, + ): Promise { + const lock = await acquireFileLock(envDir.fsPath, { + timeoutMs: CACHE_LOCK_TIMEOUT_MS, + retryIntervalMs: CACHE_LOCK_RETRY_MS, + }); + try { + return await action(lock); + } finally { + try { + await lock.release(); + } catch (error) { + this.log.warn(`Failed to release inline-script cache lock: ${getErrorMessage(error)}`); + } + } + } + + private mergePendingCreationSourceMetadataIdentityHashes( + existing: readonly string[] | undefined, + pendingCreation: PendingCreationContext, + ): readonly string[] | undefined { + let merged = existing; + for (const sourceMetadataIdentityHash of pendingCreation.sourceMetadataIdentityHashes ?? []) { + merged = mergeSourceMetadataIdentityHashes(merged, sourceMetadataIdentityHash); + } + return merged; + } + + private async mergeCacheEntrySourceMetadataIdentityHash( + cacheKey: string, + sourceMetadataIdentityHash: string, + ): Promise { + const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); + try { + return await this.withCacheEntryLock(envDir, async () => { + const sidecarResult = await inspectMetaJson(envDir); + if (sidecarResult.kind !== 'valid') { + return { success: false }; + } + if (sidecarResult.metadata.sourceMetadataIdentityHashes?.includes(sourceMetadataIdentityHash)) { + return { + success: true, + sourceMetadataIdentityHashes: sidecarResult.metadata.sourceMetadataIdentityHashes, + }; + } + const sourceMetadataIdentityHashes = mergeSourceMetadataIdentityHashes( + sidecarResult.metadata.sourceMetadataIdentityHashes, + sourceMetadataIdentityHash, + ); + await writeMetaJson(envDir, { + ...sidecarResult.metadata, + ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), + }); + return { + success: true, + sourceMetadataIdentityHashes, + }; + }); + } catch (error) { + this.log.warn(`Failed to update inline-script cache provenance: ${getErrorMessage(error)}`); + return { success: false }; + } + } + private async createOrReuseEnvironment({ cacheKey, packages, metadata, selectedBase, + pendingCreation, }: 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 await this.withCacheEntryLock(envDir, async (lock) => { + const cached = await this.inspectCacheEntry( + cacheRoot, + envDir, + metadata, + selectedBase, + pendingCreation, ); - return undefined; - } - if (cached.kind === 'stale') { - if (!(await this.removeCacheEntry(envDir))) { + 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: ${getErrorMessage(error)}`, - ); + const build = await this.buildCacheEntry( + envDir, + cacheRoot, + packages, + selectedBase, + pendingCreation, + ); + if (build.retainLock) { + try { + await lock.retain(); + } catch (error) { + this.log.error( + `Failed to mark the inline-script cache lock as retained: ${getErrorMessage(error)}`, + ); + } } - } - return build.environment; + return build.environment; + }); } catch (error) { this.log.error(`Failed to create or reuse inline-script cache entry: ${getErrorMessage(error)}`); return undefined; - } finally { - if (lock) { - try { - await lock.release(); - } catch (error) { - this.log.warn(`Failed to release inline-script cache lock: ${getErrorMessage(error)}`); - } - } } } @@ -1115,6 +2079,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { envDir: Uri, metadata: InlineScriptMetadata, selectedBase: SelectedBaseInterpreter, + pendingCreation: PendingCreationContext, ): Promise { try { const stat = await fs.lstat(envDir.fsPath); @@ -1143,7 +2108,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { kind: 'uncertain' }; } if (sidecarResult.kind !== 'valid') { - return { kind: sidecarResult.kind === 'unavailable' ? 'uncertain' : 'stale' }; + return { + kind: + sidecarResult.kind === 'unavailable' || sidecarResult.kind === 'unsupported' + ? 'uncertain' + : 'stale', + }; } const sidecar = sidecarResult.metadata; if ( @@ -1179,9 +2149,18 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (requiresPython && !this.matchesInstallConstraint(requiresPython, environment.version)) { return { kind: 'stale' }; } - try { - await writeMetaJson(envDir, { ...sidecar, lastUsedAt: new Date().toISOString() }); + pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true; + const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes( + sidecar.sourceMetadataIdentityHashes, + pendingCreation, + ); + await writeMetaJson(envDir, { + ...sidecar, + lastUsedAt: new Date().toISOString(), + ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), + }); + pendingCreation.recordedSourceMetadataIdentityHashes = sourceMetadataIdentityHashes; } catch (error) { this.log.warn(`Failed to update inline-script cache metadata: ${getErrorMessage(error)}`); } @@ -1193,6 +2172,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { cacheRoot: Uri, packages: ReadonlyArray, selectedBase: SelectedBaseInterpreter, + pendingCreation: PendingCreationContext, ): Promise { let result; try { @@ -1234,14 +2214,20 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { await this.removeCacheEntry(envDir); return {}; } - try { + pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true; + const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes( + undefined, + pendingCreation, + ); await writeMetaJson(envDir, { schemaVersion: META_SCHEMA_VERSION, baseInterpreterPath: selectedBase.canonicalPath, baseInterpreterVersion: selectedBase.environment.version, lastUsedAt: new Date().toISOString(), + ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), }); + pendingCreation.recordedSourceMetadataIdentityHashes = sourceMetadataIdentityHashes; } catch (error) { this.log.error(`Failed to record inline-script cache metadata: ${getErrorMessage(error)}`); await this.removeCacheEntry(envDir); @@ -1283,16 +2269,49 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } dispose(): void { + this.pendingMetadataRefreshes.clear(); + this.subscriptions.forEach((subscription) => subscription.dispose()); this._onDidChangeEnvironments.dispose(); this._onDidChangeEnvironment.dispose(); } + + private applyPersistedAssociations(associations: PersistedInlineScriptEnvironments): void { + const nextPaths = new Set(Object.keys(associations)); + for (const scriptPath of this.fsPathToPersistedAssociation.keys()) { + if (!nextPaths.has(scriptPath)) { + this.fsPathToPersistedAssociation.delete(scriptPath); + this.clearValidatedRouteableState(scriptPath); + } + } + for (const [scriptPath, association] of Object.entries(associations)) { + this.fsPathToPersistedAssociation.set(scriptPath, association); + } + } +} + +type PersistedInlineScriptEnvironments = Record; +type PersistedInlineScriptAssociationValue = string | PersistedInlineScriptAssociationObject; + +type PersistedMetadataBinding = + | { readonly kind: 'legacy' } + | { readonly kind: 'pending'; readonly sourceIdentity: string } + | { readonly kind: 'matched'; readonly sourceIdentity: string }; + +interface PersistedInlineScriptAssociationObject { + readonly schemaVersion: typeof PERSISTED_ASSOCIATION_SCHEMA_VERSION; + readonly environmentPath: string; + readonly metadataBinding: PersistedMetadataBinding; } -type PersistedInlineScriptEnvironments = Record; +interface PersistedAssociationRecord { + readonly environmentPath: string; + readonly metadataBinding: PersistedMetadataBinding; +} interface PersistedAssociationChange { readonly scriptPath: string; - readonly environmentPath?: string; + readonly persistedAssociation?: PersistedAssociationRecord; + readonly expectedPersistedAssociation?: PersistedAssociationRecord; readonly expectedEnvironmentPath?: string; } @@ -1303,6 +2322,7 @@ interface ScriptReference { interface PendingScriptUpdate extends ScriptReference { readonly before: PythonEnvironment | undefined; + readonly persistedAssociation?: PersistedAssociationRecord; readonly needsPersistence: boolean; readonly shouldNotify: boolean; } diff --git a/src/managers/builtin/inlineScript/main.ts b/src/managers/builtin/inlineScript/main.ts index 8c35fc6ed..8c8e7ee35 100644 --- a/src/managers/builtin/inlineScript/main.ts +++ b/src/managers/builtin/inlineScript/main.ts @@ -3,6 +3,7 @@ import { Disposable, LogOutputChannel, Uri } from 'vscode'; import { EnvironmentManager, PythonEnvironmentApi } from '../../../api'; +import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; import { traceInfo, traceVerbose } from '../../../common/logging'; import { getPythonApi } from '../../../features/pythonApi'; import { isInlineScriptsFeatureEnabled } from '../../../helpers'; @@ -20,6 +21,7 @@ export async function registerInlineScriptFeatures( log: LogOutputChannel, baseManager: EnvironmentManager, globalStorageUri: Uri, + routingRegistry: InlineScriptRoutingRegistry, ): Promise { if (!isInlineScriptsFeatureEnabled()) { traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); @@ -27,7 +29,7 @@ export async function registerInlineScriptFeatures( } const api: PythonEnvironmentApi = await getPythonApi(); - const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log); + const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log, routingRegistry); disposables.push(mgr, api.registerEnvironmentManager(mgr)); 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 d57be848b..21951ba8d 100644 --- a/src/test/common/inlineScript/cacheLayout.unit.test.ts +++ b/src/test/common/inlineScript/cacheLayout.unit.test.ts @@ -11,16 +11,20 @@ import { Uri } from 'vscode'; import { PythonEnvironment } from '../../../api'; import { CacheEntrySummary, + MAX_SOURCE_METADATA_IDENTITY_HASHES, + SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH, INLINE_SCRIPT_CACHE_DIR_NAME, InlineScriptEnvMeta, META_JSON_FILENAME, META_SCHEMA_VERSION, getBaseInterpreterStatus, + hashSourceMetadataIdentity, getMetaJsonPath, getScriptEnvCacheRoot, getScriptEnvDir, inspectOwnedCacheEntry, inspectMetaJson, + mergeSourceMetadataIdentityHashes, readMetaJson, resolveCacheEntryPath, selectStaleEntries, @@ -89,7 +93,9 @@ suite('inlineScriptCacheLayout', () => { }); test('writeMetaJson then readMetaJson returns the same object', async () => { - const meta = makeMeta(); + const meta = makeMeta({ + sourceMetadataIdentityHashes: [hashSourceMetadataIdentity('{"requiresPython":">=3.11","dependencies":["requests"]}')], + }); await writeMetaJson(envDir, meta); const read = await readMetaJson(envDir); assert.deepStrictEqual(read, meta); @@ -190,6 +196,11 @@ suite('inlineScriptCacheLayout', () => { assert.deepStrictEqual(await inspectMetaJson(envDir), { kind: 'valid', metadata }); }); + test('classifies a newer schema as unsupported without treating it as malformed', async () => { + await writeRaw(JSON.stringify({ ...makeMeta(), schemaVersion: 99 })); + assert.deepStrictEqual(await inspectMetaJson(envDir), { kind: 'unsupported' }); + }); + test('classifies non-ENOENT sidecar stat failures as unavailable', async () => { sinon.stub(fsExtra, 'lstat').rejects(Object.assign(new Error('permission denied'), { code: 'EACCES' })); assert.deepStrictEqual(await inspectMetaJson(envDir), { kind: 'unavailable' }); @@ -233,11 +244,10 @@ suite('inlineScriptCacheLayout', () => { ); }); - test('returns undefined for an unknown schemaVersion', async () => { + test('classifies a newer schemaVersion as unsupported', async () => { await writeRaw(JSON.stringify({ ...makeMeta(), schemaVersion: 99 })); - const result = await readMetaJson(envDir); - assert.strictEqual(result, undefined); - assert.ok(traceWarnStub.called); + const result = await inspectMetaJson(envDir); + assert.deepStrictEqual(result, { kind: 'unsupported' }); }); test('returns undefined when baseInterpreterPath is missing', async () => { @@ -273,6 +283,31 @@ suite('inlineScriptCacheLayout', () => { assert.strictEqual(await readMetaJson(envDir), undefined); }); + test('returns undefined for malformed sourceMetadataIdentityHashes', async () => { + await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: 'not-an-array' })); + assert.strictEqual(await readMetaJson(envDir), undefined); + await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: [] })); + assert.strictEqual(await readMetaJson(envDir), undefined); + await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: ['bad-hash'] })); + assert.strictEqual(await readMetaJson(envDir), undefined); + }); + + test('returns undefined for duplicate or oversized sourceMetadataIdentityHashes', async () => { + const hash = hashSourceMetadataIdentity('same'); + await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: [hash, hash] })); + assert.strictEqual(await readMetaJson(envDir), undefined); + await writeRaw( + JSON.stringify({ + ...makeMeta(), + sourceMetadataIdentityHashes: Array.from( + { length: MAX_SOURCE_METADATA_IDENTITY_HASHES + 1 }, + (_, index) => hashSourceMetadataIdentity(`id-${index}`), + ), + }), + ); + assert.strictEqual(await readMetaJson(envDir), undefined); + }); + test('returns undefined when lastUsedAt is not parseable', async () => { await writeRaw(JSON.stringify({ ...makeMeta(), lastUsedAt: 'not-a-date' })); const result = await readMetaJson(envDir); @@ -334,6 +369,13 @@ suite('inlineScriptCacheLayout', () => { assert.strictEqual('_internal' in result, false); }); + test('old sidecars without sourceMetadataIdentityHashes remain valid', async () => { + const result = await inspectMetaJson(envDir); + assert.deepStrictEqual(result, { kind: 'missing' }); + await writeRaw(JSON.stringify(makeMeta())); + assert.ok(await readMetaJson(envDir)); + }); + test('returns undefined when the sidecar path is a directory rather than a file', async () => { await fs.remove(getMetaJsonPath(envDir).fsPath).catch(() => undefined); await fs.ensureDir(getMetaJsonPath(envDir).fsPath); @@ -341,6 +383,24 @@ suite('inlineScriptCacheLayout', () => { assert.ok(traceWarnStub.called); }); + suite('source metadata hash helpers', () => { + test('hashSourceMetadataIdentity returns fixed-size lowercase hex', () => { + const hash = hashSourceMetadataIdentity('metadata-identity'); + assert.strictEqual(hash.length, SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH); + assert.ok(/^[0-9a-f]+$/.test(hash)); + }); + + test('mergeSourceMetadataIdentityHashes dedupes and caps the newest hashes', () => { + const hashes = Array.from({ length: MAX_SOURCE_METADATA_IDENTITY_HASHES }, (_, index) => + hashSourceMetadataIdentity(`id-${index}`), + ); + const merged = mergeSourceMetadataIdentityHashes(hashes, hashSourceMetadataIdentity('latest')); + assert.ok(merged); + assert.strictEqual(merged.length, MAX_SOURCE_METADATA_IDENTITY_HASHES); + assert.strictEqual(merged[merged.length - 1], hashSourceMetadataIdentity('latest')); + }); + }); + test('returns undefined when the sidecar exceeds the size cap (1 MiB)', async () => { const big = Buffer.alloc(1024 * 1024 + 1, 0x20); await fs.writeFile(getMetaJsonPath(envDir).fsPath, big); diff --git a/src/test/features/envManagers.lastKnown.unit.test.ts b/src/test/features/envManagers.lastKnown.unit.test.ts index 589a1a25c..14f00cd9c 100644 --- a/src/test/features/envManagers.lastKnown.unit.test.ts +++ b/src/test/features/envManagers.lastKnown.unit.test.ts @@ -23,6 +23,8 @@ import { PythonProject, } from '../../api'; import * as extensionApis from '../../common/extension.apis'; +import { InlineScriptMetadata } from '../../common/inlineScript/metadata'; +import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { PythonEnvironmentManagers } from '../../features/envManagers'; import * as settingHelpers from '../../features/settings/settingHelpers'; import { InternalPackageManager, PythonProjectManager } from '../../internal.api'; @@ -34,6 +36,13 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { let projectsByUri: Map; let defaultManagerId: string; let exactManagerSettings: Map; + let routingRegistry: InlineScriptRoutingRegistry; + + const INLINE_METADATA: InlineScriptMetadata = { + requiresPython: '>=3.11', + dependencies: ['requests'], + range: { start: 0, end: 40 }, + }; function makeEnv(id: string): PythonEnvironment { const envId: PythonEnvironmentId = { id, managerId: 'test-manager' }; @@ -64,11 +73,12 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { setupNonThenable(projectManager); projectsByUri = new Map(); exactManagerSettings = new Map(); + routingRegistry = new InlineScriptRoutingRegistry(); projectManager .setup((pm) => pm.get(typeMoq.It.isAny())) .returns((uri) => projectsByUri.get(uri.toString())); - envManagers = new PythonEnvironmentManagers(projectManager.object); + envManagers = new PythonEnvironmentManagers(projectManager.object, routingRegistry); sinon.stub(settingHelpers, 'getDefaultEnvManagerSetting').callsFake(() => defaultManagerId); sinon .stub(settingHelpers, 'getProjectEnvironmentManagerSetting') @@ -114,6 +124,11 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { sinon.stub(envManagers, 'getPackageManager').returns(packageManager.object); } + function markInlineScript(uri: Uri, associated: boolean = true, metadata: InlineScriptMetadata = INLINE_METADATA): void { + routingRegistry.setMetadata(uri, metadata); + routingRegistry.setValidatedAssociation(uri, associated); + } + test('returns undefined before any environment has been resolved', () => { registerManager(async () => makeEnv('env1')); assert.strictEqual(envManagers.getLastKnownEnvironment(undefined), undefined); @@ -267,6 +282,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { settings.onSecondCall().resolves(); const events: DidChangeEnvironmentEventArgs[] = []; envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); + markInlineScript(scope); const olderSelection = envManagers.setEnvironment(scope, first); await firstWriteStarted; @@ -293,6 +309,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { }; const events: DidChangeEnvironmentEventArgs[] = []; envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); + markInlineScript(scope); await envManagers.setEnvironment(scope, first, false); await envManagers.setEnvironment(scope, second, false); @@ -322,6 +339,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { }; const events: DidChangeEnvironmentEventArgs[] = []; envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); + markInlineScript(scope); await envManagers.setEnvironment(scope, first, false); await envManagers.setEnvironment(scope, regenerated, false); @@ -369,6 +387,8 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { const managerId = registerManager(async () => undefined, async () => undefined, 'inline-script'); const first = { ...makeEnv('first'), envId: { id: 'first', managerId } }; const second = { ...makeEnv('second'), envId: { id: 'second', managerId } }; + markInlineScript(firstUri); + markInlineScript(secondUri); await envManagers.setEnvironment(firstUri, first, false); await envManagers.setEnvironment(secondUri, second, false); @@ -377,6 +397,28 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { assert.strictEqual(envManagers.getLastKnownEnvironment(secondUri), second); }); + test('does not route inline metadata without an associated environment', () => { + const script = Uri.file('/workspace/project/script.py'); + projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); + const defaultId = registerManager(async () => makeEnv('default'), async () => undefined, 'venv'); + registerManager(async () => makeEnv('inline'), async () => undefined, 'inline-script'); + defaultManagerId = defaultId; + routingRegistry.setMetadata(script, INLINE_METADATA); + + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); + }); + + test('does not route an associated inline environment without known metadata', () => { + const script = Uri.file('/workspace/project/script.py'); + projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); + const defaultId = registerManager(async () => makeEnv('default'), async () => undefined, 'venv'); + registerManager(async () => makeEnv('inline'), async () => undefined, 'inline-script'); + defaultManagerId = defaultId; + routingRegistry.setValidatedAssociation(script, true); + + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); + }); + test('routes an active inline-script selection before the containing project default', async () => { const script = Uri.file('/workspace/project/script.py'); projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); @@ -385,6 +427,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = defaultId; + markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); @@ -400,6 +443,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = selectedId; + markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); exactManagerSettings.set(script.toString(), selectedId); @@ -417,6 +461,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId: selectedId } }; inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = selectedId; + markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); await envManagers.setEnvironment(script, selectedEnvironment, false); @@ -424,6 +469,33 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, selectedId); }); + test('ignores routeability changes while an explicit non-inline override wins', async () => { + const script = Uri.file('/workspace/project/script.py'); + projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); + let selectedEnvironment: PythonEnvironment; + const selectedId = registerManager(async () => selectedEnvironment, async () => undefined, 'venv'); + let inlineEnvironment: PythonEnvironment; + const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); + selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId: selectedId } }; + inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; + defaultManagerId = selectedId; + markInlineScript(script); + + await envManagers.setEnvironment(script, inlineEnvironment, false); + await envManagers.setEnvironment(script, selectedEnvironment, false); + await new Promise((resolve) => setImmediate(resolve)); + + const events: DidChangeEnvironmentEventArgs[] = []; + envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); + routingRegistry.clearMetadata(script); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepStrictEqual(events, []); + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, selectedId); + assert.strictEqual(envManagers.getLastKnownEnvironment(script), selectedEnvironment); + }); + test('clears inline routing after a no-op inline refresh during settings persistence', async () => { const script = Uri.file('/workspace/project/script.py'); projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); @@ -434,6 +506,7 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId: selectedId } }; inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = selectedId; + markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); stubPackageManager(); let releaseSettings: (() => void) | undefined; @@ -459,6 +532,133 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { assert.strictEqual(envManagers.getLastKnownEnvironment(script), selectedEnvironment); }); + test('refreshes to the inline manager when a persisted association becomes routeable', async () => { + const script = Uri.file('/workspace/project/script.py'); + projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); + const defaultEnvironment = makeEnv('default'); + const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); + let inlineEnvironment: PythonEnvironment; + const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); + inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; + defaultManagerId = defaultId; + routingRegistry.setMetadata(script, INLINE_METADATA); + + await envManagers.refreshEnvironment(script); + routingRegistry.setValidatedAssociation(script, true); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, inlineId); + assert.strictEqual(envManagers.getLastKnownEnvironment(script), inlineEnvironment); + }); + + test('does not publish an inline selection while routeability is false, then publishes once when it validates', async () => { + const script = Uri.file('/workspace/project/script.py'); + const project = { name: 'project', uri: Uri.file('/workspace/project') }; + projectsByUri.set(script.toString(), project); + const defaultEnvironment = makeEnv('default'); + const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); + let inlineEnvironment: PythonEnvironment; + const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); + inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; + defaultManagerId = defaultId; + + await envManagers.refreshEnvironment(script); + await new Promise((resolve) => setImmediate(resolve)); + const events: DidChangeEnvironmentEventArgs[] = []; + envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); + + await envManagers.setEnvironment(script, inlineEnvironment, false); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); + assert.strictEqual(envManagers.getLastKnownEnvironment(script), defaultEnvironment); + assert.deepStrictEqual(events, []); + + routingRegistry.setMetadata(script, INLINE_METADATA); + routingRegistry.setValidatedAssociation(script, true); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, inlineId); + assert.strictEqual(envManagers.getLastKnownEnvironment(script), inlineEnvironment); + assert.deepStrictEqual(events, [{ uri: script, old: defaultEnvironment, new: inlineEnvironment }]); + }); + + test('does not publish batch inline selections until each script becomes routeable', async () => { + const first = Uri.file('/workspace/project/first.py'); + const second = Uri.file('/workspace/project/second.py'); + const project = { name: 'project', uri: Uri.file('/workspace/project') }; + projectsByUri.set(first.toString(), project); + projectsByUri.set(second.toString(), project); + const defaultEnvironment = makeEnv('default'); + const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); + let inlineEnvironment: PythonEnvironment; + const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); + inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; + defaultManagerId = defaultId; + + await envManagers.refreshEnvironment(first); + await envManagers.refreshEnvironment(second); + await new Promise((resolve) => setImmediate(resolve)); + const events: DidChangeEnvironmentEventArgs[] = []; + envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); + + await envManagers.setEnvironments([first, second], inlineEnvironment, false); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(envManagers.getLastKnownEnvironment(first), defaultEnvironment); + assert.strictEqual(envManagers.getLastKnownEnvironment(second), defaultEnvironment); + assert.deepStrictEqual(events, []); + + routingRegistry.setMetadata(first, INLINE_METADATA); + routingRegistry.setValidatedAssociation(first, true); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(envManagers.getLastKnownEnvironment(first), inlineEnvironment); + assert.strictEqual(envManagers.getLastKnownEnvironment(second), defaultEnvironment); + assert.deepStrictEqual(events, [{ uri: first, old: defaultEnvironment, new: inlineEnvironment }]); + }); + + test('falls back when inline-script metadata is invalidated after routing', async () => { + const script = Uri.file('/workspace/project/script.py'); + const project = { name: 'project', uri: Uri.file('/workspace/project') }; + projectsByUri.set(script.toString(), project); + const defaultEnvironment = makeEnv('default'); + const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); + let inlineEnvironment: PythonEnvironment; + const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); + inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; + defaultManagerId = defaultId; + await envManagers.refreshEnvironment(script); + markInlineScript(script); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + const events: DidChangeEnvironmentEventArgs[] = []; + envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); + routingRegistry.clearMetadata(script); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); + assert.strictEqual(envManagers.getLastKnownEnvironment(script), defaultEnvironment); + assert.deepStrictEqual(events[events.length - 1], { + uri: project.uri, + old: inlineEnvironment, + new: defaultEnvironment, + }); + }); + + test('ignores routeable inline state when the inline manager is not registered', () => { + const script = Uri.file('/workspace/project/script.py'); + projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); + const defaultId = registerManager(async () => makeEnv('default'), async () => undefined, 'venv'); + defaultManagerId = defaultId; + markInlineScript(script); + + assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); + }); + test('does not persist an inline-script manager for the containing project', async () => { const script = Uri.file('/workspace/project/script.py'); const containingProject = { name: 'project', uri: Uri.file('/workspace/project') }; diff --git a/src/test/features/inlineScript/lazyDetector.unit.test.ts b/src/test/features/inlineScript/lazyDetector.unit.test.ts index f8262e146..40fb56265 100644 --- a/src/test/features/inlineScript/lazyDetector.unit.test.ts +++ b/src/test/features/inlineScript/lazyDetector.unit.test.ts @@ -6,25 +6,30 @@ import * as path from 'path'; import * as sinon from 'sinon'; import { Disposable, TextDocument, TextDocumentChangeEvent, TextDocumentContentChangeEvent, Uri } from 'vscode'; import * as ism from '../../../common/inlineScript/metadata'; +import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; import { EventNames } from '../../../common/telemetry/constants'; import * as telemetrySender from '../../../common/telemetry/sender'; import * as wapi from '../../../common/workspace.apis'; import { InlineScriptLazyDetector, shouldHandleUri } from '../../../features/inlineScript/lazyDetector'; -// Build a minimal TextDocument stub. Only the `uri` field is read by -// the detector; the rest exists to satisfy the type. +let docDirtyByUri = new Map(); + function makeDoc(uri: Uri): TextDocument { - return { uri } as TextDocument; + return { + uri, + getText: () => '', + isDirty: docDirtyByUri.get(uri.toString()) ?? false, + } as TextDocument; } -// A non-empty change event payload. The actual content of the -// changes is not inspected by the detector; only `contentChanges.length` -// matters. const NON_EMPTY_CHANGES: readonly TextDocumentContentChangeEvent[] = [ { range: undefined as never, rangeOffset: 0, rangeLength: 0, text: 'x' }, ]; -function makeChange(uri: Uri, changes: readonly TextDocumentContentChangeEvent[] = NON_EMPTY_CHANGES): TextDocumentChangeEvent { +function makeChange( + uri: Uri, + changes: readonly TextDocumentContentChangeEvent[] = NON_EMPTY_CHANGES, +): TextDocumentChangeEvent { return { document: makeDoc(uri), contentChanges: changes, @@ -43,18 +48,27 @@ suite('InlineScriptLazyDetector', () => { let onDidOpenStub: sinon.SinonStub; let onDidSaveStub: sinon.SinonStub; let onDidChangeStub: sinon.SinonStub; + let onDidDeleteStub: sinon.SinonStub; + let onDidRenameStub: sinon.SinonStub; let getOpenTextDocumentsStub: sinon.SinonStub; let getWorkspaceFolderStub: sinon.SinonStub; let readMetadataStub: sinon.SinonStub; let sendTelemetryStub: sinon.SinonStub; + let routingRegistry: InlineScriptRoutingRegistry; let openListener: ((doc: TextDocument) => unknown) | undefined; let saveListener: ((doc: TextDocument) => unknown) | undefined; let changeListener: ((e: TextDocumentChangeEvent) => unknown) | undefined; + let deleteListener: ((e: { files: readonly Uri[] }) => unknown) | undefined; + let renameListener: ((e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) | undefined; setup(() => { openListener = undefined; saveListener = undefined; changeListener = undefined; + deleteListener = undefined; + renameListener = undefined; + docDirtyByUri = new Map(); + routingRegistry = new InlineScriptRoutingRegistry(); onDidOpenStub = sinon.stub(wapi, 'onDidOpenTextDocument'); onDidOpenStub.callsFake((listener: (doc: TextDocument) => unknown) => { @@ -80,15 +94,26 @@ suite('InlineScriptLazyDetector', () => { }); }); - // Default to an empty list of open documents. Tests that - // exercise the catch-up replay override this. + onDidDeleteStub = sinon.stub(wapi, 'onDidDeleteFiles'); + onDidDeleteStub.callsFake((listener: (e: { files: readonly Uri[] }) => unknown) => { + deleteListener = listener; + return new Disposable(() => { + deleteListener = undefined; + }); + }); + + onDidRenameStub = sinon.stub(wapi, 'onDidRenameFiles'); + onDidRenameStub.callsFake((listener: (e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) => { + renameListener = listener; + return new Disposable(() => { + renameListener = undefined; + }); + }); + getOpenTextDocumentsStub = sinon.stub(wapi, 'getOpenTextDocuments'); getOpenTextDocumentsStub.returns([]); getWorkspaceFolderStub = sinon.stub(wapi, 'getWorkspaceFolder'); - // By default, every URI is treated as being inside a workspace - // folder. Tests that want to exercise the "not in workspace" - // branch override this. getWorkspaceFolderStub.callsFake((uri: Uri) => ({ uri: Uri.file(path.dirname(uri.fsPath)), name: 'mockWorkspace', @@ -105,6 +130,12 @@ suite('InlineScriptLazyDetector', () => { sinon.restore(); }); + function createDetector(): InlineScriptLazyDetector { + const detector = new InlineScriptLazyDetector(routingRegistry); + detector.activate(); + return detector; + } + async function fireOpen(uri: Uri): Promise { assert.ok(openListener, 'open listener should be registered after activate()'); await openListener!(makeDoc(uri)); @@ -120,50 +151,72 @@ suite('InlineScriptLazyDetector', () => { changeListener!(makeChange(uri, changes)); } - // Filter `sendTelemetryStub.getCalls()` to a single inline script event name. + function makeContentChanges(rangeOffset: number): readonly TextDocumentContentChangeEvent[] { + return [{ range: undefined as never, rangeOffset, rangeLength: 0, text: 'x' }]; + } + + function setDocDirty(uri: Uri, isDirty: boolean): void { + docDirtyByUri.set(uri.toString(), isDirty); + } + + function fireDelete(...uris: Uri[]): void { + assert.ok(deleteListener, 'delete listener should be registered after activate()'); + deleteListener!({ files: uris }); + } + + function fireRename(oldUri: Uri, newUri: Uri): void { + assert.ok(renameListener, 'rename listener should be registered after activate()'); + renameListener!({ files: [{ oldUri, newUri }] }); + } + function callsFor(name: EventNames): sinon.SinonSpyCall[] { return sendTelemetryStub.getCalls().filter((c) => c.args[0] === name); } - test('activate() subscribes to onDidOpen, onDidSave, and onDidChange', () => { - const detector = new InlineScriptLazyDetector(); - detector.activate(); + function flushImmediate(): Promise { + return new Promise((resolve) => setImmediate(resolve)); + } + + test('activate() subscribes to document and file events', () => { + const detector = createDetector(); assert.ok(onDidOpenStub.calledOnce, 'should subscribe to onDidOpenTextDocument'); assert.ok(onDidSaveStub.calledOnce, 'should subscribe to onDidSaveTextDocument'); assert.ok(onDidChangeStub.calledOnce, 'should subscribe to onDidChangeTextDocument'); + assert.ok(onDidDeleteStub.calledOnce, 'should subscribe to onDidDeleteFiles'); + assert.ok(onDidRenameStub.calledOnce, 'should subscribe to onDidRenameFiles'); detector.dispose(); }); test('skips non-file URI schemes', async () => { - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(Uri.parse('untitled:foo.py')); assert.ok(readMetadataStub.notCalled, 'should not read metadata for non-file URI'); detector.dispose(); }); test('skips non-.py files', async () => { - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(Uri.file(path.resolve('/ws/foo.txt'))); assert.ok(readMetadataStub.notCalled, 'should not read metadata for non-.py files'); detector.dispose(); }); - test('skips files outside any workspace folder', async () => { + test('skips telemetry for files outside any workspace folder but still refreshes saved routing metadata', async () => { getWorkspaceFolderStub.returns(undefined); - const detector = new InlineScriptLazyDetector(); - detector.activate(); - await fireOpen(Uri.file(path.resolve('/elsewhere/foo.py'))); - assert.ok(readMetadataStub.notCalled, 'should not read metadata for out-of-workspace files'); + readMetadataStub.resolves(VALID_METADATA); + routingRegistry.setValidatedAssociation(Uri.file(path.resolve('/elsewhere/foo.py')), true); + const detector = createDetector(); + const uri = Uri.file(path.resolve('/elsewhere/foo.py')); + await fireOpen(uri); + assert.ok(readMetadataStub.calledOnceWithExactly(uri), 'should still read saved metadata for routing'); + assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_DETECTED).length, 0, 'should not emit telemetry'); detector.dispose(); }); test('reads metadata for an in-workspace .py file on open', async () => { const uri = Uri.file(path.resolve('/ws/foo.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); assert.strictEqual(readMetadataStub.callCount, 1, 'open should trigger exactly one read'); assert.strictEqual((readMetadataStub.firstCall.args[0] as Uri).toString(), uri.toString()); @@ -173,18 +226,31 @@ suite('InlineScriptLazyDetector', () => { test('reads metadata for an in-workspace .py file on save', async () => { const uri = Uri.file(path.resolve('/ws/bar.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireSave(uri); assert.strictEqual(readMetadataStub.callCount, 1, 'save should trigger exactly one read'); detector.dispose(); }); + test('withholds routeability and skips disk reads for dirty documents on open', async () => { + const uri = Uri.file(path.resolve('/ws/dirty.py')); + setDocDirty(uri, true); + routingRegistry.setMetadata(uri, VALID_METADATA); + routingRegistry.setValidatedAssociation(uri, true); + const detector = createDetector(); + + await fireOpen(uri); + + assert.ok(readMetadataStub.notCalled, 'dirty open should not read saved metadata'); + assert.strictEqual(routingRegistry.getMetadata(uri), undefined); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + test('concurrent open + open coalesces to a single read', async () => { const uri = Uri.file(path.resolve('/ws/dedup.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await Promise.all([fireOpen(uri), fireOpen(uri)]); assert.strictEqual(readMetadataStub.callCount, 1, 'open+open should coalesce to a single read'); detector.dispose(); @@ -193,12 +259,8 @@ suite('InlineScriptLazyDetector', () => { test('concurrent open + save coalesces to a single read', async () => { const uri = Uri.file(path.resolve('/ws/race.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await Promise.all([fireOpen(uri), fireSave(uri)]); - // The slim observer has no cached state to keep fresh, so - // simple URI-level dedup is sufficient: a save concurrent - // with an in-flight open coalesces with it. assert.strictEqual(readMetadataStub.callCount, 1, 'concurrent open+save should coalesce to a single read'); detector.dispose(); }); @@ -212,28 +274,160 @@ suite('InlineScriptLazyDetector', () => { }), ); - const detector = new InlineScriptLazyDetector(); - detector.activate(); - // Kick off the open without awaiting it; the read is parked - // on our manual resolver above. + const detector = createDetector(); const inFlight = openListener!(makeDoc(uri)) as Promise | undefined; - // Tear the detector down BEFORE the read settles. detector.dispose(); - // Now let the in-flight read complete with metadata. The - // `disposed` guard inside processOnce must prevent any - // further work — including the detection telemetry event. resolveRead!(VALID_METADATA); await assert.doesNotReject(inFlight ?? Promise.resolve()); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_DETECTED).length, 0, 'no detection event after dispose'); }); - // ---------- catch-up replay over `getOpenTextDocuments` ---------- + test('tracks loose local .py files for routing even when telemetry skips them', async () => { + const uri = Uri.file(path.resolve('/elsewhere/loose.py')); + readMetadataStub.resolves(VALID_METADATA); + routingRegistry.setValidatedAssociation(uri.fsPath, true); + getWorkspaceFolderStub.returns(undefined); + const detector = createDetector(); - // Drain the microtask queue and the next `setImmediate` slot so - // the deferred catch-up replay can run before assertions. - function flushImmediate(): Promise { - return new Promise((resolve) => setImmediate(resolve)); - } + await fireOpen(uri); + + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + assert.ok(readMetadataStub.calledOnceWithExactly(uri), 'loose files should still refresh saved metadata'); + detector.dispose(); + }); + + test('replays already-open loose .py documents for routing on activation', async () => { + const uri = Uri.file(path.resolve('/elsewhere/replayed.py')); + readMetadataStub.resolves(VALID_METADATA); + routingRegistry.setValidatedAssociation(uri.fsPath, true); + getWorkspaceFolderStub.returns(undefined); + getOpenTextDocumentsStub.returns([makeDoc(uri)]); + + const detector = createDetector(); + await flushImmediate(); + + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + assert.ok(readMetadataStub.calledOnceWithExactly(uri), 'loose replay should refresh saved metadata'); + detector.dispose(); + }); + + test('clears routeability on header edits and refreshes saved metadata on the next save', async () => { + const uri = Uri.file(path.resolve('/elsewhere/edited.py')); + routingRegistry.setValidatedAssociation(uri, true); + readMetadataStub.onFirstCall().resolves(VALID_METADATA); + readMetadataStub.onSecondCall().resolves(VALID_METADATA); + const detector = createDetector(); + + await fireOpen(uri); + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + + fireChange(uri, makeContentChanges(0)); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + + await fireSave(uri); + assert.deepStrictEqual(routingRegistry.getMetadata(uri), VALID_METADATA); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('preserves routing when edits are after the metadata block', async () => { + const uri = Uri.file(path.resolve('/elsewhere/bodyEdit.py')); + readMetadataStub.resolves(VALID_METADATA); + routingRegistry.setValidatedAssociation(uri, true); + const detector = createDetector(); + + await fireOpen(uri); + const metadata = routingRegistry.getMetadata(uri); + assert.ok(metadata, 'expected routing metadata after open'); + + fireChange(uri, makeContentChanges(metadata!.range.end + 5)); + + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + detector.dispose(); + }); + + test('save rehydrates routing from saved file metadata rather than the live buffer', async () => { + const uri = Uri.file(path.resolve('/elsewhere/savedState.py')); + readMetadataStub.resolves(VALID_METADATA); + routingRegistry.setValidatedAssociation(uri, true); + const detector = createDetector(); + + await fireSave(uri); + + assert.strictEqual(routingRegistry.shouldRoute(uri), true); + detector.dispose(); + }); + + test('restored dirty open with removed metadata stays non-routeable until save', async () => { + const uri = Uri.file(path.resolve('/elsewhere/restoredDirtyRemoved.py')); + setDocDirty(uri, true); + routingRegistry.setMetadata(uri, VALID_METADATA); + routingRegistry.setValidatedAssociation(uri, true); + readMetadataStub.resolves(undefined); + const detector = createDetector(); + + await fireOpen(uri); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + assert.ok(readMetadataStub.notCalled); + + setDocDirty(uri, false); + await fireSave(uri); + assert.strictEqual(routingRegistry.getMetadata(uri), undefined); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('restored dirty open with changed metadata stays non-routeable until save', async () => { + const uri = Uri.file(path.resolve('/elsewhere/restoredDirtyChanged.py')); + const changedMetadata = { + ...VALID_METADATA, + dependencies: ['urllib3'], + } satisfies ism.InlineScriptMetadata; + setDocDirty(uri, true); + routingRegistry.setMetadata(uri, VALID_METADATA); + routingRegistry.setValidatedAssociation(uri, true); + readMetadataStub.resolves(changedMetadata); + const detector = createDetector(); + + await fireOpen(uri); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + assert.ok(readMetadataStub.notCalled); + + setDocDirty(uri, false); + await fireSave(uri); + assert.deepStrictEqual(routingRegistry.getMetadata(uri), changedMetadata); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('clears routing metadata and validation when a file is deleted', async () => { + const uri = Uri.file(path.resolve('/elsewhere/deleted.py')); + readMetadataStub.resolves(VALID_METADATA); + routingRegistry.setValidatedAssociation(uri, true); + const detector = createDetector(); + await fireOpen(uri); + + fireDelete(uri); + + assert.strictEqual(routingRegistry.getMetadata(uri), undefined); + assert.strictEqual(routingRegistry.shouldRoute(uri), false); + detector.dispose(); + }); + + test('clears routing metadata and validation for the old path when a file is renamed', async () => { + const oldUri = Uri.file(path.resolve('/elsewhere/old.py')); + const newUri = Uri.file(path.resolve('/elsewhere/new.py')); + readMetadataStub.resolves(VALID_METADATA); + routingRegistry.setValidatedAssociation(oldUri, true); + const detector = createDetector(); + await fireOpen(oldUri); + + fireRename(oldUri, newUri); + + assert.strictEqual(routingRegistry.getMetadata(oldUri), undefined); + assert.strictEqual(routingRegistry.shouldRoute(oldUri), false); + detector.dispose(); + }); test('activate() replays already-open .py documents via setImmediate', async () => { const uriWithMeta = Uri.file(path.resolve('/ws/withMeta.py')); @@ -244,15 +438,10 @@ suite('InlineScriptLazyDetector', () => { ); getOpenTextDocumentsStub.returns([makeDoc(uriWithMeta), makeDoc(uriPlain), makeDoc(uriNonPy)]); - const detector = new InlineScriptLazyDetector(); - detector.activate(); - // Wait for the deferred catch-up. + const detector = createDetector(); await flushImmediate(); - // Then await any in-flight reads kicked off by the replay. await flushImmediate(); - // The non-`.py` URI must be filtered out by `shouldHandleUri` - // BEFORE the read is attempted. assert.strictEqual(readMetadataStub.callCount, 2, 'should read each candidate .py document exactly once'); const readUris = readMetadataStub.getCalls().map((c) => (c.args[0] as Uri).toString()); assert.ok(readUris.includes(uriWithMeta.toString())); @@ -263,21 +452,16 @@ suite('InlineScriptLazyDetector', () => { test('dispose() cancels the pending catch-up replay', async () => { getOpenTextDocumentsStub.returns([makeDoc(Uri.file(path.resolve('/ws/never.py')))]); - const detector = new InlineScriptLazyDetector(); - detector.activate(); - // Tear down BEFORE the `setImmediate` slot fires. + const detector = createDetector(); detector.dispose(); await flushImmediate(); assert.ok(readMetadataStub.notCalled, 'dispose() must clear the pending setImmediate handle'); }); - // ---------- inlineScript.detected telemetry ---------- - test('inlineScript.detected fires once with trigger=open + dependencyCount + hasRequiresPython', async () => { const uri = Uri.file(path.resolve('/ws/detect.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); const detectedCalls = callsFor(EventNames.INLINE_SCRIPT_DETECTED); @@ -291,8 +475,7 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.detected fires with trigger=save when surfaced by a save event', async () => { const uri = Uri.file(path.resolve('/ws/detectOnSave.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireSave(uri); const detectedCalls = callsFor(EventNames.INLINE_SCRIPT_DETECTED); @@ -304,8 +487,7 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.detected does not fire when the file has no metadata block', async () => { const uri = Uri.file(path.resolve('/ws/plain.py')); readMetadataStub.resolves(undefined); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_DETECTED).length, 0); detector.dispose(); @@ -314,8 +496,7 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.detected is deduplicated across repeated opens and saves of the same URI', async () => { const uri = Uri.file(path.resolve('/ws/repeat.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); await fireSave(uri); await fireSave(uri); @@ -332,8 +513,7 @@ suite('InlineScriptLazyDetector', () => { tool: undefined, range: { start: 0, end: 20 }, } satisfies ism.InlineScriptMetadata); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); const [, measures, properties] = callsFor(EventNames.INLINE_SCRIPT_DETECTED)[0].args; @@ -342,19 +522,15 @@ suite('InlineScriptLazyDetector', () => { detector.dispose(); }); - // ---------- inlineScript.edited telemetry ---------- - test('inlineScript.edited fires once on first content change after detection', async () => { const uri = Uri.file(path.resolve('/ws/edit.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); fireChange(uri); const editedCalls = callsFor(EventNames.INLINE_SCRIPT_EDITED); assert.strictEqual(editedCalls.length, 1, 'edited event should fire exactly once'); - // Second arg is the measure (number → { duration }); accept either form. const measureArg = editedCalls[0].args[1]; assert.strictEqual(typeof measureArg, 'number', 'measure should be a number (latency ms)'); assert.ok((measureArg as number) >= 0, 'duration should be non-negative'); @@ -364,8 +540,7 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited is deduplicated across repeated edits of the same URI', async () => { const uri = Uri.file(path.resolve('/ws/multiEdit.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); fireChange(uri); fireChange(uri); @@ -377,8 +552,7 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited does not fire for changes on a URI that was never detected', async () => { const uri = Uri.file(path.resolve('/ws/notDetected.py')); readMetadataStub.resolves(undefined); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); fireChange(uri); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_EDITED).length, 0); @@ -388,15 +562,10 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited ignores change events with no content changes', async () => { const uri = Uri.file(path.resolve('/ws/noOpChange.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); - // VS Code can fire a change event with an empty contentChanges - // array for things like dirty-state toggles; that's not a user - // edit and must not count. fireChange(uri, []); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_EDITED).length, 0); - // A real edit still counts after the no-op was ignored. fireChange(uri); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_EDITED).length, 1); detector.dispose(); @@ -405,8 +574,7 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited is suppressed after dispose()', async () => { const uri = Uri.file(path.resolve('/ws/disposedEdit.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = new InlineScriptLazyDetector(); - detector.activate(); + const detector = createDetector(); await fireOpen(uri); const grabbedChangeListener = changeListener!; detector.dispose(); diff --git a/src/test/features/pythonApi.unit.test.ts b/src/test/features/pythonApi.unit.test.ts index 0287828e5..bd464b4b0 100644 --- a/src/test/features/pythonApi.unit.test.ts +++ b/src/test/features/pythonApi.unit.test.ts @@ -1,65 +1,119 @@ import * as assert from 'assert'; +import * as sinon from 'sinon'; import { EventEmitter, Uri } from 'vscode'; -import { PythonProject } from '../../api'; +import { PythonEnvironment, PythonProject } from '../../api'; +import * as managerReady from '../../features/common/managerReady'; import { PythonEnvironmentApiImpl } from '../../features/pythonApi'; import { PythonProjectManager } from '../../internal.api'; suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { - test('Fires event with correct added and removed projects', async () => { - // 1. Create a mock EventEmitter to simulate the internal project manager + test('fires event with correct added and removed projects', () => { const onDidChangeProjectsEmitter = new EventEmitter(); - - // 2. Mock the PythonProjectManager let currentProjects: PythonProject[] = []; const mockProjectManager = { getProjects: () => currentProjects, onDidChangeProjects: onDidChangeProjectsEmitter.event, } as unknown as PythonProjectManager; - // 3. Mock the other required constructor arguments using ConstructorParameters type ApiArgs = ConstructorParameters; - const mockEnvManagers = { onDidChangeActiveEnvironment: new EventEmitter().event } as unknown as ApiArgs[0]; const mockProjectCreators = {} as unknown as ApiArgs[2]; const mockTerminalManager = {} as unknown as ApiArgs[3]; const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4]; - // 4. Initialize the API instance const api = new PythonEnvironmentApiImpl( mockEnvManagers, mockProjectManager, mockProjectCreators, mockTerminalManager, - mockEnvVarManager + mockEnvVarManager, ); - // 5. Listen to the public event we are testing let firedEventPayload: unknown = null; - api.onDidChangePythonProjects((e: unknown) => { - firedEventPayload = e; + api.onDidChangePythonProjects((event: unknown) => { + firedEventPayload = event; }); - // 6. Simulate adding a project const newProject = { uri: Uri.joinPath(Uri.file(process.cwd()), 'fake', 'path') } as unknown as PythonProject; - currentProjects = [newProject]; // Update the mock's state - - // Fire the internal event + currentProjects = [newProject]; onDidChangeProjectsEmitter.fire(); - // 7. Assert the public event fired with the correct delta assert.ok(firedEventPayload, 'Event should have fired'); - assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 1, 'Should have 1 added project'); + assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 1); assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added[0].uri.fsPath, newProject.uri.fsPath); - assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 0, 'Should have 0 removed projects'); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 0); - // 8. Simulate removing the project firedEventPayload = null; currentProjects = []; onDidChangeProjectsEmitter.fire(); assert.ok(firedEventPayload, 'Event should have fired'); - assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 0, 'Should have 0 added projects'); - assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 1, 'Should have 1 removed project'); - assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed[0].uri.fsPath, newProject.uri.fsPath); + assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 0); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 1); + assert.strictEqual( + (firedEventPayload as { removed: PythonProject[] }).removed[0].uri.fsPath, + newProject.uri.fsPath, + ); + }); +}); + +suite('PythonEnvironmentApiImpl - getEnvironment timeout fallback', () => { + let clock: sinon.SinonFakeTimers; + + setup(() => { + clock = sinon.useFakeTimers(); + sinon.stub(managerReady, 'waitForEnvManager').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('returns the last-known environment while a slower lookup continues in the background', async () => { + const scope = Uri.file('/workspace/script.py'); + const lastKnown: PythonEnvironment = { + envId: { id: 'default', managerId: 'ms-python.python:venv' }, + name: 'default', + displayName: 'default', + displayPath: '/env/default', + version: '3.11.0', + environmentPath: Uri.file('/env/default'), + execInfo: { run: { executable: '/env/default/python', args: [] } }, + sysPrefix: '/env/default', + }; + let resolveEnvironment: ((value: PythonEnvironment | undefined) => void) | undefined; + + const mockProjectManager = { + getProjects: () => [], + onDidChangeProjects: new EventEmitter().event, + } as unknown as PythonProjectManager; + + type ApiArgs = ConstructorParameters; + const mockEnvManagers = { + onDidChangeActiveEnvironment: new EventEmitter().event, + getEnvironment: sinon.stub().returns( + new Promise((resolve) => { + resolveEnvironment = resolve; + }), + ), + getLastKnownEnvironment: sinon.stub().withArgs(scope).returns(lastKnown), + } as unknown as ApiArgs[0]; + const mockProjectCreators = {} as unknown as ApiArgs[2]; + const mockTerminalManager = {} as unknown as ApiArgs[3]; + const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4]; + + const api = new PythonEnvironmentApiImpl( + mockEnvManagers, + mockProjectManager, + mockProjectCreators, + mockTerminalManager, + mockEnvVarManager, + ); + + const pending = api.getEnvironment(scope); + await clock.tickAsync(1_000); + + assert.strictEqual(await pending, lastKnown); + resolveEnvironment?.(undefined); }); -}); \ No newline at end of file +}); diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index 3d0488cae..dc4dca486 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -6,16 +6,18 @@ 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 { Disposable, LogOutputChannel, TextDocument, Uri } from 'vscode'; import { 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'; +import { InlineScriptRoutingRegistry } from '../../../../common/inlineScript/routingRegistry'; import * as lockfileApis from '../../../../common/lockfile.apis'; import * as persistentState from '../../../../common/persistentState'; import { isWindows } from '../../../../common/utils/platformUtils'; import { normalizePath } from '../../../../common/utils/pathUtils'; import { getVenvPythonPath } from '../../../../common/utils/virtualEnvironment'; +import * as workspaceApis from '../../../../common/workspace.apis'; import { InlineScriptEnvManager, INLINE_SCRIPT_ENVS_KEY, @@ -32,6 +34,10 @@ const VALID_METADATA: metadataReader.InlineScriptMetadata = { dependencies: ['requests'], range: { start: 0, end: 40 }, }; +const VALID_METADATA_IDENTITY = JSON.stringify({ + requiresPython: '>=3.11', + dependencies: ['requests'], +}); function makeFakeLog(): LogOutputChannel { return { @@ -109,9 +115,15 @@ suite('InlineScriptEnvManager', () => { let releaseLockStub: sinon.SinonStub; let resolveSystemPythonStub: sinon.SinonStub; let resolveVenvStub: sinon.SinonStub; + let routingRegistry: InlineScriptRoutingRegistry; + let sidecarsByEnvDir: Map; + let environmentsByExecutablePath: Map; + let cacheKeysByInputs: Map; let tempRoot: string; let baseInterpreterStatusStub: sinon.SinonStub; let writeMetaStub: sinon.SinonStub; + let deleteFilesListener: ((e: { files: readonly Uri[] }) => unknown) | undefined; + let renameFilesListener: ((e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) | undefined; let workspaceState: { get: sinon.SinonStub; set: sinon.SinonStub; @@ -133,6 +145,12 @@ suite('InlineScriptEnvManager', () => { refreshEnvironments: apiRefreshEnvironmentsStub, } as unknown as PythonEnvironmentApi; nativeFinder = {} as NativePythonFinder; + routingRegistry = new InlineScriptRoutingRegistry(); + sidecarsByEnvDir = new Map(); + environmentsByExecutablePath = new Map(); + cacheKeysByInputs = new Map(); + deleteFilesListener = undefined; + renameFilesListener = undefined; baseManager = {} as EnvironmentManager; persistedAssociations = undefined; workspaceState = { @@ -149,38 +167,75 @@ suite('InlineScriptEnvManager', () => { sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(workspaceState); readMetadataStub = sinon.stub(metadataReader, 'readInlineScriptMetadataFromFile').resolves(VALID_METADATA); - computeCacheKeyStub = sinon.stub(cacheKey, 'computeCacheKey').returns(CACHE_KEY); + computeCacheKeyStub = sinon.stub(cacheKey, 'computeCacheKey').callsFake((inputs) => { + return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; + }); + registerCacheKey(CACHE_KEY, VALID_METADATA.dependencies ?? [], baseExecutable); getAvailablePythonVersionsStub = sinon.stub(uvPythonInstaller, 'getAvailablePythonVersions').resolves([]); ensureUvForVersionLookupStub = sinon .stub(uvPythonInstaller, 'ensureUvForInlineScriptVersionLookup') .resolves(true); promptInstallPythonViaUvStub = sinon.stub(uvPythonInstaller, 'promptInstallPythonViaUv'); - inspectMetaStub = sinon.stub(cacheLayout, 'inspectMetaJson').resolves({ kind: 'missing' }); + inspectMetaStub = sinon.stub(cacheLayout, 'inspectMetaJson').callsFake(async (envDir: Uri) => { + const result = sidecarsByEnvDir.get(normalizePath(envDir.fsPath)) ?? 'missing'; + if (result === 'missing' || result === 'invalid' || result === 'unavailable') { + return { kind: result }; + } + return { kind: 'valid', metadata: result }; + }); baseInterpreterStatusStub = sinon.stub(cacheLayout, 'getBaseInterpreterStatus').resolves('available'); - writeMetaStub = sinon.stub(cacheLayout, 'writeMetaJson').resolves(); + writeMetaStub = sinon.stub(cacheLayout, 'writeMetaJson').callsFake(async (envDir: Uri, meta: cacheLayout.InlineScriptEnvMeta) => { + sidecarsByEnvDir.set(normalizePath(envDir.fsPath), meta); + }); retainLockStub = sinon.stub().resolves(); releaseLockStub = sinon.stub().resolves(); lockStub = sinon .stub(lockfileApis, 'acquireFileLock') .resolves({ release: releaseLockStub, retain: retainLockStub }); resolveSystemPythonStub = sinon.stub(builtinUtils, 'resolveSystemPythonEnvironmentPath').resolves(undefined); - resolveVenvStub = sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(undefined); + resolveVenvStub = sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').callsFake(async (environmentPath: string) => { + return environmentsByExecutablePath.get(normalizePath(environmentPath)); + }); + sinon.stub(workspaceApis, 'onDidDeleteFiles').callsFake((listener: (e: { files: readonly Uri[] }) => unknown) => { + deleteFilesListener = listener; + return new Disposable(() => { + deleteFilesListener = undefined; + }); + }); + sinon + .stub(workspaceApis, 'onDidRenameFiles') + .callsFake((listener: (e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) => { + renameFilesListener = listener; + return new Disposable(() => { + renameFilesListener = undefined; + }); + }); + sinon.stub(workspaceApis, 'getOpenTextDocuments').returns([]); createWithProgressStub = sinon.stub(venvUtils, 'createWithProgress').callsFake(async (...args: unknown[]) => { const envDir = args[6] as string; const selectedBase = args[4] as PythonEnvironment; await fs.outputFile(getVenvPythonPath(envDir), ''); + const environment = makeEnvironment( + 'ms-python.python:inline-script', + selectedBase.version, + getVenvPythonPath(envDir), + envDir, + ); + environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); return { - environment: makeEnvironment( - 'ms-python.python:inline-script', - selectedBase.version, - getVenvPythonPath(envDir), - envDir, - ), + environment, }; }); clock = sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }); - manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); + manager = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + routingRegistry, + ); }); teardown(async () => { @@ -197,8 +252,21 @@ suite('InlineScriptEnvManager', () => { return cacheLayout.getScriptEnvDir(globalStorageUri, CACHE_KEY); } - function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta): void { - inspectMetaStub.resolves({ kind: 'valid', metadata }); + function getCacheKeyInputKey(dependencies: readonly string[], interpreterPath: string): string { + return JSON.stringify({ + dependencies: Array.from( + new Set(dependencies.map((dependency) => cacheKey.normalizeDependency(dependency)).filter(Boolean)), + ).sort(), + interpreterPath: normalizePath(interpreterPath), + }); + } + + function registerCacheKey(cacheKeyValue: string, dependencies: readonly string[], interpreterPath: string): void { + cacheKeysByInputs.set(getCacheKeyInputKey(dependencies, interpreterPath), cacheKeyValue); + } + + function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta, targetEnvDir: Uri = envDir()): void { + sidecarsByEnvDir.set(normalizePath(targetEnvDir.fsPath), metadata); } async function createOwnedEnvironment( @@ -207,11 +275,25 @@ suite('InlineScriptEnvManager', () => { ): Promise { const location = cacheLayout.getScriptEnvDir(globalStorageUri, cacheKey).fsPath; const executable = getVenvPythonPath(location); + const baseInterpreterPath = + cacheKey === CACHE_KEY + ? baseExecutable + : path.join(tempRoot, `base-python-${cacheKey}`, isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(baseInterpreterPath, ''); await fs.outputFile(executable, ''); - return { + registerCacheKey(cacheKey, VALID_METADATA.dependencies ?? [], baseInterpreterPath); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }, Uri.file(location)); + const environment = { ...makeEnvironment('ms-python.python:inline-script', '3.12.4', executable, location), envId: { managerId: 'ms-python.python:inline-script', id: envId }, }; + environmentsByExecutablePath.set(normalizePath(executable), environment); + return environment; } async function waitForStubCall(stub: sinon.SinonStub): Promise { @@ -224,10 +306,133 @@ suite('InlineScriptEnvManager', () => { assert.fail('Expected the stub to be called'); } + async function waitForStubCallCount(stub: { callCount: number }, count: number): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (stub.callCount >= count) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.fail(`Expected the stub to be called at least ${count} times`); + } + function nextTurn(): Promise { return new Promise((resolve) => setImmediate(resolve)); } + function fireDelete(...files: Uri[]): void { + assert.ok(deleteFilesListener, 'delete listener should be registered'); + deleteFilesListener!({ files }); + } + + function fireRename(oldUri: Uri, newUri: Uri): void { + assert.ok(renameFilesListener, 'rename listener should be registered'); + renameFilesListener!({ files: [{ oldUri, newUri }] }); + } + + function workspaceStateSetCalls(key: string): readonly sinon.SinonSpyCall[] { + return workspaceState.set.getCalls().filter((call) => call.args[0] === key); + } + + function matchedAssociationRecord(environmentPath: string, metadataIdentity: string = VALID_METADATA_IDENTITY): unknown { + return { + schemaVersion: 1, + environmentPath, + metadataBinding: { + kind: 'matched', + sourceIdentity: metadataIdentity, + }, + }; + } + + function pendingAssociationRecord(environmentPath: string, metadataIdentity: string = VALID_METADATA_IDENTITY): unknown { + return { + schemaVersion: 1, + environmentPath, + metadataBinding: { + kind: 'pending', + sourceIdentity: metadataIdentity, + }, + }; + } + + function futureAssociationRecord(environmentPath: string): unknown { + return { + schemaVersion: 2, + environmentPath, + metadataBinding: { + kind: 'matched', + sourceIdentity: 'future', + }, + }; + } + + async function triggerSavedMetadataChange( + registry: InlineScriptRoutingRegistry, + managerInstance: InlineScriptEnvManager, + uri: Uri, + metadata: metadataReader.InlineScriptMetadata = VALID_METADATA, + ): Promise { + registry.setMetadata(uri, metadata); + await ( + managerInstance as unknown as { + handleSavedMetadataChange(event: { + uri: Uri; + metadata: metadataReader.InlineScriptMetadata; + metadataIdentity: string | undefined; + metadataRevision: number; + }): Promise; + } + ).handleSavedMetadataChange({ + uri, + metadata, + metadataIdentity: registry.getMetadataIdentity(uri), + metadataRevision: registry.getMetadataRevision(uri), + }); + } + + function asMetadataRefreshManager(managerInstance: InlineScriptEnvManager): { + refreshValidatedAssociationForMetadataInternal( + scriptPath: string, + uri: Uri, + metadata: metadataReader.InlineScriptMetadata, + metadataIdentity: string, + metadataRevision: number, + associationRevision: number, + ): Promise; + currentCacheEntryProvesSourceMetadataIdentity( + candidate: PythonEnvironment, + metadataIdentity: string, + metadata: metadataReader.InlineScriptMetadata, + ): Promise; + cachedAssociationValidatedAt: Map; + lastValidatedMetadataIdentities: Map; + lastValidatedMetadataIdentityProofs: Map; + associationRevisions: Map; + subscriptions: Disposable[]; + } { + return managerInstance as unknown as { + refreshValidatedAssociationForMetadataInternal( + scriptPath: string, + uri: Uri, + metadata: metadataReader.InlineScriptMetadata, + metadataIdentity: string, + metadataRevision: number, + associationRevision: number, + ): Promise; + currentCacheEntryProvesSourceMetadataIdentity( + candidate: PythonEnvironment, + metadataIdentity: string, + metadata: metadataReader.InlineScriptMetadata, + ): Promise; + cachedAssociationValidatedAt: Map; + lastValidatedMetadataIdentities: Map; + lastValidatedMetadataIdentityProofs: Map; + associationRevisions: Map; + subscriptions: Disposable[]; + }; + } + suite('static metadata and deferred methods', () => { test('exposes creation but leaves later-phase methods empty', async () => { const asInterface: EnvironmentManager = manager; @@ -961,6 +1166,9 @@ suite('InlineScriptEnvManager', () => { baseInterpreterPath: baseExecutable, baseInterpreterVersion: baseEnvironment.version, lastUsedAt: NOW.toISOString(), + sourceMetadataIdentityHashes: [ + cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), + ], }, ]); assert.strictEqual( @@ -1026,6 +1234,482 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(createWithProgressStub.callCount, 1); }); + test('records every successful same-key coalesced caller provenance for later set and restart routing', async () => { + const cacheKeyValue = 'fedcba9876543210'; + const firstUri = scriptUri('a.py'); + const secondUri = scriptUri('b.py'); + const secondMetadata = { + ...VALID_METADATA, + requiresPython: '>=3.12', + } satisfies metadataReader.InlineScriptMetadata; + const firstIdentity = VALID_METADATA_IDENTITY; + const secondIdentity = JSON.stringify({ + requiresPython: secondMetadata.requiresPython, + dependencies: secondMetadata.dependencies, + }); + const metadataByScript = new Map([ + [normalizePath(firstUri.fsPath), VALID_METADATA], + [normalizePath(secondUri.fsPath), secondMetadata], + ]); + readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); + routingRegistry.setMetadata(firstUri, VALID_METADATA); + routingRegistry.setMetadata(secondUri, secondMetadata); + registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); + + 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((inputs: cacheKey.CacheKeyInputs) => { + if (computeCacheKeyStub.callCount === 2) { + secondCallHashed!(); + } + return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + const environment = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ); + environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); + creationStarted!(); + await gate; + return { environment }; + }); + + const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); + await started; + const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); + await secondHashed; + continueCreation!(); + const [firstEnvironment, secondEnvironment] = await Promise.all([first, second]); + + assert.ok(firstEnvironment); + assert.strictEqual(firstEnvironment, secondEnvironment); + assert.strictEqual(lockStub.callCount, 1); + assert.strictEqual(createWithProgressStub.callCount, 1); + assert.deepStrictEqual( + ( + sidecarsByEnvDir.get( + normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), + ) as cacheLayout.InlineScriptEnvMeta + ).sourceMetadataIdentityHashes, + [ + cacheLayout.hashSourceMetadataIdentity(firstIdentity), + cacheLayout.hashSourceMetadataIdentity(secondIdentity), + ], + ); + + await manager.set(firstUri, firstEnvironment); + await manager.set(secondUri, secondEnvironment); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(firstUri.fsPath)]: matchedAssociationRecord(firstEnvironment.environmentPath.fsPath, firstIdentity), + [normalizePath(secondUri.fsPath)]: matchedAssociationRecord(secondEnvironment!.environmentPath.fsPath, secondIdentity), + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(firstUri), true); + assert.strictEqual(routingRegistry.hasValidatedAssociation(secondUri), true); + + persistedAssociations = {}; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + restartRoutingRegistry.setMetadata(firstUri, VALID_METADATA); + restartRoutingRegistry.setMetadata(secondUri, secondMetadata); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + + await restarted.set(firstUri, firstEnvironment); + await restarted.set(secondUri, secondEnvironment); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(firstUri.fsPath)]: matchedAssociationRecord(firstEnvironment.environmentPath.fsPath, firstIdentity), + [normalizePath(secondUri.fsPath)]: matchedAssociationRecord(secondEnvironment!.environmentPath.fsPath, secondIdentity), + }); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(firstUri), true); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(secondUri), true); + restarted.dispose(); + }); + + test('merges a late same-key caller that arrives while the initial sidecar write is in flight', async () => { + const cacheKeyValue = 'fedcba9876543210'; + const firstUri = scriptUri('a.py'); + const secondUri = scriptUri('b.py'); + const secondMetadata = { + ...VALID_METADATA, + requiresPython: '>=3.12', + } satisfies metadataReader.InlineScriptMetadata; + const secondIdentity = JSON.stringify({ + requiresPython: secondMetadata.requiresPython, + dependencies: secondMetadata.dependencies, + }); + const firstHash = cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY); + const secondHash = cacheLayout.hashSourceMetadataIdentity(secondIdentity); + const metadataByScript = new Map([ + [normalizePath(firstUri.fsPath), VALID_METADATA], + [normalizePath(secondUri.fsPath), secondMetadata], + ]); + readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); + registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); + let secondCallHashed: (() => void) | undefined; + const secondHashed = new Promise((resolve) => { + secondCallHashed = resolve; + }); + computeCacheKeyStub.callsFake((inputs: cacheKey.CacheKeyInputs) => { + if (computeCacheKeyStub.callCount === 2) { + secondCallHashed!(); + } + return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; + }); + + let releaseFirstWrite: (() => void) | undefined; + let firstWriteStarted: (() => void) | undefined; + const firstWriteGate = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + const firstWritePending = new Promise((resolve) => { + firstWriteStarted = resolve; + }); + let firstWrittenHashes: readonly string[] | undefined; + writeMetaStub.callsFake(async (envDir: Uri, meta: cacheLayout.InlineScriptEnvMeta) => { + if (writeMetaStub.callCount === 1) { + firstWrittenHashes = meta.sourceMetadataIdentityHashes; + firstWriteStarted!(); + await firstWriteGate; + } + sidecarsByEnvDir.set(normalizePath(envDir.fsPath), meta); + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + const environment = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ); + environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); + return { environment }; + }); + + const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); + await firstWritePending; + const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); + await secondHashed; + const pendingCreations = ( + manager as unknown as { + pendingCreations: Map; + } + ).pendingCreations; + for (let attempt = 0; attempt < 20; attempt += 1) { + if (pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes?.includes(secondHash)) { + break; + } + await nextTurn(); + } + + assert.deepStrictEqual(firstWrittenHashes, [firstHash]); + assert.strictEqual( + pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes?.includes(secondHash), + true, + ); + + releaseFirstWrite!(); + const [firstEnvironment, secondEnvironment] = await Promise.all([first, second]); + + assert.ok(firstEnvironment); + assert.strictEqual(firstEnvironment, secondEnvironment); + assert.strictEqual(createWithProgressStub.callCount, 1); + assert.strictEqual(lockStub.callCount, 2); + assert.deepStrictEqual( + ( + sidecarsByEnvDir.get( + normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), + ) as cacheLayout.InlineScriptEnvMeta + ).sourceMetadataIdentityHashes, + [firstHash, secondHash], + ); + }); + + for (const failureMode of ['lock', 'read', 'write'] as const) { + test(`late same-key caller returns undefined when durable provenance merge ${failureMode} fails, but first caller and retry succeed`, async () => { + const cacheKeyValue = 'fedcba9876543210'; + const firstUri = scriptUri('a.py'); + const secondUri = scriptUri('b.py'); + const secondMetadata = { + ...VALID_METADATA, + requiresPython: '>=3.12', + } satisfies metadataReader.InlineScriptMetadata; + const secondIdentity = JSON.stringify({ + requiresPython: secondMetadata.requiresPython, + dependencies: secondMetadata.dependencies, + }); + const firstHash = cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY); + const secondHash = cacheLayout.hashSourceMetadataIdentity(secondIdentity); + const metadataByScript = new Map([ + [normalizePath(firstUri.fsPath), VALID_METADATA], + [normalizePath(secondUri.fsPath), secondMetadata], + ]); + readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); + registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); + let secondCallHashed: (() => void) | undefined; + const secondHashed = new Promise((resolve) => { + secondCallHashed = resolve; + }); + computeCacheKeyStub.callsFake((inputs: cacheKey.CacheKeyInputs) => { + if (computeCacheKeyStub.callCount === 2) { + secondCallHashed!(); + } + return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; + }); + + let releaseFirstWrite: (() => void) | undefined; + let firstWriteStarted: (() => void) | undefined; + const firstWriteGate = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + const firstWritePending = new Promise((resolve) => { + firstWriteStarted = resolve; + }); + writeMetaStub.callsFake(async (envDir: Uri, meta: cacheLayout.InlineScriptEnvMeta) => { + if (writeMetaStub.callCount === 1) { + firstWriteStarted!(); + await firstWriteGate; + } + sidecarsByEnvDir.set(normalizePath(envDir.fsPath), meta); + }); + if (failureMode === 'lock') { + lockStub.onSecondCall().rejects(new Error('merge lock failed')); + } else if (failureMode === 'read') { + inspectMetaStub.onFirstCall().rejects(new Error('merge read failed')); + } else { + writeMetaStub.onSecondCall().rejects(new Error('merge write failed')); + } + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + const environment = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ); + environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); + return { environment }; + }); + + const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); + await firstWritePending; + const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); + await secondHashed; + releaseFirstWrite!(); + const [firstEnvironment, secondEnvironment] = await Promise.all([first, second]); + + assert.ok(firstEnvironment); + assert.strictEqual(secondEnvironment, undefined); + assert.strictEqual(createWithProgressStub.callCount, 1); + assert.deepStrictEqual( + ( + sidecarsByEnvDir.get( + normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), + ) as cacheLayout.InlineScriptEnvMeta + ).sourceMetadataIdentityHashes, + [firstHash], + ); + + const retried = await manager.create(secondUri, { additionalPackages: ['pytest'] }); + + assert.ok(retried); + assert.strictEqual(normalizePath(retried!.environmentPath.fsPath), normalizePath(firstEnvironment.environmentPath.fsPath)); + assert.deepStrictEqual( + ( + sidecarsByEnvDir.get( + normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), + ) as cacheLayout.InlineScriptEnvMeta + ).sourceMetadataIdentityHashes, + [firstHash, secondHash], + ); + assert.strictEqual(createWithProgressStub.callCount, 1); + }); + } + + test('does not record provenance when a shared same-key creation fails', async () => { + const firstUri = scriptUri('a.py'); + const secondUri = scriptUri('b.py'); + const secondMetadata = { + ...VALID_METADATA, + requiresPython: '>=3.12', + } satisfies metadataReader.InlineScriptMetadata; + const metadataByScript = new Map([ + [normalizePath(firstUri.fsPath), VALID_METADATA], + [normalizePath(secondUri.fsPath), secondMetadata], + ]); + readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); + registerCacheKey(CACHE_KEY, ['requests', 'pytest'], baseExecutable); + + 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((inputs: cacheKey.CacheKeyInputs) => { + if (computeCacheKeyStub.callCount === 2) { + secondCallHashed!(); + } + return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; + }); + createWithProgressStub.callsFake(async () => { + creationStarted!(); + await gate; + return { envCreationErr: 'boom' }; + }); + + const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); + await started; + const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); + await secondHashed; + continueCreation!(); + + assert.deepStrictEqual(await Promise.all([first, second]), [undefined, undefined]); + assert.strictEqual(writeMetaStub.callCount, 0); + assert.strictEqual(sidecarsByEnvDir.size, 0); + }); + + test('dedupes and caps coalesced same-key provenance hashes before the first sidecar write', async () => { + const cacheKeyValue = 'fedcba9876543210'; + const scriptSpecs = [ + ['script-0.py', '>=3.0'], + ['script-1.py', '>=3.1'], + ['script-2.py', '>=3.2'], + ['script-3.py', '>=3.3'], + ['script-4.py', '>=3.4'], + ['script-5.py', '>=3.5'], + ['script-6.py', '>=3.6'], + ['script-7.py', '>=3.7'], + ['script-8.py', '>=3.8'], + ['script-9.py', '>=3.8'], + ] as const; + const metadataByScript = new Map( + scriptSpecs.map(([name, requiresPython]) => [ + normalizePath(scriptUri(name).fsPath), + { + ...VALID_METADATA, + requiresPython, + }, + ]), + ); + let expectedHashes: readonly string[] | undefined; + for (const [, requiresPython] of scriptSpecs) { + expectedHashes = cacheLayout.mergeSourceMetadataIdentityHashes( + expectedHashes, + cacheLayout.hashSourceMetadataIdentity( + JSON.stringify({ + requiresPython, + dependencies: ['requests'], + }), + ), + ); + } + readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); + registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); + + let continueCreation: (() => void) | undefined; + let creationStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + creationStarted = resolve; + }); + const gate = new Promise((resolve) => { + continueCreation = resolve; + }); + createWithProgressStub.callsFake(async (...args: unknown[]) => { + const target = args[6] as string; + await fs.outputFile(venvPythonPath(target), ''); + const environment = makeEnvironment( + 'ms-python.python:inline-script', + '3.12.4', + venvPythonPath(target), + target, + ); + environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); + creationStarted!(); + await gate; + return { environment }; + }); + + const pendingCreates = [manager.create(scriptUri(scriptSpecs[0][0]), { additionalPackages: ['pytest'] })]; + await started; + const pendingCreations = ( + manager as unknown as { + pendingCreations: Map; + } + ).pendingCreations; + const addPendingCreationSourceMetadataIdentityHashStub = sinon + .stub( + manager as unknown as { + addPendingCreationSourceMetadataIdentityHash( + pendingCreation: { sourceMetadataIdentityHashes?: readonly string[] }, + sourceMetadataIdentityHash: string | undefined, + ): void; + }, + 'addPendingCreationSourceMetadataIdentityHash', + ) + .callThrough(); + for (const [name, requiresPython] of scriptSpecs.slice(1)) { + const hash = cacheLayout.hashSourceMetadataIdentity( + JSON.stringify({ + requiresPython, + dependencies: ['requests'], + }), + ); + pendingCreates.push(manager.create(scriptUri(name), { additionalPackages: ['pytest'] })); + await waitForStubCallCount(addPendingCreationSourceMetadataIdentityHashStub, pendingCreates.length - 1); + assert.strictEqual( + pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes?.includes(hash), + true, + ); + } + assert.deepStrictEqual( + [...(pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes ?? [])].sort(), + [...(expectedHashes ?? [])].sort(), + ); + continueCreation!(); + const environments = await Promise.all(pendingCreates); + + assert.ok(environments[0]); + assert.ok(environments.every((environment) => environment === environments[0])); + assert.strictEqual(lockStub.callCount, 1); + const sourceMetadataIdentityHashes = ( + sidecarsByEnvDir.get( + normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), + ) as cacheLayout.InlineScriptEnvMeta + ).sourceMetadataIdentityHashes; + assert.deepStrictEqual([...(sourceMetadataIdentityHashes ?? [])].sort(), [...(expectedHashes ?? [])].sort()); + assert.strictEqual(sourceMetadataIdentityHashes?.length, cacheLayout.MAX_SOURCE_METADATA_IDENTITY_HASHES); + assert.strictEqual(sourceMetadataIdentityHashes ? new Set(sourceMetadataIdentityHashes).size : 0, sourceMetadataIdentityHashes?.length); + }); + 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); @@ -1060,17 +1744,24 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(resolveVenvStub.firstCall.args[0], venvPythonPath(envDir().fsPath)); assert.deepStrictEqual(writeMetaStub.firstCall.args, [ envDir(), - { ...sidecar, lastUsedAt: NOW.toISOString() }, + { + ...sidecar, + lastUsedAt: NOW.toISOString(), + sourceMetadataIdentityHashes: [ + cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), + ], + }, ]); }); - test('returns a valid hit even when the last-used timestamp cannot be updated', async () => { + test('merges the current metadata identity hash into a reused cache sidecar', async () => { await fs.ensureDir(envDir().fsPath); setSidecar({ schemaVersion: cacheLayout.META_SCHEMA_VERSION, baseInterpreterPath: baseExecutable, baseInterpreterVersion: baseEnvironment.version, lastUsedAt: '2026-07-01T00:00:00.000Z', + sourceMetadataIdentityHashes: [cacheLayout.hashSourceMetadataIdentity('{"requiresPython":">=3.12","dependencies":["rich"]}')], }); const cached = makeEnvironment( 'ms-python.python:inline-script', @@ -1080,52 +1771,125 @@ suite('InlineScriptEnvManager', () => { ); 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); - }); + await manager.create(scriptUri()); - 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({ + assert.deepStrictEqual(writeMetaStub.firstCall.args[1], { schemaVersion: cacheLayout.META_SCHEMA_VERSION, baseInterpreterPath: baseExecutable, baseInterpreterVersion: baseEnvironment.version, lastUsedAt: NOW.toISOString(), + sourceMetadataIdentityHashes: [ + cacheLayout.hashSourceMetadataIdentity('{"requiresPython":">=3.12","dependencies":["rich"]}'), + cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), + ], }); - 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 () => { + test('dedupes and caps reused cache provenance hashes', async () => { await fs.ensureDir(envDir().fsPath); + const currentHash = cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY); + const hashes = [ + currentHash, + ...Array.from({ length: cacheLayout.MAX_SOURCE_METADATA_IDENTITY_HASHES - 1 }, (_, index) => + cacheLayout.hashSourceMetadataIdentity(`identity-${index}`), + ), + ]; setSidecar({ schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: path.join(tempRoot, 'different-python'), + baseInterpreterPath: baseExecutable, baseInterpreterVersion: baseEnvironment.version, - lastUsedAt: NOW.toISOString(), + lastUsedAt: '2026-07-01T00:00:00.000Z', + sourceMetadataIdentityHashes: hashes, }); + 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); - const result = await manager.create(scriptUri()); + await manager.create(scriptUri()); - assert.ok(result); - assert.strictEqual(resolveVenvStub.callCount, 0); - assert.strictEqual(createWithProgressStub.callCount, 1); + assert.strictEqual((writeMetaStub.firstCall.args[1] as cacheLayout.InlineScriptEnvMeta).sourceMetadataIdentityHashes?.length, cacheLayout.MAX_SOURCE_METADATA_IDENTITY_HASHES); }); - test('rebuilds when the base version changed at the same canonical path', async () => { + test('preserves a cache entry with a future sidecar schema version', async () => { await fs.ensureDir(envDir().fsPath); - setSidecar({ - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: baseExecutable, + await fs.outputFile(venvPythonPath(envDir().fsPath), ''); + const markerPath = path.join(envDir().fsPath, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + sidecarsByEnvDir.set(normalizePath(envDir().fsPath), 'unavailable'); + inspectMetaStub.callsFake(async () => ({ kind: 'unsupported' } as cacheLayout.InlineScriptMetaReadResult)); + + assert.strictEqual(await manager.create(scriptUri()), undefined); + assert.strictEqual(await fs.pathExists(markerPath), true); + }); + + 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('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(), }); @@ -1472,97 +2236,1209 @@ suite('InlineScriptEnvManager', () => { 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); - }); + 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', () => { + manager.dispose(); + assert.doesNotThrow(() => manager.dispose()); + }); + }); + + suite('script association persistence', () => { + test('sets, gets, unsets, persists, and reports only actual selection changes', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set(uri, environment); + + assert.strictEqual(await manager.get(uri), environment); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(workspaceState.set.firstCall.args[0], INLINE_SCRIPT_ENVS_KEY); + assert.strictEqual(listener.callCount, 1); + assert.deepStrictEqual(listener.firstCall.args[0], { uri, old: undefined, new: environment }); + + await manager.set(uri, environment); + assert.strictEqual(listener.callCount, 1); + + await manager.set(uri, undefined); + assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(listener.callCount, 2); + assert.deepStrictEqual(listener.secondCall.args[0], { uri, old: environment, new: undefined }); + }); + + test('updates validated routing state when selections are set and unset', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + routingRegistry.setMetadata(uri, VALID_METADATA); + + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + + await manager.set(uri, environment); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); + + await manager.set(uri, undefined); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + }); + + test('persists the saved metadata identity separately from the environment path', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + + await manager.set(uri, environment); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }); + }); + + test('routes an environment created with additional packages by saved metadata identity', async () => { + const uri = scriptUri(); + routingRegistry.setMetadata(uri, VALID_METADATA); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + + await manager.set(uri, environment!); + + assert.strictEqual(await manager.get(uri), environment); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); + }); + + test('reselecting the same matched additional-packages environment after restart preserves matched provenance', async () => { + const uri = scriptUri(); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + await manager.set(uri, environment!); + + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + + await restarted.set(uri, environment!); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), + }); + restarted.dispose(); + }); + + test('create with additional packages can route after reload before the first set via sidecar provenance', async () => { + const uri = scriptUri(); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + persistedAssociations = {}; + + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + + await restarted.set(uri, environment!); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), + }); + restarted.dispose(); + }); + + test('does not reuse matched provenance after the same cache path is rebuilt for a different generation', async () => { + const uri = scriptUri(); + const cacheKeyValue = 'fedcba9876543210'; + routingRegistry.setMetadata(uri, VALID_METADATA); + const environment = await createOwnedEnvironment(cacheKeyValue); + setSidecar( + { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: path.join( + tempRoot, + `base-python-${cacheKeyValue}`, + isWindows() ? 'python.exe' : 'python', + ), + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + sourceMetadataIdentityHashes: [ + cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), + ], + }, + Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), + ); + + await manager.set(uri, environment); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), + }); + + const rebuiltMetadata = { + ...VALID_METADATA, + requiresPython: '>=3.12', + } satisfies metadataReader.InlineScriptMetadata; + const rebuiltBaseExecutable = path.join(tempRoot, 'rebuilt-base', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(rebuiltBaseExecutable, ''); + setSidecar( + { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: rebuiltBaseExecutable, + baseInterpreterVersion: '3.12.9', + lastUsedAt: NOW.toISOString(), + sourceMetadataIdentityHashes: [ + cacheLayout.hashSourceMetadataIdentity( + JSON.stringify({ + requiresPython: rebuiltMetadata.requiresPython, + dependencies: rebuiltMetadata.dependencies, + }), + ), + ], + }, + Uri.file(path.dirname(path.dirname(environment!.environmentPath.fsPath))), + ); + + await manager.set(uri, environment); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: { + schemaVersion: 1, + environmentPath: environment!.environmentPath.fsPath, + metadataBinding: { kind: 'legacy' }, + }, + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + }); + + test('does not infer matched provenance when the sidecar source identity hash does not match', async () => { + const sourceUri = scriptUri('source.py'); + const targetUri = scriptUri('target.py'); + const sourceMetadata = { + ...VALID_METADATA, + dependencies: ['rich'], + } satisfies metadataReader.InlineScriptMetadata; + routingRegistry.setMetadata(targetUri, VALID_METADATA); + registerCacheKey('fedcba9876543210', ['rich', 'pytest'], baseExecutable); + readMetadataStub.resolves(sourceMetadata); + const environment = await manager.create(sourceUri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + readMetadataStub.resolves(VALID_METADATA); + + await manager.set(targetUri, environment!); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(targetUri.fsPath)]: { + schemaVersion: 1, + environmentPath: environment!.environmentPath.fsPath, + metadataBinding: { kind: 'legacy' }, + }, + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(targetUri), false); + }); + + test('reselecting a different owned env after restart does not inherit matched provenance', async () => { + const uri = scriptUri(); + const otherUri = scriptUri('other.py'); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const matchedEnvironment = await manager.create(uri, { additionalPackages: ['pytest'] }); + const otherMetadata = { + ...VALID_METADATA, + dependencies: ['urllib3'], + } satisfies metadataReader.InlineScriptMetadata; + registerCacheKey('0011223344556677', ['urllib3', 'pytest', 'rich'], baseExecutable); + readMetadataStub.resolves(otherMetadata); + const differentOwnedEnvironment = await manager.create(otherUri, { additionalPackages: ['pytest', 'rich'] }); + readMetadataStub.resolves(VALID_METADATA); + assert.ok(matchedEnvironment); + assert.ok(differentOwnedEnvironment); + await manager.set(uri, matchedEnvironment!); + + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + + await restarted.set(uri, differentOwnedEnvironment!); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: { + schemaVersion: 1, + environmentPath: differentOwnedEnvironment!.environmentPath.fsPath, + metadataBinding: { kind: 'legacy' }, + }, + }); + restarted.dispose(); + }); + + test('old sidecars without provenance keep additional-packages envs conservative on reload', async () => { + const uri = scriptUri(); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + const envDirPath = path.dirname(path.dirname(environment!.environmentPath.fsPath)); + setSidecar({ + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }, Uri.file(envDirPath)); + persistedAssociations = {}; + + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + + await restarted.set(uri, environment); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: { + schemaVersion: 1, + environmentPath: environment!.environmentPath.fsPath, + metadataBinding: { kind: 'legacy' }, + }, + }); + restarted.dispose(); + }); + + test('stores a pending verified binding for a dirty selection and promotes it on matching save', async () => { + const uri = scriptUri(); + const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; + openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + + await manager.set(uri, environment!); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + + openDocumentsStub.returns([]); + await triggerSavedMetadataChange(routingRegistry, manager, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); + }); + + test('dirty pending binding for the same path after restart keeps pending until saved metadata is consistent', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment('fedcba9876543210'); + persistedAssociations = { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }; + const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; + openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + + await restarted.set(uri, environment); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); + }); + + test('keeps a dirty pending binding non-routeable when the saved metadata identity no longer matches', async () => { + const uri = scriptUri(); + const changedMetadata = { + ...VALID_METADATA, + dependencies: ['urllib3'], + } satisfies metadataReader.InlineScriptMetadata; + const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; + openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + + await manager.set(uri, environment!); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), + }); + + openDocumentsStub.returns([]); + routingRegistry.setMetadata(uri, changedMetadata); + await triggerSavedMetadataChange(routingRegistry, manager, uri, changedMetadata); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + }); + + test('failed pending bind invalidates warm validation before a retry within 5s', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment('fedcba9876543210'); + persistedAssociations = { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + resolveVenvStub.resolves(environment); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + workspaceState.set.onFirstCall().rejects(new Error('Memento unavailable')); + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await fs.remove(environment.environmentPath.fsPath); + clock.tick(5_000 - 1); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); + }); + + test('removes a dirty pending binding when the environment was deleted before save validation', async () => { + const uri = scriptUri(); + const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; + openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + + await manager.set(uri, environment!); + await fs.remove(environment!.environmentPath.fsPath); + + openDocumentsStub.returns([]); + routingRegistry.setMetadata(uri, VALID_METADATA); + await triggerSavedMetadataChange(routingRegistry, manager, uri); + + assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + }); + + test('keeps a dirty pending binding non-routeable when validation is transiently unavailable on save', async () => { + const uri = scriptUri(); + const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; + openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + + await manager.set(uri, environment!); + resolveVenvStub.resolves(undefined); + openDocumentsStub.returns([]); + routingRegistry.setMetadata(uri, VALID_METADATA); + await triggerSavedMetadataChange(routingRegistry, manager, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + }); + + test('keeps a dirty pending binding non-routeable when ownership validation changes on save', async () => { + const uri = scriptUri(); + const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; + openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + + await manager.set(uri, environment!); + resolveVenvStub.resolves({ + ...environment!, + envId: { ...environment!.envId, managerId: 'ms-python.python:system' }, + }); + openDocumentsStub.returns([]); + routingRegistry.setMetadata(uri, VALID_METADATA); + await triggerSavedMetadataChange(routingRegistry, manager, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), + }); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + }); + + test('removes only the requested malformed entry while preserving valid and legacy records', async () => { + const invalidUri = scriptUri('invalid.py'); + const validUri = scriptUri('valid.py'); + const legacyUri = scriptUri('legacy.py'); + const validEnvironment = await createOwnedEnvironment('fedcba9876543210'); + const legacyEnvironment = await createOwnedEnvironment('0011223344556677'); + persistedAssociations = { + [normalizePath(invalidUri.fsPath)]: { schemaVersion: 1, environmentPath: '', metadataBinding: { kind: 'pending' } }, + [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), + [normalizePath(legacyUri.fsPath)]: legacyEnvironment.environmentPath.fsPath, + }; + resolveVenvStub.callsFake(async (environmentPath: string) => { + const normalized = normalizePath(environmentPath); + if (normalized === normalizePath(validEnvironment.environmentPath.fsPath)) { + return validEnvironment; + } + if (normalized === normalizePath(legacyEnvironment.environmentPath.fsPath)) { + return legacyEnvironment; + } + return undefined; + }); + + assert.strictEqual(await manager.get(invalidUri), undefined); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), + [normalizePath(legacyUri.fsPath)]: legacyEnvironment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(validUri), validEnvironment); + assert.strictEqual(await manager.get(legacyUri), legacyEnvironment); + }); + + test('preserves unknown future-version entries when repairing a malformed requested entry', async () => { + const invalidUri = scriptUri('invalid.py'); + const futureUri = scriptUri('future.py'); + const futureEnvironment = await createOwnedEnvironment('8899aabbccddeeff'); + persistedAssociations = { + [normalizePath(invalidUri.fsPath)]: { schemaVersion: 1, environmentPath: '', metadataBinding: { kind: 'pending' } }, + [normalizePath(futureUri.fsPath)]: futureAssociationRecord(futureEnvironment.environmentPath.fsPath), + }; + + assert.strictEqual(await manager.get(invalidUri), undefined); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(futureUri.fsPath)]: futureAssociationRecord(futureEnvironment.environmentPath.fsPath), + }); + assert.strictEqual(await manager.get(futureUri), undefined); + }); + + test('removes a requested record with an unknown current binding kind without affecting unrelated entries', async () => { + const invalidUri = scriptUri('invalid.py'); + const validUri = scriptUri('valid.py'); + const validEnvironment = await createOwnedEnvironment('fedcba9876543210'); + persistedAssociations = { + [normalizePath(invalidUri.fsPath)]: { + schemaVersion: 1, + environmentPath: validEnvironment.environmentPath.fsPath, + metadataBinding: { kind: 'mystery' }, + }, + [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), + }; + resolveVenvStub.resolves(validEnvironment); + + assert.strictEqual(await manager.get(invalidUri), undefined); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), + }); + assert.strictEqual(await manager.get(validUri), validEnvironment); + }); + + test('persists a batch atomically and reports each distinct script URI exactly once', async () => { + const first = scriptUri('first.py'); + const second = scriptUri('second.py'); + const environment = await createOwnedEnvironment(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + + await manager.set([first, second, first], environment); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(first.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(second.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(workspaceStateSetCalls(INLINE_SCRIPT_ENVS_KEY).length, 1); + assert.strictEqual(listener.callCount, 2); + assert.strictEqual(listener.firstCall.args[0].uri, first); + assert.strictEqual(listener.secondCall.args[0].uri, second); + assert.strictEqual(await manager.get(first), environment); + assert.strictEqual(await manager.get(second), environment); + }); + + test('serializes concurrent selections so neither persisted association is lost', async () => { + const firstUri = scriptUri('first.py'); + const secondUri = scriptUri('second.py'); + const firstEnvironment = await createOwnedEnvironment(); + const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); + + await Promise.all([ + manager.set(firstUri, firstEnvironment), + manager.set(secondUri, secondEnvironment), + ]); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(firstUri.fsPath)]: matchedAssociationRecord(firstEnvironment.environmentPath.fsPath), + [normalizePath(secondUri.fsPath)]: matchedAssociationRecord(secondEnvironment.environmentPath.fsPath), + }); + assert.strictEqual(await manager.get(firstUri), firstEnvironment); + assert.strictEqual(await manager.get(secondUri), secondEnvironment); + }); + + test('does not let pending binding overwrite a newer unset', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + let resolvePending: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolvePending = resolve; + }), + ); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + const pendingBind = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await waitForStubCall(resolveVenvStub); + await restarted.set(uri, undefined); + resolvePending!(environment); + await pendingBind; + + assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); + }); + + test('does not let pending binding overwrite a newer matched selection', async () => { + const uri = scriptUri(); + const oldEnvironment = await createOwnedEnvironment(); + const newEnvironment = await createOwnedEnvironment('fedcba9876543210'); + persistedAssociations = { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(oldEnvironment.environmentPath.fsPath), + }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + let resolvePending: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolvePending = resolve; + }), + ); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + const pendingBind = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await waitForStubCall(resolveVenvStub); + await restarted.set(uri, newEnvironment); + resolvePending!(oldEnvironment); + await pendingBind; + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(newEnvironment.environmentPath.fsPath), + }); + assert.strictEqual(await restarted.get(uri), newEnvironment); + restarted.dispose(); + }); + + test('preserves a concurrent valid set while repairing an unrelated malformed entry', async () => { + const invalidUri = scriptUri('invalid.py'); + const validUri = scriptUri('valid.py'); + const validEnvironment = await createOwnedEnvironment('fedcba9876543210'); + persistedAssociations = { + [normalizePath(invalidUri.fsPath)]: { schemaVersion: 1, environmentPath: '', metadataBinding: { kind: 'pending' } }, + }; + + await Promise.all([manager.get(invalidUri), manager.set(validUri, validEnvironment)]); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), + }); + assert.strictEqual(await manager.get(validUri), validEnvironment); + }); + + test('leaves a pending binding non-routeable when persistence fails', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + resolveVenvStub.resolves(environment); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + ((restarted as unknown as { subscriptions: Disposable[] }).subscriptions[0]).dispose(); + workspaceState.set.onFirstCall().rejects(new Error('Memento unavailable')); + workspaceState.set.onSecondCall().rejects(new Error('Memento unavailable')); + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); + }); + + test('does not publish routeability from raw persisted associations after startup', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); + }); + + test('legacy string associations stay non-routeable after restart but remain retrievable', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + assert.strictEqual(await restarted.get(uri), environment); + restarted.dispose(); + }); + + test('routes a persisted matched additional-packages association on restart when the current sidecar hash matches', async () => { + const uri = scriptUri(); + routingRegistry.setMetadata(uri, VALID_METADATA); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); + restarted.dispose(); + }); + + test('does not route a persisted matched association on restart when the same cache path was rebuilt for another identity', async () => { + const uri = scriptUri(); + const cacheKeyValue = 'fedcba9876543210'; + const environment = await createOwnedEnvironment(cacheKeyValue); + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }; + const rebuiltMetadata = { + ...VALID_METADATA, + requiresPython: '>=3.12', + } satisfies metadataReader.InlineScriptMetadata; + const rebuiltBaseExecutable = path.join(tempRoot, 'rebuilt-base-restart', isWindows() ? 'python.exe' : 'python'); + await fs.outputFile(rebuiltBaseExecutable, ''); + setSidecar( + { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: rebuiltBaseExecutable, + baseInterpreterVersion: '3.12.9', + lastUsedAt: NOW.toISOString(), + sourceMetadataIdentityHashes: [ + cacheLayout.hashSourceMetadataIdentity( + JSON.stringify({ + requiresPython: rebuiltMetadata.requiresPython, + dependencies: rebuiltMetadata.dependencies, + }), + ), + ], + }, + Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), + ); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + assert.strictEqual(await restarted.get(uri), environment); + restarted.dispose(); + }); + + test('does not promote a pending association when the current sidecar hash does not prove its source identity', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment('fedcba9876543210'); + persistedAssociations = { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }; + setSidecar( + { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: path.join( + tempRoot, + 'base-python-fedcba9876543210', + isWindows() ? 'python.exe' : 'python', + ), + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + sourceMetadataIdentityHashes: [ + cacheLayout.hashSourceMetadataIdentity('{"requiresPython":">=3.12","dependencies":["requests"]}'), + ], + }, + Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), + ); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); + }); + + test('preserves a persisted matched association with a future sidecar but leaves it non-routeable', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + const markerPath = path.join(environment.sysPrefix, 'keep.txt'); + await fs.outputFile(markerPath, 'keep'); + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }; + inspectMetaStub.callsFake(async (envDir: Uri) => + normalizePath(envDir.fsPath) === normalizePath(environment.sysPrefix) + ? ({ kind: 'unsupported' } as cacheLayout.InlineScriptMetaReadResult) + : ({ kind: 'missing' } as cacheLayout.InlineScriptMetaReadResult), + ); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + assert.strictEqual(await restarted.get(uri), environment); + assert.strictEqual(await fs.pathExists(markerPath), true); + restarted.dispose(); + }); + + test('keeps a persisted matched additional-packages association non-routeable on restart when only an old sidecar remains', async () => { + const uri = scriptUri(); + routingRegistry.setMetadata(uri, VALID_METADATA); + registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); + const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); + assert.ok(environment); + setSidecar( + { + schemaVersion: cacheLayout.META_SCHEMA_VERSION, + baseInterpreterPath: baseExecutable, + baseInterpreterVersion: baseEnvironment.version, + lastUsedAt: NOW.toISOString(), + }, + Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), + ); + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); + }); + + test('enables routeability only after persisted validation succeeds on restart', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + resolveVenvStub.resolves(environment); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + const pending = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await waitForStubCall(resolveVenvStub); + await pending; + + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); + restarted.dispose(); + }); + + test('keeps routeability disabled while persisted restart validation is still in flight', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + let resolveRehydration: ((value: PythonEnvironment) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolveRehydration = resolve; + }), + ); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + const pending = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await waitForStubCall(resolveVenvStub); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + resolveRehydration!(environment); + await pending; - test('dispose is idempotent', () => { - manager.dispose(); - assert.doesNotThrow(() => manager.dispose()); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); + restarted.dispose(); }); - }); - suite('script association persistence', () => { - test('sets, gets, unsets, persists, and reports only actual selection changes', async () => { + test('ignores a stale saved-metadata refresh when metadata changes while sidecar proof awaits', async () => { const uri = scriptUri(); + const scriptPath = normalizePath(uri.fsPath); const environment = await createOwnedEnvironment(); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - await manager.set(uri, environment); + const refreshManager = asMetadataRefreshManager(manager); + refreshManager.subscriptions[0].dispose(); + const validatedAtBefore = refreshManager.cachedAssociationValidatedAt.get(scriptPath); + assert.ok(validatedAtBefore !== undefined); + const routeabilityListener = sinon.spy(); + routingRegistry.onDidChangeRouteability(routeabilityListener); + clock.tick(1); + routingRegistry.setMetadata(uri, VALID_METADATA); + const metadataIdentity = routingRegistry.getMetadataIdentity(uri)!; + const metadataRevision = routingRegistry.getMetadataRevision(uri); + let resolveProof: ((value: boolean) => void) | undefined; + const proofStub = sinon.stub(refreshManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); + proofStub.onFirstCall().returns( + new Promise((resolve) => { + resolveProof = resolve; + }), + ); - assert.strictEqual(await manager.get(uri), environment); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + const pendingRefresh = refreshManager.refreshValidatedAssociationForMetadataInternal( + scriptPath, + uri, + VALID_METADATA, + metadataIdentity, + metadataRevision, + refreshManager.associationRevisions.get(scriptPath) ?? 0, + ); + await waitForStubCall(proofStub); + routingRegistry.setMetadata(uri, { + ...VALID_METADATA, + requiresPython: '>=3.12', }); - assert.strictEqual(workspaceState.set.firstCall.args[0], INLINE_SCRIPT_ENVS_KEY); - assert.strictEqual(listener.callCount, 1); - assert.deepStrictEqual(listener.firstCall.args[0], { uri, old: undefined, new: environment }); + resolveProof!(true); + await pendingRefresh; + + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + assert.strictEqual(routeabilityListener.callCount, 0); + assert.strictEqual(refreshManager.cachedAssociationValidatedAt.get(scriptPath), validatedAtBefore); + assert.strictEqual(refreshManager.lastValidatedMetadataIdentities.get(scriptPath), VALID_METADATA_IDENTITY); + assert.strictEqual(refreshManager.lastValidatedMetadataIdentityProofs.has(scriptPath), false); + }); + test('ignores a stale saved-metadata refresh when an unset wins while sidecar proof awaits', async () => { + const uri = scriptUri(); + const scriptPath = normalizePath(uri.fsPath); + const environment = await createOwnedEnvironment(); + const refreshManager = asMetadataRefreshManager(manager); + refreshManager.subscriptions[0].dispose(); + routingRegistry.setMetadata(uri, VALID_METADATA); await manager.set(uri, environment); - assert.strictEqual(listener.callCount, 1); + const routeabilityListener = sinon.spy(); + routingRegistry.onDidChangeRouteability(routeabilityListener); + clock.tick(1); + let resolveProof: ((value: boolean) => void) | undefined; + const proofStub = sinon.stub(refreshManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); + proofStub.onFirstCall().returns( + new Promise((resolve) => { + resolveProof = resolve; + }), + ); + const pendingRefresh = refreshManager.refreshValidatedAssociationForMetadataInternal( + scriptPath, + uri, + VALID_METADATA, + routingRegistry.getMetadataIdentity(uri)!, + routingRegistry.getMetadataRevision(uri), + refreshManager.associationRevisions.get(scriptPath) ?? 0, + ); + await waitForStubCall(proofStub); await manager.set(uri, undefined); + resolveProof!(true); + await pendingRefresh; + + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + sinon.assert.calledOnceWithExactly(routeabilityListener, { + uri, + previousRouteable: true, + routeable: false, + }); + assert.strictEqual(refreshManager.cachedAssociationValidatedAt.has(scriptPath), false); + assert.strictEqual(refreshManager.lastValidatedMetadataIdentities.has(scriptPath), false); + assert.strictEqual(refreshManager.lastValidatedMetadataIdentityProofs.has(scriptPath), false); assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(listener.callCount, 2); - assert.deepStrictEqual(listener.secondCall.args[0], { uri, old: environment, new: undefined }); }); - test('persists a batch atomically and reports each distinct script URI exactly once', async () => { - const first = scriptUri('first.py'); - const second = scriptUri('second.py'); - const environment = await createOwnedEnvironment(); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - - await manager.set([first, second, first], environment); + test('ignores a stale saved-metadata refresh when a replacement wins while sidecar proof awaits', async () => { + const uri = scriptUri(); + const scriptPath = normalizePath(uri.fsPath); + const oldEnvironment = await createOwnedEnvironment(); + const replacementEnvironment = await createOwnedEnvironment('fedcba9876543210'); + const refreshManager = asMetadataRefreshManager(manager); + refreshManager.subscriptions[0].dispose(); + routingRegistry.setMetadata(uri, VALID_METADATA); + await manager.set(uri, oldEnvironment); + const routeabilityListener = sinon.spy(); + routingRegistry.onDidChangeRouteability(routeabilityListener); + clock.tick(1); + let resolveProof: ((value: boolean) => void) | undefined; + const proofStub = sinon.stub(refreshManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); + proofStub.onFirstCall().returns( + new Promise((resolve) => { + resolveProof = resolve; + }), + ); + const pendingRefresh = refreshManager.refreshValidatedAssociationForMetadataInternal( + scriptPath, + uri, + VALID_METADATA, + routingRegistry.getMetadataIdentity(uri)!, + routingRegistry.getMetadataRevision(uri), + refreshManager.associationRevisions.get(scriptPath) ?? 0, + ); + await waitForStubCall(proofStub); + await manager.set(uri, replacementEnvironment); + const validatedAtAfterReplacement = refreshManager.cachedAssociationValidatedAt.get(scriptPath); + resolveProof!(false); + await pendingRefresh; + + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); + assert.strictEqual(routeabilityListener.callCount, 0); + assert.strictEqual( + refreshManager.cachedAssociationValidatedAt.get(scriptPath), + validatedAtAfterReplacement, + ); + assert.strictEqual(refreshManager.lastValidatedMetadataIdentities.get(scriptPath), VALID_METADATA_IDENTITY); + assert.strictEqual(refreshManager.lastValidatedMetadataIdentityProofs.has(scriptPath), false); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(first.fsPath)]: environment.environmentPath.fsPath, - [normalizePath(second.fsPath)]: environment.environmentPath.fsPath, + [scriptPath]: matchedAssociationRecord(replacementEnvironment.environmentPath.fsPath), }); - assert.strictEqual(workspaceState.set.callCount, 1); - assert.strictEqual(listener.callCount, 2); - assert.strictEqual(listener.firstCall.args[0].uri, first); - assert.strictEqual(listener.secondCall.args[0].uri, second); - assert.strictEqual(await manager.get(first), environment); - assert.strictEqual(await manager.get(second), environment); }); - test('serializes concurrent selections so neither persisted association is lost', async () => { - const firstUri = scriptUri('first.py'); - const secondUri = scriptUri('second.py'); - const firstEnvironment = await createOwnedEnvironment(); - const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); + test('preserves a persisted restart candidate after transient validation failure and retries later', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + resolveVenvStub.onFirstCall().rejects(new Error('resolver unavailable')); + resolveVenvStub.onSecondCall().resolves(environment); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - await Promise.all([ - manager.set(firstUri, firstEnvironment), - manager.set(secondUri, secondEnvironment), - ]); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await waitForStubCall(resolveVenvStub); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(firstUri.fsPath)]: firstEnvironment.environmentPath.fsPath, - [normalizePath(secondUri.fsPath)]: secondEnvironment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); - assert.strictEqual(await manager.get(firstUri), firstEnvironment); - assert.strictEqual(await manager.get(secondUri), secondEnvironment); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); + restarted.dispose(); + }); + + test('clears a stale persisted restart candidate instead of routing it', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + await fs.remove(environment.environmentPath.fsPath); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + + assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + restarted.dispose(); }); test('rehydrates a persisted owned association on demand after restart', async () => { const uri = scriptUri(); const persistedEnvironment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: persistedEnvironment.environmentPath.fsPath }; + persistedAssociations = { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(persistedEnvironment.environmentPath.fsPath), + }; const rehydrated = { ...persistedEnvironment, envId: { ...persistedEnvironment.envId, id: 'rehydrated' } }; resolveVenvStub.resolves(rehydrated); - const restarted = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); assert.strictEqual(await restarted.get(uri), rehydrated); assert.strictEqual(resolveVenvStub.callCount, 1); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: persistedEnvironment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(persistedEnvironment.environmentPath.fsPath), }); const listener = sinon.spy(); @@ -1576,13 +3452,13 @@ suite('InlineScriptEnvManager', () => { test('preserves and retries a cold association when resolution rejects', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; resolveVenvStub.onFirstCall().rejects(new Error('resolver unavailable')); resolveVenvStub.onSecondCall().resolves(environment); assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); assert.strictEqual(await manager.get(uri), environment); }); @@ -1590,7 +3466,7 @@ suite('InlineScriptEnvManager', () => { test('preserves and retries a cold association when ownership inspection rejects', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; resolveVenvStub.resolves(environment); const inspectionManager = manager as unknown as { inspectAssociationOwnership( @@ -1602,7 +3478,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); assert.strictEqual(await manager.get(uri), environment); }); @@ -1610,7 +3486,7 @@ suite('InlineScriptEnvManager', () => { test('notifies when a slow persisted association finishes rehydrating', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; let resolveRehydration: ((value: PythonEnvironment) => void) | undefined; resolveVenvStub.callsFake( () => @@ -1630,18 +3506,63 @@ suite('InlineScriptEnvManager', () => { sinon.assert.calledOnceWithExactly(listener, { uri, old: undefined, new: environment }); }); + test('coalesces repeated saved-metadata validation for the same identity', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + let resolveRehydration: ((value: PythonEnvironment | undefined) => void) | undefined; + resolveVenvStub.callsFake( + () => + new Promise((resolve) => { + resolveRehydration = resolve; + }), + ); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + const listener = sinon.spy(); + restarted.onDidChangeEnvironment(listener); + await nextTurn(); + + const first = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await waitForStubCall(resolveVenvStub); + const second = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + assert.strictEqual(resolveVenvStub.callCount, 1); + + resolveRehydration!(environment); + await Promise.all([first, second]); + + assert.strictEqual(listener.callCount, 1); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); + restarted.dispose(); + }); + test('does not rewrite or notify when a restart reselects the same persisted executable', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; - const restarted = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); const listener = sinon.spy(); restarted.onDidChangeEnvironment(listener); await restarted.set(uri, environment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); assert.strictEqual(workspaceState.set.callCount, 0); assert.strictEqual(listener.callCount, 0); @@ -1658,23 +3579,44 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + }); + + readMetadataStub.resolves(VALID_METADATA); + assert.strictEqual(await manager.get(uri), environment); + }); + + test('does not return a retained association when current metadata dependencies changed', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + computeCacheKeyStub + .withArgs( + sinon.match((inputs: cacheKey.CacheKeyInputs) => inputs.dependencies.length === 1 && inputs.dependencies[0] === 'urllib3'), + ) + .returns('different-cache-key'); + readMetadataStub.resolves({ ...VALID_METADATA, dependencies: ['urllib3'] }); + + assert.strictEqual(await manager.get(uri), undefined); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); readMetadataStub.resolves(VALID_METADATA); assert.strictEqual(await manager.get(uri), environment); }); - test('uses full PEP 440 semantics when validating a retained association', async () => { + test('does not return a retained association when current requires-python identity changed, even if compatible', async () => { const uri = scriptUri(); const environment = { ...(await createOwnedEnvironment()), version: '3.15.0', }; await manager.set(uri, environment); + resolveVenvStub.resolves(environment); readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '!=3.15.0rc2' }); - assert.strictEqual(await manager.get(uri), environment); + assert.strictEqual(await manager.get(uri), undefined); }); test('does not resolve or discard an association when metadata is absent or unreadable', async () => { @@ -1705,6 +3647,62 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(resolveVenvStub.callCount, 0); }); + test('clears the routing registry when a stale persisted association is removed', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + resolveVenvStub.resolves(environment); + const restartRoutingRegistry = new InlineScriptRoutingRegistry(); + + const restarted = new InlineScriptEnvManager( + nativeFinder, + api, + baseManager, + globalStorageUri, + makeFakeLog(), + restartRoutingRegistry, + ); + await nextTurn(); + await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); + + await fs.remove(environment.environmentPath.fsPath); + clock.tick(5_000); + assert.strictEqual(await restarted.get(uri), undefined); + assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); + + restarted.dispose(); + }); + + test('clears persisted association state when the script path is deleted', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + + fireDelete(uri); + await nextTurn(); + await nextTurn(); + + assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); + }); + + test('clears persisted association state for the old path when a script is renamed', async () => { + const oldUri = scriptUri('old.py'); + const newUri = scriptUri('new.py'); + const environment = await createOwnedEnvironment(); + await manager.set(oldUri, environment); + + fireRename(oldUri, newUri); + await nextTurn(); + await nextTurn(); + + assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(await manager.get(oldUri), undefined); + assert.strictEqual(routingRegistry.hasValidatedAssociation(oldUri), false); + }); + test('removes and notifies for a warm association whose executable was deleted', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); @@ -1755,7 +3753,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); assert.strictEqual(listener.callCount, 0); }); @@ -1794,6 +3792,94 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(listener.callCount, 0); }); + test('refreshes warm validation timestamps when validation keeps the same environment', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + resolveVenvStub.resolves({ + ...environment, + envId: { ...environment.envId, id: 'new-generated-id' }, + }); + clock.tick(5_000); + + assert.strictEqual(await manager.get(uri), environment); + assert.strictEqual(resolveVenvStub.callCount, 1); + assert.strictEqual(await manager.get(uri), environment); + assert.strictEqual(resolveVenvStub.callCount, 1); + }); + + test('lets an unset win while warm validation awaits sidecar proof', async () => { + const uri = scriptUri(); + const environment = await createOwnedEnvironment(); + await manager.set(uri, environment); + const validationManager = manager as unknown as { + currentCacheEntryProvesSourceMetadataIdentity( + candidate: PythonEnvironment, + metadataIdentity: string, + metadata: metadataReader.InlineScriptMetadata, + ): Promise; + }; + let resolveProof: ((value: boolean) => void) | undefined; + const proofStub = sinon.stub(validationManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); + proofStub.onFirstCall().returns( + new Promise((resolve) => { + resolveProof = resolve; + }), + ); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + clock.tick(5_000); + + const pendingGet = manager.get(uri); + await waitForStubCall(proofStub); + await manager.set(uri, undefined); + resolveProof!(true); + + assert.strictEqual(await pendingGet, undefined); + assert.strictEqual(await manager.get(uri), undefined); + sinon.assert.calledOnceWithExactly(listener, { uri, old: environment, new: undefined }); + }); + + test('lets a replacement win while warm validation awaits sidecar proof', async () => { + const uri = scriptUri(); + const oldEnvironment = await createOwnedEnvironment(); + const replacementEnvironment = await createOwnedEnvironment('fedcba9876543210'); + await manager.set(uri, oldEnvironment); + const validationManager = manager as unknown as { + currentCacheEntryProvesSourceMetadataIdentity( + candidate: PythonEnvironment, + metadataIdentity: string, + metadata: metadataReader.InlineScriptMetadata, + ): Promise; + }; + let resolveProof: ((value: boolean) => void) | undefined; + const proofStub = sinon.stub(validationManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); + proofStub.onFirstCall().returns( + new Promise((resolve) => { + resolveProof = resolve; + }), + ); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + clock.tick(5_000); + + const pendingGet = manager.get(uri); + await waitForStubCall(proofStub); + await manager.set(uri, replacementEnvironment); + resolveProof!(true); + + assert.strictEqual(await pendingGet, replacementEnvironment); + assert.strictEqual(await manager.get(uri), replacementEnvironment); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: matchedAssociationRecord(replacementEnvironment.environmentPath.fsPath), + }); + sinon.assert.calledOnceWithExactly(listener, { + uri, + old: oldEnvironment, + new: replacementEnvironment, + }); + }); + test('coalesces concurrent validation of an expired warm association', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); @@ -1856,7 +3942,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await pendingGet, selectedEnvironment); assert.strictEqual(await manager.get(uri), selectedEnvironment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: selectedEnvironment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(selectedEnvironment.environmentPath.fsPath), }); assert.strictEqual(resolveVenvStub.callCount, 0); sinon.assert.calledOnceWithExactly(listener, { @@ -1918,14 +4004,22 @@ suite('InlineScriptEnvManager', () => { const environment = await createOwnedEnvironment(); const scriptPath = normalizePath(uri.fsPath); persistedAssociations = { [scriptPath]: 42 }; - workspaceState.get.onSecondCall().callsFake(async () => { - persistedAssociations = { [scriptPath]: environment.environmentPath.fsPath }; - return persistedAssociations; + let envKeyReads = 0; + workspaceState.get.callsFake(async (key: string) => { + if (key === INLINE_SCRIPT_ENVS_KEY) { + envKeyReads += 1; + if (envKeyReads === 1) { + return { [scriptPath]: 42 }; + } + persistedAssociations = { [scriptPath]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + return persistedAssociations; + } + return undefined; }); - assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(await manager.get(uri), environment); assert.deepStrictEqual(persistedAssociations, { - [scriptPath]: environment.environmentPath.fsPath, + [scriptPath]: matchedAssociationRecord(environment.environmentPath.fsPath), }); assert.strictEqual(workspaceState.set.callCount, 0); }); @@ -2008,7 +4102,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), first); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: first.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(first.environmentPath.fsPath), }); assert.strictEqual(listener.callCount, 1); }); @@ -2025,7 +4119,7 @@ suite('InlineScriptEnvManager', () => { await assert.rejects(manager.set(uri, undefined), /Memento unavailable/); assert.strictEqual(await manager.get(uri), environment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); assert.strictEqual(listener.callCount, 1); }); @@ -2107,9 +4201,9 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await pendingGet, environment); assert.strictEqual(await manager.get(uri), environment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), }); - assert.strictEqual(workspaceState.set.callCount, 0); + assert.strictEqual(workspaceStateSetCalls(INLINE_SCRIPT_ENVS_KEY).length, 1); }); test('retains a pending rehydration when a competing persistence write fails', async () => { diff --git a/src/test/managers/builtin/inlineScript/main.unit.test.ts b/src/test/managers/builtin/inlineScript/main.unit.test.ts index d109e318d..a090ff80b 100644 --- a/src/test/managers/builtin/inlineScript/main.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/main.unit.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { Disposable, LogOutputChannel, Uri } from 'vscode'; import { EnvironmentManager, PythonEnvironmentApi } from '../../../../api'; +import { InlineScriptRoutingRegistry } from '../../../../common/inlineScript/routingRegistry'; import * as pythonApi from '../../../../features/pythonApi'; import * as helpers from '../../../../helpers'; import { registerInlineScriptFeatures } from '../../../../managers/builtin/inlineScript/main'; @@ -34,6 +35,7 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { const nativeFinder = {} as NativePythonFinder; const baseManager = {} as EnvironmentManager; const globalStorageUri = Uri.file('inline-script-global-storage'); + const routingRegistry = new InlineScriptRoutingRegistry(); setup(() => { isEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled'); @@ -51,7 +53,14 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(false); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); + await registerInlineScriptFeatures( + nativeFinder, + disposables, + makeFakeLog(), + baseManager, + globalStorageUri, + routingRegistry, + ); 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'); @@ -62,7 +71,14 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(true); const disposables: Disposable[] = []; - await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); + await registerInlineScriptFeatures( + nativeFinder, + disposables, + makeFakeLog(), + baseManager, + globalStorageUri, + routingRegistry, + ); assert.strictEqual(getPythonApiStub.callCount, 1); assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); From d5d0efcade5fed03253d89f62a17822f530ccb5d Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Mon, 17 Aug 2026 12:17:50 -0700 Subject: [PATCH 2/2] Simplify inline script setup for preview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cb82ae9-7424-40a4-9156-8c54ac6e0895 --- package.json | 11 + package.nls.json | 1 + src/common/inlineScript/cacheLayout.ts | 76 +- src/common/inlineScript/routingRegistry.ts | 195 -- src/extension.ts | 13 +- src/features/envCommands.ts | 92 + src/features/envManagers.ts | 204 +- src/features/inlineScript/lazyDetector.ts | 58 +- .../builtin/inlineScript/envManager.ts | 1296 +-------- src/managers/builtin/inlineScript/main.ts | 4 +- .../inlineScript/cacheLayout.unit.test.ts | 70 +- src/test/features/envCommands.unit.test.ts | 407 ++- .../envManagers.lastKnown.unit.test.ts | 202 +- .../inlineScript/lazyDetector.unit.test.ts | 344 +-- src/test/features/pythonApi.unit.test.ts | 102 +- .../inlineScript/envManager.unit.test.ts | 2340 +---------------- .../builtin/inlineScript/main.unit.test.ts | 20 +- src/test/smoke/registration.smoke.test.ts | 1 + 18 files changed, 954 insertions(+), 4482 deletions(-) delete mode 100644 src/common/inlineScript/routingRegistry.ts diff --git a/package.json b/package.json index dd7cba3cf..8c1a7c346 100644 --- a/package.json +++ b/package.json @@ -190,6 +190,13 @@ "category": "Python", "icon": "$(add)" }, + { + "command": "python-envs.setupInlineScriptEnvironment", + "title": "%python-envs.setupInlineScriptEnvironment.title%", + "category": "Python", + "icon": "$(python)", + "when": "config.python.useEnvironmentsExtension != false" + }, { "command": "python-envs.set", "title": "%python-envs.set.title%", @@ -446,6 +453,10 @@ "command": "python-envs.createAny", "when": "false" }, + { + "command": "python-envs.setupInlineScriptEnvironment", + "when": "config.python.useEnvironmentsExtension != false" + }, { "command": "python-envs.revealProjectInExplorer", "when": "false" diff --git a/package.nls.json b/package.nls.json index 483ecfd29..2e9e9da20 100644 --- a/package.nls.json +++ b/package.nls.json @@ -26,6 +26,7 @@ "python-envs.copyProjectPathCopied.title": "Copied!", "python-envs.create.title": "Create Environment", "python-envs.createAny.title": "Create Environment", + "python-envs.setupInlineScriptEnvironment.title": "Set Up Environment for Inline Script", "python-envs.set.title": "Set Project Environment", "python-envs.setEnv.title": "Set As Project Environment", "python-envs.setEnvSelected.title": "Set!", diff --git a/src/common/inlineScript/cacheLayout.ts b/src/common/inlineScript/cacheLayout.ts index ddc593380..1371040b2 100644 --- a/src/common/inlineScript/cacheLayout.ts +++ b/src/common/inlineScript/cacheLayout.ts @@ -22,8 +22,6 @@ export const META_JSON_FILENAME = '.meta.json'; * Schema version embedded in every {@link InlineScriptEnvMeta}. */ export const META_SCHEMA_VERSION = 1 as const; -export const SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH = 64; -export const MAX_SOURCE_METADATA_IDENTITY_HASHES = 8; const MAX_META_JSON_BYTES = 1024 * 1024; @@ -40,13 +38,11 @@ export interface InlineScriptEnvMeta { readonly baseInterpreterVersion: string; /** Last successful use as a canonical UTC string produced by `Date.toISOString()`. */ readonly lastUsedAt: string; - /** Bounded SHA-256 hashes of metadata identities proven for this cache entry. */ - readonly sourceMetadataIdentityHashes?: readonly string[]; } export type InlineScriptMetaReadResult = | { readonly kind: 'valid'; readonly metadata: InlineScriptEnvMeta } - | { readonly kind: 'missing' | 'invalid' | 'unsupported' | 'unavailable' }; + | { readonly kind: 'missing' | 'invalid' | 'unavailable' }; export type BaseInterpreterStatus = 'available' | 'missing' | 'unavailable'; export type CacheEnvironmentInspection = 'expected' | 'stale' | 'uncertain'; @@ -164,10 +160,6 @@ export async function inspectMetaJson(envDir: Uri): Promise undefined); - await fsapi.move(tmpPath, finalPath, { overwrite: true }); - } + await fsapi.rename(tmpPath, finalPath); } catch (err) { await fsapi.remove(tmpPath).catch(() => undefined); throw err; } } -export function hashSourceMetadataIdentity(identity: string): string { - return crypto.createHash('sha256').update(identity, 'utf8').digest('hex'); -} - -export function mergeSourceMetadataIdentityHashes( - existing: readonly string[] | undefined, - current: string | undefined, -): readonly string[] | undefined { - const ordered = [...(existing ?? [])]; - if (current && !ordered.includes(current)) { - ordered.push(current); - } - if (ordered.length === 0) { - return undefined; - } - return Object.freeze(ordered.slice(-MAX_SOURCE_METADATA_IDENTITY_HASHES)); -} - /** * Pure selector: returns the env-dir paths whose age exceeds `ttlMs`. */ @@ -320,17 +285,11 @@ function isNonEmptyTrimmedString(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value.trim() === value; } -function validateMeta(value: unknown): InlineScriptEnvMeta | 'unsupported' | undefined { +function validateMeta(value: unknown): InlineScriptEnvMeta | undefined { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return undefined; } const obj = value as Record; - if (typeof obj.schemaVersion !== 'number') { - return undefined; - } - if (obj.schemaVersion > META_SCHEMA_VERSION) { - return 'unsupported'; - } if (obj.schemaVersion !== META_SCHEMA_VERSION) { return undefined; } @@ -343,44 +302,15 @@ function validateMeta(value: unknown): InlineScriptEnvMeta | 'unsupported' | und if (!isCanonicalIsoTimestamp(obj.lastUsedAt)) { return undefined; } - const sourceMetadataIdentityHashes = validateSourceMetadataIdentityHashes(obj.sourceMetadataIdentityHashes); - if (obj.sourceMetadataIdentityHashes !== undefined && sourceMetadataIdentityHashes === undefined) { - return undefined; - } return { schemaVersion: META_SCHEMA_VERSION, baseInterpreterPath: obj.baseInterpreterPath, baseInterpreterVersion: obj.baseInterpreterVersion, lastUsedAt: obj.lastUsedAt, - ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), }; } -function validateSourceMetadataIdentityHashes(value: unknown): readonly string[] | undefined { - if (value === undefined) { - return undefined; - } - if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SOURCE_METADATA_IDENTITY_HASHES) { - return undefined; - } - const hashes: string[] = []; - const seen = new Set(); - for (const item of value) { - if ( - typeof item !== 'string' || - item.length !== SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH || - !/^[0-9a-f]+$/.test(item) || - seen.has(item) - ) { - return undefined; - } - seen.add(item); - hashes.push(item); - } - return Object.freeze(hashes); -} - function isCanonicalIsoTimestamp(value: unknown): value is string { if (typeof value !== 'string') { return false; diff --git a/src/common/inlineScript/routingRegistry.ts b/src/common/inlineScript/routingRegistry.ts deleted file mode 100644 index 36d7b7046..000000000 --- a/src/common/inlineScript/routingRegistry.ts +++ /dev/null @@ -1,195 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -import * as path from 'path'; -import { Disposable, Event, EventEmitter, Uri } from 'vscode'; -import { normalizeDependency } from './cacheKey'; -import { InlineScriptMetadata } from './metadata'; -import { normalizePath } from '../utils/pathUtils'; - -export interface InlineScriptRouteabilityChangeEvent { - readonly uri: Uri; - readonly previousRouteable: boolean; - readonly routeable: boolean; -} - -export interface InlineScriptMetadataChangeEvent { - readonly uri: Uri; - readonly metadata: InlineScriptMetadata | undefined; - readonly metadataIdentity: string | undefined; - readonly metadataRevision: number; -} - -interface ScriptRoutingState { - readonly uri?: Uri; - readonly metadata?: InlineScriptMetadata; - readonly metadataIdentity?: string; - readonly metadataRevision: number; - readonly validatedAssociation: boolean; -} - -export class InlineScriptRoutingRegistry implements Disposable { - private readonly states = new Map(); - private readonly _onDidChangeRouteability = new EventEmitter(); - private readonly _onDidChangeMetadata = new EventEmitter(); - - public readonly onDidChangeRouteability: Event = - this._onDidChangeRouteability.event; - - public readonly onDidChangeMetadata: Event = this._onDidChangeMetadata.event; - - public setMetadata(uri: Uri, metadata: InlineScriptMetadata | undefined): void { - const scriptPath = getInlineScriptRoutingKey(uri); - if (!scriptPath) { - return; - } - const metadataIdentity = getInlineScriptMetadataRoutingIdentity(metadata); - this.update( - scriptPath, - (state) => { - const currentRevision = state?.metadataRevision ?? 0; - return { - ...state, - uri, - metadata, - metadataIdentity, - metadataRevision: currentRevision + 1, - }; - }, - true, - ); - } - - public clearMetadata(uri: Uri): void { - const scriptPath = getInlineScriptRoutingKey(uri); - if (!scriptPath) { - return; - } - this.update( - scriptPath, - (state) => { - const currentRevision = state?.metadataRevision ?? 0; - return { - ...state, - uri, - metadata: undefined, - metadataIdentity: undefined, - metadataRevision: currentRevision + 1, - }; - }, - true, - ); - } - - public getMetadata(script: Uri | string): InlineScriptMetadata | undefined { - const scriptPath = getInlineScriptRoutingKey(script); - return scriptPath ? this.states.get(scriptPath)?.metadata : undefined; - } - - public getMetadataIdentity(script: Uri | string): string | undefined { - const scriptPath = getInlineScriptRoutingKey(script); - return scriptPath ? this.states.get(scriptPath)?.metadataIdentity : undefined; - } - - public getMetadataRevision(script: Uri | string): number { - const scriptPath = getInlineScriptRoutingKey(script); - return scriptPath ? (this.states.get(scriptPath)?.metadataRevision ?? 0) : 0; - } - - public getUri(script: Uri | string): Uri | undefined { - const scriptPath = getInlineScriptRoutingKey(script); - return scriptPath ? this.states.get(scriptPath)?.uri : undefined; - } - - public setValidatedAssociation(script: Uri | string, validatedAssociation: boolean): void { - const scriptPath = getInlineScriptRoutingKey(script); - if (!scriptPath) { - return; - } - this.update(scriptPath, (state) => ({ - ...state, - uri: script instanceof Uri ? script : state.uri, - validatedAssociation, - })); - } - - public hasValidatedAssociation(script: Uri | string): boolean { - const scriptPath = getInlineScriptRoutingKey(script); - return scriptPath ? this.states.get(scriptPath)?.validatedAssociation === true : false; - } - - public shouldRoute(uri: Uri): boolean { - const scriptPath = getInlineScriptRoutingKey(uri); - return scriptPath ? this.isRouteable(this.states.get(scriptPath)) : false; - } - - public dispose(): void { - this.states.clear(); - this._onDidChangeMetadata.dispose(); - this._onDidChangeRouteability.dispose(); - } - - private update( - scriptPath: string, - updater: (state: ScriptRoutingState) => ScriptRoutingState, - fireMetadataChange: boolean = false, - ): void { - const previous = this.states.get(scriptPath) ?? { metadataRevision: 0, validatedAssociation: false }; - const previousRouteable = this.isRouteable(previous); - const next = updater(previous); - - if (!next.metadata && !next.validatedAssociation) { - this.states.delete(scriptPath); - } else { - this.states.set(scriptPath, next); - } - - if (fireMetadataChange && next.uri) { - this._onDidChangeMetadata.fire({ - uri: next.uri, - metadata: next.metadata, - metadataIdentity: next.metadataIdentity, - metadataRevision: next.metadataRevision, - }); - } - - const routeable = this.isRouteable(next); - if (previousRouteable !== routeable && next.uri) { - this._onDidChangeRouteability.fire({ - uri: next.uri, - previousRouteable, - routeable, - }); - } - } - - private isRouteable(state: ScriptRoutingState | undefined): boolean { - return !!state?.metadata && state.validatedAssociation; - } -} - -export function getInlineScriptRoutingKey(script: Uri | string): string | undefined { - if (typeof script === 'string') { - return normalizePath(script); - } - if (script.scheme !== 'file') { - return undefined; - } - if (path.extname(script.fsPath).toLowerCase() !== '.py') { - return undefined; - } - return normalizePath(script.fsPath); -} - -export function getInlineScriptMetadataRoutingIdentity(metadata: InlineScriptMetadata | undefined): string | undefined { - if (!metadata) { - return undefined; - } - const normalizedDependencies = Array.from( - new Set((metadata.dependencies ?? []).map((dependency) => normalizeDependency(dependency)).filter(Boolean)), - ).sort(); - return JSON.stringify({ - requiresPython: metadata.requiresPython?.trim() ?? '', - dependencies: normalizedDependencies, - }); -} diff --git a/src/extension.ts b/src/extension.ts index b929d3793..cdca1e7db 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -62,11 +62,11 @@ import { setEnvironmentCommand, setEnvManagerCommand, setPackageManagerCommand, + setupInlineScriptEnvironmentCommand, } from './features/envCommands'; import { PythonEnvironmentManagers } from './features/envManagers'; import { EnvVarManager, PythonEnvVariableManager } from './features/execution/envVariableManager'; import { InlineScriptLazyDetector } from './features/inlineScript/lazyDetector'; -import { InlineScriptRoutingRegistry } from './common/inlineScript/routingRegistry'; import { applyInitialEnvironmentSelection, registerInterpreterSettingsChangeListener, @@ -182,13 +182,10 @@ export async function activate(context: ExtensionContext): Promise { + return setupInlineScriptEnvironmentCommand(item, envManagers); + }), commands.registerCommand('python-envs.remove', async (item) => { await removeEnvironmentCommand(item, envManagers); }), @@ -661,7 +661,6 @@ export async function activate(context: ExtensionContext): Promise { + if (context !== undefined && !(context instanceof Uri)) { + showErrorMessage(l10n.t('Inline script environment setup requires a local .py file.')); + return undefined; + } + + const document = + context instanceof Uri + ? getOpenTextDocuments().find((openDocument) => hasSameIdentity(openDocument.uri, context)) + : activeTextEditor()?.document; + const uri = context instanceof Uri ? context : document?.uri; + if (!uri) { + showErrorMessage( + l10n.t('Open or select a saved local .py file with valid PEP 723 inline script metadata to continue.'), + ); + return undefined; + } + + if (!isLocalPythonFile(uri)) { + showErrorMessage(l10n.t('Inline script environment setup requires a local .py file.')); + return undefined; + } + + const inlineScriptsFeatureEnabled = isInlineScriptsFeatureEnabled(); + if (!inlineScriptsFeatureEnabled) { + showErrorMessage( + l10n.t( + 'Inline script environment setup is disabled. Add "python-envs.inlineScripts.enabled": true to your settings and reload the window to use this command.', + ), + ); + return undefined; + } + + if (document?.isDirty) { + showErrorMessage(l10n.t('Save the file before setting up an inline script environment.')); + return undefined; + } + + if ((await readInlineScriptMetadataFromFile(uri)) === undefined) { + showErrorMessage( + l10n.t( + 'Save a local .py file with valid PEP 723 inline script metadata before setting up an environment.', + ), + ); + return undefined; + } + + await waitForEnvManagerId([INLINE_SCRIPT_MANAGER_ID]); + const inlineManager = em.getEnvironmentManager(INLINE_SCRIPT_MANAGER_ID); + if (!inlineManager) { + showErrorMessage( + l10n.t( + 'Inline script environment setup is unavailable because the preview manager is not registered. Reload the window and try again.', + ), + ); + return undefined; + } + + const environment = await inlineManager.create(uri, undefined); + if (!environment) { + return undefined; + } + + await em.setEnvironment(uri, environment, false); + return environment; +} + export async function removeEnvironmentCommand(context: unknown, managers: EnvironmentManagers): Promise { if (context instanceof PythonEnvTreeItem) { const view = context as PythonEnvTreeItem; diff --git a/src/features/envManagers.ts b/src/features/envManagers.ts index 7277bc36b..9fa545d3b 100644 --- a/src/features/envManagers.ts +++ b/src/features/envManagers.ts @@ -15,10 +15,6 @@ import { EnvironmentManagerAlreadyRegisteredError, PackageManagerAlreadyRegisteredError, } from '../common/errors/AlreadyRegisteredError'; -import { - InlineScriptRouteabilityChangeEvent, - InlineScriptRoutingRegistry, -} from '../common/inlineScript/routingRegistry'; import { traceError, traceVerbose } from '../common/logging'; import { StopWatch } from '../common/stopWatch'; import { EventNames } from '../common/telemetry/constants'; @@ -57,7 +53,6 @@ function generateId(name: string, extensionId?: string): string { export class PythonEnvironmentManagers implements EnvironmentManagers { private _environmentManagers: Map = new Map(); private _packageManagers: Map = new Map(); - private readonly subscriptions: Disposable[] = []; /** * The last environment announced as "active" for each scope. @@ -69,7 +64,6 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { * Only mutated by setEnvironment() / setEnvironments() / refreshEnvironment(). */ private readonly _activeSelection = new Map(); - private readonly _inlineRoutingOverrides = new Map(); private readonly _selectionRevisions = new Map(); private readonly _selectionOperationCounters = new Map(); @@ -98,18 +92,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { public onDidChangeActiveEnvironment: Event = this._onDidChangeActiveEnvironment.event; - constructor( - private readonly pm: PythonProjectManager, - private readonly inlineScriptRouting: InlineScriptRoutingRegistry = new InlineScriptRoutingRegistry(), - ) { - this.subscriptions.push( - this.inlineScriptRouting.onDidChangeRouteability((e) => { - void this.handleInlineScriptRouteabilityChange(e).catch((error) => - traceError('Failed to refresh inline-script routing:', error), - ); - }), - ); - } + constructor(private readonly pm: PythonProjectManager) {} public registerEnvironmentManager(manager: EnvironmentManager, options?: { extensionId?: string }): Disposable { const registrationStopWatch = new StopWatch(); @@ -202,8 +185,6 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { public dispose() { this._environmentManagers.clear(); this._packageManagers.clear(); - this._inlineRoutingOverrides.clear(); - this.subscriptions.forEach((subscription) => subscription.dispose()); this._onDidChangeEnvironmentManager.dispose(); this._onDidChangePackageManager.dispose(); this._onDidChangeEnvironments.dispose(); @@ -217,11 +198,10 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { * * Priority: * 1. Use an exact per-script project setting. - * 2. Use an explicit in-session per-script override. - * 3. Use a recognized per-script inline association. - * 4. Use the containing project or default setting. - * 5. Fall back to the cached project/global environment's manager. - * 6. If context is a string or PythonEnvironment, return its manager directly. + * 2. Use a cached per-script inline selection. + * 3. Use the containing project or default setting. + * 4. Fall back to the cached project/global environment's manager. + * 5. If context is a string or PythonEnvironment, return its manager directly. */ public getEnvironmentManager(context: EnvironmentManagerScope): InternalEnvironmentManager | undefined { if (this._environmentManagers.size === 0) { @@ -231,23 +211,47 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { if (context === undefined || context instanceof Uri) { const project = context ? this.pm.get(context) : undefined; - const exactManager = - context instanceof Uri ? this.getExactProjectEnvironmentManager(context, project) : undefined; - if (exactManager) { - return exactManager; + if ( + context instanceof Uri && + project && + normalizePath(project.uri.fsPath) === normalizePath(context.fsPath) + ) { + const exactManagerId = getProjectEnvironmentManagerSetting(this.pm, context); + const exactManager = exactManagerId + ? this._environmentManagers.get(exactManagerId) + : undefined; + if (exactManager) { + return exactManager; + } } if (context instanceof Uri) { - const overrideManager = this.getInlineRoutingOverrideManager(context); - if (overrideManager) { - return overrideManager; + const inlineEnv = this._activeSelection.get(this.getInlineScriptSelectionKey(context)); + if (inlineEnv?.envId.managerId === INLINE_SCRIPT_MANAGER_ID) { + const inlineManager = this._environmentManagers.get(INLINE_SCRIPT_MANAGER_ID); + if (inlineManager) { + return inlineManager; + } } - const inlineManager = this._environmentManagers.get(INLINE_SCRIPT_MANAGER_ID); - if (inlineManager && this.inlineScriptRouting.shouldRoute(context)) { - return inlineManager; + } + + const defaultEnvManagerId = getDefaultEnvManagerSetting(this.pm, context); + if (defaultEnvManagerId !== undefined) { + const settingsManager = this._environmentManagers.get(defaultEnvManagerId); + if (settingsManager) { + return settingsManager; } } - return this.getConfiguredOrCachedEnvironmentManager(context, project); + + const cachedEnv = this._activeSelection.get(project ? project.uri.toString() : 'global'); + if (cachedEnv) { + const cachedManager = this._environmentManagers.get(cachedEnv.envId.managerId); + if (cachedManager) { + return cachedManager; + } + } + + return undefined; } if (typeof context === 'string') { @@ -360,8 +364,6 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { const project = scope ? this.pm.get(scope) : undefined; const key = this.getActiveSelectionKey(scope, manager, project); const operation = this.beginSelectionOperation(key); - const publishInlineSelection = - !(scope instanceof Uri) || this.shouldPublishInlineSelectionImmediately(scope, manager); const inlineClearOperation = scope instanceof Uri && manager.id !== INLINE_SCRIPT_MANAGER_ID ? this.beginSelectionOperation(this.getInlineScriptSelectionKey(scope)) @@ -394,12 +396,8 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { } if (scope instanceof Uri) { - this.updateInlineRoutingOverride(scope, manager, environment); this.clearInlineActiveSelection(scope, manager, inlineClearOperation); } - if (!publishInlineSelection) { - return; - } if (!this.commitSelectionOperation(key, operation)) { return; } @@ -473,11 +471,7 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { await setAllManagerSettings(settings); } selections.forEach((selection) => { - this.updateInlineRoutingOverride(selection.scope, manager, environment); this.clearInlineActiveSelection(selection.scope, manager, selection.inlineClearOperation); - if (!selection.publishInlineSelection) { - return; - } if (!this.commitSelectionOperation(selection.key, selection.operation)) { return; } @@ -542,7 +536,6 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { await manager.set(uris); await Promise.all( selections.map(async (selection) => { - this.clearInlineRoutingOverride(selection.scope); const newEnv = await manager.get(selection.scope); if (!this.commitSelectionOperation(selection.key, selection.operation)) { return; @@ -700,117 +693,6 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { return `inline-script:${normalizePath(scope.fsPath)}`; } - private getExactProjectEnvironmentManager( - scope: Uri, - project: PythonProject | undefined, - ): InternalEnvironmentManager | undefined { - if (!project || normalizePath(project.uri.fsPath) !== normalizePath(scope.fsPath)) { - return undefined; - } - const exactManagerId = getProjectEnvironmentManagerSetting(this.pm, scope); - return exactManagerId ? this._environmentManagers.get(exactManagerId) : undefined; - } - - private getConfiguredOrCachedEnvironmentManager( - context: Uri | undefined, - project: PythonProject | undefined, - ): InternalEnvironmentManager | undefined { - const defaultEnvManagerId = getDefaultEnvManagerSetting(this.pm, context); - if (defaultEnvManagerId !== undefined) { - const settingsManager = this._environmentManagers.get(defaultEnvManagerId); - if (settingsManager) { - return settingsManager; - } - } - - const cachedEnv = this._activeSelection.get(this.getProjectSelectionKey(project)); - if (cachedEnv) { - const cachedManager = this._environmentManagers.get(cachedEnv.envId.managerId); - if (cachedManager) { - return cachedManager; - } - } - - return undefined; - } - - private getProjectSelectionKey(project: PythonProject | undefined): string { - return project ? project.uri.toString() : 'global'; - } - - private getInlineRoutingOverrideManager(scope: Uri): InternalEnvironmentManager | undefined { - const managerId = this._inlineRoutingOverrides.get(this.getInlineScriptSelectionKey(scope)); - return managerId ? this._environmentManagers.get(managerId) : undefined; - } - - private updateInlineRoutingOverride( - scope: Uri, - manager: InternalEnvironmentManager, - environment: PythonEnvironment | undefined, - ): void { - const key = this.getInlineScriptSelectionKey(scope); - if (!environment || manager.id === INLINE_SCRIPT_MANAGER_ID) { - this._inlineRoutingOverrides.delete(key); - return; - } - this._inlineRoutingOverrides.set(key, manager.id); - } - - private clearInlineRoutingOverride(scope: Uri): void { - this._inlineRoutingOverrides.delete(this.getInlineScriptSelectionKey(scope)); - } - - private async handleInlineScriptRouteabilityChange( - event: InlineScriptRouteabilityChangeEvent, - ): Promise { - const { uri, previousRouteable } = event; - const project = this.pm.get(uri); - const exactManager = this.getExactProjectEnvironmentManager(uri, project); - if (exactManager) { - if (exactManager.id === INLINE_SCRIPT_MANAGER_ID) { - await this.refreshEnvironment(uri); - } - return; - } - - if (this.getInlineRoutingOverrideManager(uri)) { - return; - } - - const manager = this.getEnvironmentManager(uri); - if (!manager) { - return; - } - - const refreshedProject = this.pm.get(uri); - const key = this.getActiveSelectionKey(uri, manager, refreshedProject); - const operation = this.beginSelectionOperation(key); - const newEnv = await manager.get(uri); - const latestProject = this.pm.get(uri); - if (this.getEnvironmentManager(uri) !== manager || !this.commitSelectionOperation(key, operation)) { - return; - } - - const inlineKey = this.getInlineScriptSelectionKey(uri); - const oldEnv = previousRouteable - ? this._activeSelection.get(inlineKey) - : this._activeSelection.get(this.getProjectSelectionKey(latestProject)); - - if (manager.id !== INLINE_SCRIPT_MANAGER_ID) { - this._activeSelection.delete(inlineKey); - } - this._activeSelection.set(key, newEnv); - if (!this.isSameEnvironment(oldEnv, newEnv)) { - await this.fireActiveEnvironmentEvents([ - { - uri: this.getActiveSelectionUri(uri, manager, latestProject), - old: oldEnv, - new: newEnv, - }, - ]); - } - } - private beginPendingSelection(scope: Uri, manager: InternalEnvironmentManager): PendingEnvironmentSelection { const project = this.pm.get(scope); const key = this.getActiveSelectionKey(scope, manager, project); @@ -819,7 +701,6 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { project, key, operation: this.beginSelectionOperation(key), - publishInlineSelection: this.shouldPublishInlineSelectionImmediately(scope, manager), inlineClearOperation: manager.id === INLINE_SCRIPT_MANAGER_ID ? undefined @@ -827,10 +708,6 @@ export class PythonEnvironmentManagers implements EnvironmentManagers { }; } - private shouldPublishInlineSelectionImmediately(scope: Uri, manager: InternalEnvironmentManager): boolean { - return manager.id !== INLINE_SCRIPT_MANAGER_ID || this.inlineScriptRouting.shouldRoute(scope); - } - private clearInlineActiveSelection( scope: Uri, manager: InternalEnvironmentManager, @@ -920,6 +797,5 @@ interface PendingEnvironmentSelection { readonly project: PythonProject | undefined; readonly key: string; readonly operation: number; - readonly publishInlineSelection: boolean; readonly inlineClearOperation: number | undefined; } diff --git a/src/features/inlineScript/lazyDetector.ts b/src/features/inlineScript/lazyDetector.ts index 5595928b6..fb9756e1f 100644 --- a/src/features/inlineScript/lazyDetector.ts +++ b/src/features/inlineScript/lazyDetector.ts @@ -2,19 +2,16 @@ // Licensed under the MIT License. import * as path from 'path'; -import { Disposable, TextDocument, TextDocumentChangeEvent, TextDocumentContentChangeEvent, Uri } from 'vscode'; +import { Disposable, TextDocument, TextDocumentChangeEvent, Uri } from 'vscode'; import { readInlineScriptMetadataFromFile } from '../../common/inlineScript/metadata'; -import { getInlineScriptRoutingKey, InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { traceVerbose, traceWarn } from '../../common/logging'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import { getOpenTextDocuments, getWorkspaceFolder, - onDidDeleteFiles, onDidChangeTextDocument, onDidOpenTextDocument, - onDidRenameFiles, onDidSaveTextDocument, } from '../../common/workspace.apis'; @@ -61,8 +58,6 @@ export class InlineScriptLazyDetector implements Disposable { // already torn down. private disposed = false; - constructor(private readonly routingRegistry: InlineScriptRoutingRegistry = new InlineScriptRoutingRegistry()) {} - /** * Subscribe to workspace text-document events. Safe to call once * during extension activation. @@ -88,8 +83,6 @@ export class InlineScriptLazyDetector implements Disposable { onDidOpenTextDocument((doc) => this.handleDocument(doc, 'open')), onDidSaveTextDocument((doc) => this.handleDocument(doc, 'save')), onDidChangeTextDocument((e) => this.handleChange(e)), - onDidDeleteFiles((e) => e.files.forEach((uri) => this.clearRouteability(uri))), - onDidRenameFiles((e) => e.files.forEach((file) => this.clearRouteability(file.oldUri))), ); // Defer the catch-up pass so we observe `workspace.textDocuments` // AFTER VS Code finishes registering the document that triggered @@ -106,13 +99,19 @@ export class InlineScriptLazyDetector implements Disposable { * `handleDocument` keeps this safe to call repeatedly. */ private replayOpenDocuments(source: 'activate'): void { - const openDocs = getOpenTextDocuments().filter((d) => shouldTrackRoutingUri(d.uri)); + // Restrict the replay to documents that the per-event handler + // would actually look at. This keeps the activation log + // proportional to the work the detector will do — on an + // editor with many tabs open we would otherwise dump every + // URI just to throw most of them away inside + // `handleDocument`. + const openDocs = getOpenTextDocuments().filter((d) => shouldHandleUri(d.uri)); if (openDocs.length === 0) { - traceVerbose(`inlineScriptLazyDetector: ${source} replay found no candidate local .py documents`); + traceVerbose(`inlineScriptLazyDetector: ${source} replay found no candidate .py documents`); return; } traceVerbose( - `inlineScriptLazyDetector: ${source} replay over ${openDocs.length} candidate local .py document(s): ` + + `inlineScriptLazyDetector: ${source} replay over ${openDocs.length} candidate .py document(s): ` + openDocs.map((d) => d.uri.fsPath).join(', '), ); for (const doc of openDocs) { @@ -135,7 +134,7 @@ export class InlineScriptLazyDetector implements Disposable { // the `Trace` log level — to avoid flooding the default // `Info` channel. traceVerbose(`inlineScriptLazyDetector: event received (${trigger}) ${uri.toString()}`); - if (!shouldTrackRoutingUri(uri)) { + if (!shouldHandleUri(uri)) { traceVerbose( `inlineScriptLazyDetector: skipped (${trigger}) ${uri.toString()} ` + `(scheme='${uri.scheme}', extname='${path.extname(uri.fsPath).toLowerCase()}', ` + @@ -143,11 +142,6 @@ export class InlineScriptLazyDetector implements Disposable { ); return; } - if (trigger === 'open' && doc.isDirty) { - traceVerbose(`inlineScriptLazyDetector: withholding dirty document metadata for ${uri.toString()}`); - this.clearRouteability(uri); - return; - } const key = uri.toString(); const existing = this.inFlight.get(key); if (existing) { @@ -158,21 +152,20 @@ export class InlineScriptLazyDetector implements Disposable { await existing; return; } - const work = this.processOnce(uri, trigger, shouldHandleUri(uri)).finally(() => { + const work = this.processOnce(uri, trigger).finally(() => { this.inFlight.delete(key); }); this.inFlight.set(key, work); await work; } - private async processOnce(uri: Uri, trigger: 'open' | 'save', shouldEmitTelemetry: boolean): Promise { + private async processOnce(uri: Uri, trigger: 'open' | 'save'): Promise { try { const metadata = await readInlineScriptMetadataFromFile(uri); if (this.disposed) { return; } - this.routingRegistry.setMetadata(uri, metadata); - if (!shouldEmitTelemetry || metadata === undefined) { + if (metadata === undefined) { return; } const key = uri.toString(); @@ -216,10 +209,6 @@ export class InlineScriptLazyDetector implements Disposable { if (e.contentChanges.length === 0) { return; } - const metadata = this.routingRegistry.getMetadata(e.document.uri); - if (metadata && this.contentChangesMayAffectMetadata(e.contentChanges, metadata.range.end)) { - this.clearRouteability(e.document.uri); - } const key = e.document.uri.toString(); if (!this.detectedUris.has(key)) { return; @@ -235,21 +224,6 @@ export class InlineScriptLazyDetector implements Disposable { ); sendTelemetryEvent(EventNames.INLINE_SCRIPT_EDITED, duration); } - - private contentChangesMayAffectMetadata( - changes: readonly TextDocumentContentChangeEvent[], - metadataEnd: number, - ): boolean { - return changes.some((change) => change.rangeOffset < metadataEnd); - } - - private clearRouteability(uri: Uri): void { - if (!shouldTrackRoutingUri(uri)) { - return; - } - this.routingRegistry.clearMetadata(uri); - this.routingRegistry.setValidatedAssociation(uri, false); - } } /** @@ -270,7 +244,3 @@ export function shouldHandleUri(uri: Uri): boolean { } return true; } - -function shouldTrackRoutingUri(uri: Uri): boolean { - return getInlineScriptRoutingKey(uri) !== undefined; -} diff --git a/src/managers/builtin/inlineScript/envManager.ts b/src/managers/builtin/inlineScript/envManager.ts index 81deac9c6..d68d9dda6 100644 --- a/src/managers/builtin/inlineScript/envManager.ts +++ b/src/managers/builtin/inlineScript/envManager.ts @@ -24,9 +24,6 @@ import { getErrorMessage } from '../../../common/errors/utils'; import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey'; import { CacheEnvironmentInspection, - InlineScriptEnvMeta, - hashSourceMetadataIdentity, - mergeSourceMetadataIdentityHashes, META_SCHEMA_VERSION, getBaseInterpreterStatus, getScriptEnvCacheRoot, @@ -38,11 +35,6 @@ import { } from '../../../common/inlineScript/cacheLayout'; import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter'; import { InlineScriptMetadata, readInlineScriptMetadataFromFile } from '../../../common/inlineScript/metadata'; -import { - getInlineScriptMetadataRoutingIdentity, - InlineScriptMetadataChangeEvent, - InlineScriptRoutingRegistry, -} from '../../../common/inlineScript/routingRegistry'; import { CONDA_MANAGER_ID, ENVS_EXTENSION_ID, @@ -56,7 +48,6 @@ import { isFileNotFoundError } from '../../../common/utils/filesystem'; import { normalizePath } from '../../../common/utils/pathUtils'; import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release'; import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment'; -import { getOpenTextDocuments, onDidDeleteFiles, onDidRenameFiles } from '../../../common/workspace.apis'; import { NativePythonFinder } from '../../common/nativePythonFinder'; import { resolveSystemPythonEnvironmentPath } from '../utils'; import * as uvPythonInstaller from '../uvPythonInstaller'; @@ -73,7 +64,6 @@ const CACHE_LOCK_RETRY_MS = 500; const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000; /** Workspace-state key for PEP 723 script path to environment executable associations. */ export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`; -const PERSISTED_ASSOCIATION_SCHEMA_VERSION = 1 as const; interface SelectedBaseInterpreter { readonly environment: PythonEnvironment; @@ -85,7 +75,6 @@ interface CreateOrReuseEnvironmentOptions { readonly packages: ReadonlyArray; readonly metadata: InlineScriptMetadata; readonly selectedBase: SelectedBaseInterpreter; - readonly pendingCreation: PendingCreationContext; } interface BuildCacheEntryResult { @@ -93,61 +82,21 @@ interface BuildCacheEntryResult { readonly retainLock?: boolean; } -interface PendingCreationContext { - promise: Promise; - sourceMetadataIdentityHashes?: readonly string[]; - hasStartedRecordingSourceMetadataIdentityHashes: boolean; - recordedSourceMetadataIdentityHashes?: readonly string[]; -} - -interface MergeCacheEntrySourceMetadataIdentityHashResult { - readonly success: boolean; - readonly sourceMetadataIdentityHashes?: readonly string[]; -} - type CacheEntryInspection = | { readonly kind: 'absent' | 'stale' | 'uncertain' } | { readonly kind: 'reusable'; readonly environment: PythonEnvironment }; -interface PendingAssociationValidation { - readonly metadataIdentity: string; - readonly associationRevision: number; - readonly promise: Promise; -} - -interface PendingMetadataRefresh { - readonly metadataIdentity: string; - readonly metadataRevision: number; - readonly associationRevision: number; - readonly promise: Promise; -} - -interface ParsedPersistedAssociations { - readonly rawEntries: Record; - readonly records: PersistedInlineScriptEnvironments; - readonly invalidKeys: Set; -} - -interface SavedMetadataSnapshot { - readonly metadata?: InlineScriptMetadata; - readonly identity?: string; -} - /** Manages extension-owned PEP 723 script environments. */ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly pendingSetups = new Map>(); - private readonly pendingCreations = new Map(); + private readonly pendingCreations = new Map>(); private readonly directlyResolvedBaseInterpreters = new Map(); private baseInterpreterInstallationQueue: Promise = Promise.resolve(); - private readonly pendingRehydrations = new Map(); - private readonly pendingMetadataRefreshes = new Map(); + private readonly pendingRehydrations = new Map>(); private readonly fsPathToEnv = new Map(); - private readonly fsPathToPersistedAssociation = new Map(); + private readonly fsPathToPersistedEnvPath = new Map(); private readonly cachedAssociationValidatedAt = new Map(); - private readonly lastValidatedMetadataIdentities = new Map(); - private readonly lastValidatedMetadataIdentityProofs = new Map(); private readonly associationRevisions = new Map(); - private readonly subscriptions: Disposable[] = []; private persistenceQueue: Promise = Promise.resolve(); private selectionQueue: Promise = Promise.resolve(); @@ -174,33 +123,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private readonly baseManager: EnvironmentManager, private readonly globalStorageUri: Uri, public readonly log: LogOutputChannel, - private readonly routingRegistry: InlineScriptRoutingRegistry = new InlineScriptRoutingRegistry(), - ) { - this.subscriptions.push( - this.routingRegistry.onDidChangeMetadata((event) => { - void this.handleSavedMetadataChange(event).catch((error) => { - this.log.warn(`Failed to refresh inline-script routing state: ${getErrorMessage(error)}`); - }); - }), - onDidDeleteFiles((event) => { - void this.clearAssociationsForScripts(event.files).catch((error) => { - this.log.warn(`Failed to clear inline-script associations for deleted files: ${getErrorMessage(error)}`); - }); - }), - onDidRenameFiles((event) => { - void this.clearAssociationsForScripts(event.files.map((file) => file.oldUri)).catch((error) => { - this.log.warn(`Failed to clear inline-script associations for renamed files: ${getErrorMessage(error)}`); - }); - }), - ); - queueMicrotask(() => { - void this.initializePersistedAssociations().catch((error) => { - this.log.warn( - `Failed to prime inline-script environment associations: ${getErrorMessage(error)}`, - ); - }); - }); - } + ) {} async create( scope: CreateEnvironmentScope, @@ -268,47 +191,22 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { dependencies: packages, interpreterPath: selectedBase.canonicalPath, }); - const metadataIdentity = getInlineScriptMetadataRoutingIdentity(metadata); - const sourceMetadataIdentityHash = metadataIdentity ? hashSourceMetadataIdentity(metadataIdentity) : undefined; const pending = this.pendingCreations.get(cacheKey); if (pending) { - const joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes = - pending.hasStartedRecordingSourceMetadataIdentityHashes; - this.addPendingCreationSourceMetadataIdentityHash(pending, sourceMetadataIdentityHash); - const environment = await pending.promise; - return await this.finalizeCreateForScript( - cacheKey, - environment, - sourceMetadataIdentityHash, - pending, - joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes, - ); + return await pending; } - const pendingCreation: PendingCreationContext = { - promise: Promise.resolve(undefined), - sourceMetadataIdentityHashes: mergeSourceMetadataIdentityHashes(undefined, sourceMetadataIdentityHash), - hasStartedRecordingSourceMetadataIdentityHashes: false, - }; + const creation = this.createOrReuseEnvironment({ cacheKey, packages, metadata, selectedBase, - pendingCreation, }); - pendingCreation.promise = creation; - this.pendingCreations.set(cacheKey, pendingCreation); + this.pendingCreations.set(cacheKey, creation); try { - const environment = await creation; - return await this.finalizeCreateForScript( - cacheKey, - environment, - sourceMetadataIdentityHash, - pendingCreation, - false, - ); + return await creation; } finally { - if (this.pendingCreations.get(cacheKey) === pendingCreation) { + if (this.pendingCreations.get(cacheKey) === creation) { this.pendingCreations.delete(cacheKey); } } @@ -329,45 +227,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ]); } - private addPendingCreationSourceMetadataIdentityHash( - pendingCreation: PendingCreationContext, - sourceMetadataIdentityHash: string | undefined, - ): void { - pendingCreation.sourceMetadataIdentityHashes = mergeSourceMetadataIdentityHashes( - pendingCreation.sourceMetadataIdentityHashes, - sourceMetadataIdentityHash, - ); - } - - private async finalizeCreateForScript( - cacheKey: string, - environment: PythonEnvironment | undefined, - sourceMetadataIdentityHash: string | undefined, - pendingCreation: PendingCreationContext, - joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes: boolean, - ): Promise { - if (!environment || !sourceMetadataIdentityHash) { - return environment; - } - if ( - pendingCreation.recordedSourceMetadataIdentityHashes?.includes(sourceMetadataIdentityHash) !== true && - joinedAfterPendingCreationStartedRecordingSourceMetadataIdentityHashes - ) { - const mergeResult = await this.mergeCacheEntrySourceMetadataIdentityHash( - cacheKey, - sourceMetadataIdentityHash, - ); - if (!mergeResult.success) { - this.log.warn( - `Failed to durably record inline-script cache provenance for ${cacheKey}; returning no environment to the caller.`, - ); - return undefined; - } - pendingCreation.recordedSourceMetadataIdentityHashes = mergeResult.sourceMetadataIdentityHashes; - } - return environment; - } - async refresh(_scope: RefreshEnvironmentsScope): Promise { return; } @@ -413,22 +272,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const updates: PendingScriptUpdate[] = []; for (const script of scripts) { const before = await this.getAssociationForMutation(script.scriptPath); - const persistedAssociation = this.getPersistedAssociationFromMemory(script.scriptPath); - const savedMetadata = environment ? await this.getSavedMetadataForPersistence(script.uri) : undefined; - const sourceMetadataIdentity = - environment && savedMetadata - ? await this.resolveVerifiedSourceMetadataIdentity(script, environment, savedMetadata) - : undefined; - const nextPersistedAssociation = environmentPath - ? this.createPersistedAssociationRecord(environmentPath, sourceMetadataIdentity, savedMetadata?.identity) - : undefined; - const needsPersistence = nextPersistedAssociation - ? !this.isSamePersistedAssociation(persistedAssociation, nextPersistedAssociation) - : persistedAssociation !== undefined; + const hadPersistedAssociation = this.fsPathToPersistedEnvPath.has(script.scriptPath); + const hasSamePersistedEnvironment = + environmentPath !== undefined && + normalizePath(this.fsPathToPersistedEnvPath.get(script.scriptPath) ?? '') === + normalizePath(environmentPath); + const needsPersistence = environment ? !hasSamePersistedEnvironment : hadPersistedAssociation; const shouldNotify = - (!this.isSameEnvironment(before, environment) && - !this.isSamePersistedAssociation(persistedAssociation, nextPersistedAssociation)) || - (!environment && persistedAssociation !== undefined); + (!this.isSameEnvironment(before, environment) && !hasSamePersistedEnvironment) || + (!environment && hadPersistedAssociation); const hasPendingRehydration = this.pendingRehydrations.has(script.scriptPath); const cached = this.fsPathToEnv.get(script.scriptPath); const needsMemoryUpdate = environment ? cached !== environment : cached !== undefined; @@ -436,7 +288,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { updates.push({ ...script, before, - persistedAssociation: nextPersistedAssociation, needsPersistence, shouldNotify, }); @@ -452,7 +303,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { await this.updatePersistedAssociations( persistenceUpdates.map((update) => ({ scriptPath: update.scriptPath, - persistedAssociation: update.persistedAssociation, + environmentPath, })), ); } @@ -464,15 +315,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { for (const update of updates) { this.bumpAssociationRevision(update.scriptPath); this.pendingRehydrations.delete(update.scriptPath); - this.pendingMetadataRefreshes.delete(update.scriptPath); if (environment) { this.fsPathToEnv.set(update.scriptPath, environment); - this.fsPathToPersistedAssociation.set(update.scriptPath, update.persistedAssociation!); - this.invalidateCachedAssociationValidation(update.scriptPath); + this.fsPathToPersistedEnvPath.set(update.scriptPath, environmentPath!); + this.cachedAssociationValidatedAt.set(update.scriptPath, Date.now()); } else { this.fsPathToEnv.delete(update.scriptPath); - this.fsPathToPersistedAssociation.delete(update.scriptPath); - this.invalidateCachedAssociationValidation(update.scriptPath); + this.fsPathToPersistedEnvPath.delete(update.scriptPath); + this.cachedAssociationValidatedAt.delete(update.scriptPath); } if (update.shouldNotify) { this._onDidChangeEnvironment.fire({ @@ -482,16 +332,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { }); } } - - await Promise.all( - updates.map(async (update) => { - if (!environment) { - this.clearValidatedRouteableState(update.uri); - return; - } - await this.updateValidatedStateForSelection(update); - }), - ); } private async getInternal(scope: GetEnvironmentScope): Promise { @@ -506,11 +346,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } - return this.getAssociationForMetadata( - normalizePath(scope.fsPath), - scope, - metadata, - ); + const environment = await this.getAssociation(normalizePath(scope.fsPath), scope); + if (!environment) { + return undefined; + } + + const requiresPython = metadata.requiresPython?.trim(); + return requiresPython && !this.matchesInstallConstraint(requiresPython, environment.version) + ? undefined + : environment; } private getScriptUris(scope: SetEnvironmentScope): ScriptReference[] { @@ -535,72 +379,39 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return scripts; } - private async getAssociationForMetadata( - scriptPath: string, - scriptUri: Uri, - metadata: InlineScriptMetadata, - ): Promise { + private async getAssociation(scriptPath: string, scriptUri: Uri): Promise { const pending = this.pendingRehydrations.get(scriptPath); + if (pending) { + return pending; + } + const cached = this.fsPathToEnv.get(scriptPath); const revision = this.associationRevisions.get(scriptPath) ?? 0; - const metadataIdentity = getInlineScriptMetadataRoutingIdentity(metadata)!; - const forceFreshValidation = - this.fsPathToPersistedAssociation.get(scriptPath)?.metadataBinding.kind === 'pending'; - if ( - pending && - pending.metadataIdentity === metadataIdentity && - pending.associationRevision === revision - ) { - return pending.promise; - } if (cached) { const validatedAt = this.cachedAssociationValidatedAt.get(scriptPath); if ( - !forceFreshValidation && validatedAt !== undefined && - this.lastValidatedMetadataIdentities.get(scriptPath) === metadataIdentity && Date.now() - validatedAt < CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS ) { return cached; } - const validation = this.validateCachedAssociation( - scriptPath, - scriptUri, - cached, - revision, - metadataIdentity, - metadata, - ); - this.pendingRehydrations.set(scriptPath, { - metadataIdentity, - associationRevision: revision, - promise: validation, - }); + const validation = this.validateCachedAssociation(scriptPath, scriptUri, cached, revision); + this.pendingRehydrations.set(scriptPath, validation); try { return await validation; } finally { - if (this.pendingRehydrations.get(scriptPath)?.promise === validation) { + if (this.pendingRehydrations.get(scriptPath) === validation) { this.pendingRehydrations.delete(scriptPath); } } } - const rehydration = this.rehydrateAssociation( - scriptPath, - scriptUri, - revision, - metadataIdentity, - metadata, - ); - this.pendingRehydrations.set(scriptPath, { - metadataIdentity, - associationRevision: revision, - promise: rehydration, - }); + const rehydration = this.rehydrateAssociation(scriptPath, scriptUri, revision); + this.pendingRehydrations.set(scriptPath, rehydration); try { return await rehydration; } finally { - if (this.pendingRehydrations.get(scriptPath)?.promise === rehydration) { + if (this.pendingRehydrations.get(scriptPath) === rehydration) { this.pendingRehydrations.delete(scriptPath); } } @@ -620,11 +431,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scriptUri: Uri, cached: PythonEnvironment, revision: number, - metadataIdentity: string, - metadata: InlineScriptMetadata, ): Promise { const environmentPath = cached.environmentPath.fsPath; - const expectedPersistedAssociation = this.fsPathToPersistedAssociation.get(scriptPath); const envDirPath = path.dirname(path.dirname(environmentPath)); const busy = await this.isCacheEntryBusy(envDirPath); if (!this.isCurrentAssociationRevision(scriptPath, revision)) { @@ -662,35 +470,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { environmentPath, revision, scriptUri, - expectedPersistedAssociation, ); return undefined; } if (ownership !== 'expected') { return undefined; } - const metadataMatch = this.inspectAssociationMetadata(scriptPath, metadataIdentity, true); - if (!this.isCurrentAssociationRevision(scriptPath, revision)) { - return this.fsPathToEnv.get(scriptPath); - } - if (metadataMatch === 'mismatched') { - return undefined; - } - const metadataIdentityProven = await this.currentCacheEntryProvesSourceMetadataIdentity( - resolved, - metadataIdentity, - metadata, - ); - if (!this.isCurrentAssociationRevision(scriptPath, revision)) { - return this.fsPathToEnv.get(scriptPath); - } - const current = this.fsPathToEnv.get(scriptPath); this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); - this.lastValidatedMetadataIdentities.set(scriptPath, metadataIdentity); - this.lastValidatedMetadataIdentityProofs.set(scriptPath, metadataIdentityProven); - if (current && this.isSameEnvironment(current, resolved)) { - return current; - } if (cached.version === resolved.version) { return cached; } @@ -708,7 +494,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { environmentPath, revision, scriptUri, - expectedPersistedAssociation, ); } } catch (error) { @@ -726,7 +511,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { environmentPath, revision, scriptUri, - expectedPersistedAssociation, ); } } else { @@ -742,17 +526,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { scriptPath: string, scriptUri: Uri, revision: number, - metadataIdentity: string, - metadata: InlineScriptMetadata, ): Promise { - let persistedAssociation: PersistedAssociationRecord | undefined; + let environmentPath: string | undefined; try { - persistedAssociation = await this.getPersistedAssociation(scriptPath); + environmentPath = await this.getPersistedAssociation(scriptPath); } catch (error) { this.log.warn(`Failed to read inline-script environment association: ${getErrorMessage(error)}`); return undefined; } - const environmentPath = persistedAssociation?.environmentPath; if (!environmentPath) { return undefined; } @@ -760,13 +541,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return this.fsPathToEnv.get(scriptPath); } if (!path.isAbsolute(environmentPath)) { - await this.removeStalePersistedAssociation( - scriptPath, - environmentPath, - revision, - scriptUri, - persistedAssociation, - ); + await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); return undefined; } const envDirPath = path.dirname(path.dirname(environmentPath)); @@ -778,26 +553,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { const stat = await fs.stat(environmentPath); if (!stat.isFile()) { if (!(await this.isCacheEntryBusy(envDirPath))) { - await this.removeStalePersistedAssociation( - scriptPath, - environmentPath, - revision, - scriptUri, - persistedAssociation, - ); + await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); } return undefined; } } catch (error) { if (this.isDefinitivelyStalePathError(error)) { if (!(await this.isCacheEntryBusy(envDirPath))) { - await this.removeStalePersistedAssociation( - scriptPath, - environmentPath, - revision, - scriptUri, - persistedAssociation, - ); + await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); } } else { this.log.warn( @@ -840,66 +603,22 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return undefined; } if (ownership === 'stale') { - await this.removeStalePersistedAssociation( - scriptPath, - environmentPath, - revision, - scriptUri, - persistedAssociation, - ); + await this.removeStalePersistedAssociation(scriptPath, environmentPath, revision, scriptUri); return undefined; } if (ownership !== 'expected') { return undefined; } - const metadataMatch = this.inspectAssociationMetadata(scriptPath, metadataIdentity, true); - if (metadataMatch === 'mismatched') { - return undefined; - } - const metadataIdentityProven = await this.currentCacheEntryProvesSourceMetadataIdentity( - resolved, - metadataIdentity, - metadata, - ); - if (!this.isCurrentAssociationRevision(scriptPath, revision)) { - return this.fsPathToEnv.get(scriptPath); - } - const current = this.fsPathToEnv.get(scriptPath); - this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); - this.lastValidatedMetadataIdentities.set(scriptPath, metadataIdentity); - this.lastValidatedMetadataIdentityProofs.set(scriptPath, metadataIdentityProven); - if (current && this.isSameEnvironment(current, resolved)) { - return current; - } if (!this.isCurrentAssociationRevision(scriptPath, revision) || this.fsPathToEnv.has(scriptPath)) { return this.fsPathToEnv.get(scriptPath); } this.fsPathToEnv.set(scriptPath, resolved); + this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); this._onDidChangeEnvironment.fire({ uri: scriptUri, old: undefined, new: resolved }); return resolved; } - private inspectAssociationMetadata( - scriptPath: string, - metadataIdentity: string, - allowUnboundAssociation: boolean, - ): 'matched' | 'pending' | 'legacy' | 'mismatched' { - const persistedAssociation = this.fsPathToPersistedAssociation.get(scriptPath); - if (!persistedAssociation) { - return 'mismatched'; - } - if (persistedAssociation.metadataBinding.kind === 'matched') { - return persistedAssociation.metadataBinding.sourceIdentity === metadataIdentity ? 'matched' : 'mismatched'; - } - if (persistedAssociation.metadataBinding.kind === 'pending') { - return persistedAssociation.metadataBinding.sourceIdentity === metadataIdentity && allowUnboundAssociation - ? 'pending' - : 'mismatched'; - } - return allowUnboundAssociation ? 'legacy' : 'mismatched'; - } - private async inspectAssociationOwnership(environment: PythonEnvironment): Promise { if (environment.envId.managerId !== INLINE_SCRIPT_MANAGER_ID || !path.isAbsolute(environment.sysPrefix)) { return 'uncertain'; @@ -920,445 +639,33 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { ); } - private async handleSavedMetadataChange(event: InlineScriptMetadataChangeEvent): Promise { - if (event.metadata === undefined) { - this.clearValidatedRouteableState(event.uri); - return; - } - await this.refreshValidatedAssociationForMetadata( - event.uri, - event.metadata, - event.metadataIdentity ?? getInlineScriptMetadataRoutingIdentity(event.metadata)!, - event.metadataRevision, - ); - } - - private async refreshValidatedAssociationForMetadata( - uri: Uri, - metadata: InlineScriptMetadata, - metadataIdentity: string, - metadataRevision: number, - ): Promise { - const scriptPath = normalizePath(uri.fsPath); - const associationRevision = this.associationRevisions.get(scriptPath) ?? 0; - const pendingRefresh = this.pendingMetadataRefreshes.get(scriptPath); - if ( - pendingRefresh && - pendingRefresh.metadataIdentity === metadataIdentity && - pendingRefresh.metadataRevision === metadataRevision && - pendingRefresh.associationRevision === associationRevision - ) { - return pendingRefresh.promise; - } - const refresh = this.refreshValidatedAssociationForMetadataInternal( - scriptPath, - uri, - metadata, - metadataIdentity, - metadataRevision, - associationRevision, - ); - this.pendingMetadataRefreshes.set(scriptPath, { - metadataIdentity, - metadataRevision, - associationRevision, - promise: refresh, - }); - try { - await refresh; - } finally { - if (this.pendingMetadataRefreshes.get(scriptPath)?.promise === refresh) { - this.pendingMetadataRefreshes.delete(scriptPath); - } - } - } - - private async refreshValidatedAssociationForMetadataInternal( - scriptPath: string, - uri: Uri, - metadata: InlineScriptMetadata, - metadataIdentity: string, - metadataRevision: number, - associationRevision: number, - ): Promise { - const environment = await this.getAssociationForMetadata(scriptPath, uri, metadata); - if (!this.isCurrentMetadataRefreshTask(uri, metadataIdentity, metadataRevision, scriptPath, associationRevision)) { - return; - } - if (!environment) { - this.clearValidatedRouteableState(uri); - return; - } - let metadataIdentityProven = this.lastValidatedMetadataIdentityProofs.get(scriptPath); - if ( - this.lastValidatedMetadataIdentities.get(scriptPath) !== metadataIdentity || - metadataIdentityProven === undefined - ) { - metadataIdentityProven = await this.currentCacheEntryProvesSourceMetadataIdentity( - environment, - metadataIdentity, - metadata, - ); - if ( - !this.isCurrentMetadataRefreshTask( - uri, - metadataIdentity, - metadataRevision, - scriptPath, - associationRevision, - ) - ) { - return; - } - this.cachedAssociationValidatedAt.set(scriptPath, Date.now()); - this.lastValidatedMetadataIdentities.set(scriptPath, metadataIdentity); - this.lastValidatedMetadataIdentityProofs.set(scriptPath, metadataIdentityProven); - } - if (metadataIdentityProven !== true) { - this.clearValidatedRouteableState(uri); - return; - } - const metadataMatch = this.inspectAssociationMetadata(scriptPath, metadataIdentity, true); - if (metadataMatch === 'pending') { - let bindResult = await this.bindPendingMetadataIdentity( - scriptPath, - environment.environmentPath.fsPath, - metadataIdentity, - metadataRevision, - associationRevision, - uri, - ); - if (!this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision)) { - return; - } - if ( - bindResult === 'stale' && - !this.isCurrentAssociationRevision(scriptPath, associationRevision) - ) { - const currentAssociation = this.fsPathToPersistedAssociation.get(scriptPath); - const currentAssociationRevision = this.associationRevisions.get(scriptPath) ?? 0; - if ( - currentAssociation?.metadataBinding.kind === 'pending' && - currentAssociation.metadataBinding.sourceIdentity === metadataIdentity && - normalizePath(currentAssociation.environmentPath) === - normalizePath(environment.environmentPath.fsPath) - ) { - bindResult = await this.bindPendingMetadataIdentity( - scriptPath, - environment.environmentPath.fsPath, - metadataIdentity, - metadataRevision, - currentAssociationRevision, - uri, - ); - if ( - !this.isCurrentMetadataRefreshTask( - uri, - metadataIdentity, - metadataRevision, - scriptPath, - currentAssociationRevision, - ) - ) { - return; - } - } - } else if (!this.isCurrentAssociationRevision(scriptPath, associationRevision)) { - return; - } - if (bindResult !== 'bound') { - const currentAssociation = this.fsPathToPersistedAssociation.get(scriptPath); - if ( - currentAssociation?.metadataBinding.kind === 'pending' && - currentAssociation.metadataBinding.sourceIdentity === metadataIdentity && - normalizePath(currentAssociation.environmentPath) === - normalizePath(environment.environmentPath.fsPath) - ) { - this.invalidateCachedAssociationValidation(scriptPath); - } - return; - } - } else if (metadataMatch !== 'matched') { - this.clearValidatedRouteableState(uri); - return; - } - this.routingRegistry.setValidatedAssociation(uri, true); - } - - private async updateValidatedStateForSelection(script: ScriptReference): Promise { - const savedMetadata = await this.getSavedMetadataForPersistence(script.uri); - if (!savedMetadata.identity) { - this.clearValidatedRouteableState(script.uri); - return; - } - if (this.inspectAssociationMetadata(script.scriptPath, savedMetadata.identity, false) !== 'matched') { - this.clearValidatedRouteableState(script.uri); - return; - } - this.cachedAssociationValidatedAt.set(script.scriptPath, Date.now()); - this.lastValidatedMetadataIdentities.set(script.scriptPath, savedMetadata.identity); - this.routingRegistry.setValidatedAssociation( - script.uri, - this.routingRegistry.getMetadataIdentity(script.uri) === savedMetadata.identity, - ); - } - - private async getSavedMetadataForPersistence(uri: Uri): Promise { - for (const document of getOpenTextDocuments()) { - if (document.uri.toString() === uri.toString() && document.isDirty) { - return {}; - } - } - return this.readSavedMetadataSnapshot(uri); - } - - private async readSavedMetadataSnapshot(uri: Uri): Promise { - const metadata = await readInlineScriptMetadataFromFile(uri); - return { - metadata, - identity: getInlineScriptMetadataRoutingIdentity(metadata), - }; - } - - private async currentCacheEntryProvesSourceMetadataIdentity( - environment: PythonEnvironment, - metadataIdentity: string, - metadata: InlineScriptMetadata, - ): Promise { - const sidecar = await this.readCurrentCacheEntrySidecar(environment); - return !!sidecar && this.cacheEntryProvesSourceMetadataIdentity(sidecar, environment, metadataIdentity, metadata); - } - - private async readCurrentCacheEntrySidecar(environment: PythonEnvironment): Promise { - let sidecarResult; - try { - sidecarResult = await inspectMetaJson(Uri.file(environment.sysPrefix)); - } catch { - return undefined; - } - return sidecarResult.kind === 'valid' ? sidecarResult.metadata : undefined; - } - - private cacheEntryProvesSourceMetadataIdentity( - sidecar: InlineScriptEnvMeta, - environment: PythonEnvironment, - metadataIdentity: string, - metadata: InlineScriptMetadata, - ): boolean { - return ( - this.sidecarProvesSourceMetadataIdentity(sidecar, metadataIdentity) || - this.isMetadataOnlyCacheEntryForMetadata(sidecar, environment, metadata) - ); - } - - private async resolveVerifiedSourceMetadataIdentity( - script: ScriptReference, - environment: PythonEnvironment, - savedMetadata: SavedMetadataSnapshot, - ): Promise { - if (savedMetadata.identity) { - return savedMetadata.metadata && - (await this.currentCacheEntryProvesSourceMetadataIdentity( - environment, - savedMetadata.identity, - savedMetadata.metadata, - )) - ? savedMetadata.identity - : undefined; - } - - const persistedSourceMetadataIdentity = this.getPersistedSourceMetadataIdentity( - script.scriptPath, - environment.environmentPath.fsPath, - ); - if (persistedSourceMetadataIdentity) { - const sidecar = await this.readCurrentCacheEntrySidecar(environment); - if (sidecar && this.sidecarProvesSourceMetadataIdentity(sidecar, persistedSourceMetadataIdentity)) { - return persistedSourceMetadataIdentity; - } - } - - const savedSourceMetadata = await this.readSavedMetadataSnapshot(script.uri); - if (!savedSourceMetadata.identity || !savedSourceMetadata.metadata) { - return undefined; - } - return (await this.currentCacheEntryProvesSourceMetadataIdentity( - environment, - savedSourceMetadata.identity, - savedSourceMetadata.metadata, - )) - ? savedSourceMetadata.identity - : undefined; - } - - private sidecarProvesSourceMetadataIdentity( - sidecar: InlineScriptEnvMeta, - metadataIdentity: string, - ): boolean { - if (sidecar.sourceMetadataIdentityHashes === undefined) { - return false; - } - const expectedHash = hashSourceMetadataIdentity(metadataIdentity); - return sidecar.sourceMetadataIdentityHashes.includes(expectedHash); - } - - private isMetadataOnlyCacheEntryForMetadata( - sidecar: InlineScriptEnvMeta, - environment: PythonEnvironment, - metadata: InlineScriptMetadata, - ): boolean { - if (sidecar.sourceMetadataIdentityHashes !== undefined) { - return false; - } - const expectedCacheKey = computeCacheKey({ - dependencies: metadata.dependencies ?? [], - interpreterPath: sidecar.baseInterpreterPath, - }); - if ( - normalizePath(getScriptEnvDir(this.globalStorageUri, expectedCacheKey).fsPath) !== - normalizePath(environment.sysPrefix) - ) { - return false; - } - const requiresPython = metadata.requiresPython?.trim(); - return !requiresPython || this.matchesInstallConstraint(requiresPython, environment.version); - } - - private getPersistedSourceMetadataIdentity(scriptPath: string, environmentPath: string): string | undefined { - const persistedAssociation = this.fsPathToPersistedAssociation.get(scriptPath); - return persistedAssociation && - normalizePath(persistedAssociation.environmentPath) === normalizePath(environmentPath) && - (persistedAssociation.metadataBinding.kind === 'matched' || - persistedAssociation.metadataBinding.kind === 'pending') - ? persistedAssociation.metadataBinding.sourceIdentity - : undefined; - } - - private async bindPendingMetadataIdentity( - scriptPath: string, - environmentPath: string, - metadataIdentity: string, - metadataRevision: number, - associationRevision: number, - uri: Uri, - ): Promise<'bound' | 'stale' | 'failed'> { - return this.enqueueSelection(async () => { - if ( - !this.isCurrentAssociationRevision(scriptPath, associationRevision) || - !this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision) - ) { - return 'stale'; - } - const expectedAssociation: PersistedAssociationRecord = { - environmentPath, - metadataBinding: { kind: 'pending', sourceIdentity: metadataIdentity }, - }; - const matchedAssociation: PersistedAssociationRecord = { - environmentPath, - metadataBinding: { kind: 'matched', sourceIdentity: metadataIdentity }, - }; - if (!this.isSamePersistedAssociation(this.fsPathToPersistedAssociation.get(scriptPath), expectedAssociation)) { - return 'stale'; - } - try { - await this.updatePersistedAssociations([ - { - scriptPath, - persistedAssociation: matchedAssociation, - expectedPersistedAssociation: expectedAssociation, - }, - ]); - } catch (error) { - this.log.warn(`Failed to bind inline-script metadata identity: ${getErrorMessage(error)}`); - return 'failed'; - } - if ( - !this.isCurrentAssociationRevision(scriptPath, associationRevision) || - !this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision) - ) { - return 'stale'; - } - return this.isSamePersistedAssociation(this.fsPathToPersistedAssociation.get(scriptPath), matchedAssociation) - ? 'bound' - : 'stale'; - }); - } - - private isCurrentMetadataRefreshTask( - uri: Uri, - metadataIdentity: string, - metadataRevision: number, - scriptPath: string, - associationRevision: number, - ): boolean { - return ( - this.isCurrentRoutingMetadata(uri, metadataIdentity, metadataRevision) && - this.isCurrentAssociationRevision(scriptPath, associationRevision) - ); - } - - private isCurrentRoutingMetadata(uri: Uri, metadataIdentity: string, metadataRevision: number): boolean { - return ( - this.routingRegistry.getMetadataIdentity(uri) === metadataIdentity && - this.routingRegistry.getMetadataRevision(uri) === metadataRevision - ); - } - - private clearValidatedRouteableState(script: Uri | string): void { - const scriptPath = typeof script === 'string' ? script : normalizePath(script.fsPath); - this.invalidateCachedAssociationValidation(scriptPath); - this.routingRegistry.setValidatedAssociation(script, false); - } - - private invalidateCachedAssociationValidation(scriptPath: string): void { - this.cachedAssociationValidatedAt.delete(scriptPath); - this.lastValidatedMetadataIdentities.delete(scriptPath); - this.lastValidatedMetadataIdentityProofs.delete(scriptPath); - } - - private initializePersistedAssociations(): Promise { - return this.enqueuePersistence(async (state) => { - const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); - const parsed = this.parsePersistedAssociations(rawAssociations); - this.applyPersistedAssociations(parsed?.records ?? {}); - }).then(async () => { - await Promise.all( - [...this.fsPathToPersistedAssociation.keys()].map(async (scriptPath) => { - const uri = this.routingRegistry.getUri(scriptPath); - const metadata = this.routingRegistry.getMetadata(scriptPath); - if (uri && metadata) { - await this.refreshValidatedAssociationForMetadata( - uri, - metadata, - getInlineScriptMetadataRoutingIdentity(metadata)!, - this.routingRegistry.getMetadataRevision(uri), - ); - } - }), - ); - }); - } - - private async getPersistedAssociation(scriptPath: string): Promise { + private async getPersistedAssociation(scriptPath: string): Promise { await this.persistenceQueue; const state = await getWorkspacePersistentState(); - const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); - if (rawAssociations === undefined) { - this.applyPersistedAssociations({}); + const raw = await state.get(INLINE_SCRIPT_ENVS_KEY); + if (raw === undefined) { + this.fsPathToPersistedEnvPath.delete(scriptPath); return undefined; } - const parsed = this.parsePersistedAssociations(rawAssociations); - if (!parsed) { + const associations = this.asPersistedAssociations(raw); + if (!associations) { await this.removeInvalidPersistedAssociation(scriptPath); - return this.getPersistedAssociationFromMemory(scriptPath); + this.fsPathToPersistedEnvPath.delete(scriptPath); + return undefined; } - const rawValue = (rawAssociations as Record)[scriptPath]; - if (rawValue !== undefined && this.parsePersistedAssociationValue(rawValue).kind === 'invalid') { + const rawValue = (raw as Record)[scriptPath]; + if (rawValue !== undefined && (typeof rawValue !== 'string' || rawValue.length === 0)) { await this.removeInvalidPersistedAssociation(scriptPath); - return this.getPersistedAssociationFromMemory(scriptPath); + this.fsPathToPersistedEnvPath.delete(scriptPath); + return undefined; + } + const environmentPath = associations[scriptPath]; + if (environmentPath) { + this.fsPathToPersistedEnvPath.set(scriptPath, environmentPath); + } else { + this.fsPathToPersistedEnvPath.delete(scriptPath); } - this.applyPersistedAssociations(parsed.records); - return this.getPersistedAssociationFromMemory(scriptPath); + return environmentPath; } private async removeStalePersistedAssociation( @@ -1366,31 +673,23 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { expectedEnvironmentPath: string, revision: number, scriptUri?: Uri, - expectedPersistedAssociation?: PersistedAssociationRecord, ): Promise { await this.enqueueSelection(async () => { if (!this.isCurrentAssociationRevision(scriptPath, revision)) { return; } try { - const persistedPathBeforeUpdate = this.fsPathToPersistedAssociation.get(scriptPath)?.environmentPath; - await this.updatePersistedAssociations([ - { - scriptPath, - expectedEnvironmentPath, - expectedPersistedAssociation, - }, - ]); + await this.updatePersistedAssociations([{ scriptPath, expectedEnvironmentPath }]); if ( - normalizePath(persistedPathBeforeUpdate ?? '') === normalizePath(expectedEnvironmentPath) && - !this.fsPathToPersistedAssociation.has(scriptPath) && + normalizePath(this.fsPathToPersistedEnvPath.get(scriptPath) ?? '') === + normalizePath(expectedEnvironmentPath) && this.isCurrentAssociationRevision(scriptPath, revision) ) { const old = this.fsPathToEnv.get(scriptPath); this.bumpAssociationRevision(scriptPath); this.fsPathToEnv.delete(scriptPath); - this.fsPathToPersistedAssociation.delete(scriptPath); - this.clearValidatedRouteableState(scriptPath); + this.fsPathToPersistedEnvPath.delete(scriptPath); + this.cachedAssociationValidatedAt.delete(scriptPath); if (old && scriptUri) { this._onDidChangeEnvironment.fire({ uri: scriptUri, old, new: undefined }); } @@ -1405,218 +704,54 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { private removeInvalidPersistedAssociation(scriptPath: string): Promise { return this.enqueuePersistence(async (state) => { - const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); - if (rawAssociations === undefined) { - this.applyPersistedAssociations({}); + const raw = await state.get(INLINE_SCRIPT_ENVS_KEY); + if (raw === undefined) { return; } - const parsed = this.parsePersistedAssociations(rawAssociations); - if (!parsed) { + const associations = this.asPersistedAssociations(raw); + if (!associations) { await state.set(INLINE_SCRIPT_ENVS_KEY, {}); - this.applyPersistedAssociations({}); return; } - if (parsed.invalidKeys.has(scriptPath)) { - delete parsed.rawEntries[scriptPath]; - delete parsed.records[scriptPath]; - parsed.invalidKeys.delete(scriptPath); - await state.set(INLINE_SCRIPT_ENVS_KEY, parsed.rawEntries); + const rawValue = (raw as Record)[scriptPath]; + if (rawValue !== undefined && (typeof rawValue !== 'string' || rawValue.length === 0)) { + delete associations[scriptPath]; + await state.set(INLINE_SCRIPT_ENVS_KEY, associations); } - this.applyPersistedAssociations(parsed.records); }); } private updatePersistedAssociations(changes: readonly PersistedAssociationChange[]): Promise { return this.enqueuePersistence(async (state) => { - const rawAssociations = await state.get(INLINE_SCRIPT_ENVS_KEY); - const parsed = this.parsePersistedAssociations(rawAssociations); - const rawEntries = { ...(parsed?.rawEntries ?? {}) }; - const associations = { ...(parsed?.records ?? {}) }; + const raw = await state.get(INLINE_SCRIPT_ENVS_KEY); + const associations = { ...(this.asPersistedAssociations(raw) ?? {}) }; for (const change of changes) { const current = associations[change.scriptPath]; - if (change.persistedAssociation) { - if ( - change.expectedPersistedAssociation && - !this.isSamePersistedAssociation(current, change.expectedPersistedAssociation) - ) { - continue; - } - associations[change.scriptPath] = change.persistedAssociation; - rawEntries[change.scriptPath] = this.serializePersistedAssociation(change.persistedAssociation); + if (change.environmentPath) { + associations[change.scriptPath] = change.environmentPath; } else if ( - (change.expectedPersistedAssociation && - this.isSamePersistedAssociation(current, change.expectedPersistedAssociation)) || - (change.expectedPersistedAssociation === undefined && - (change.expectedEnvironmentPath === undefined || - (current !== undefined && - normalizePath(current.environmentPath) === normalizePath(change.expectedEnvironmentPath)))) + change.expectedEnvironmentPath === undefined || + (current !== undefined && + normalizePath(current) === normalizePath(change.expectedEnvironmentPath)) ) { delete associations[change.scriptPath]; - delete rawEntries[change.scriptPath]; } } - await state.set(INLINE_SCRIPT_ENVS_KEY, rawEntries); - this.applyPersistedAssociations(associations); + await state.set(INLINE_SCRIPT_ENVS_KEY, associations); }); } - private parsePersistedAssociations(value: unknown): ParsedPersistedAssociations | undefined { - if (value === undefined) { - return { - rawEntries: {}, - records: {}, - invalidKeys: new Set(), - }; - } + private asPersistedAssociations(value: unknown): PersistedInlineScriptEnvironments | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) { return undefined; } - const rawEntries = { ...(value as Record) }; - const records: PersistedInlineScriptEnvironments = {}; - const invalidKeys = new Set(); - for (const [scriptPath, association] of Object.entries(rawEntries)) { - const parsed = this.parsePersistedAssociationValue(association); - if (parsed.kind === 'valid') { - records[scriptPath] = parsed.record; - } else if (parsed.kind === 'invalid') { - invalidKeys.add(scriptPath); - } - } - return { rawEntries, records, invalidKeys }; - } - - private getPersistedAssociationFromMemory(scriptPath: string): PersistedAssociationRecord | undefined { - return this.fsPathToPersistedAssociation.get(scriptPath); - } - - private createPersistedAssociationRecord( - environmentPath: string, - sourceMetadataIdentity: string | undefined, - currentMetadataIdentity: string | undefined, - ): PersistedAssociationRecord { - if (!sourceMetadataIdentity) { - return { - environmentPath, - metadataBinding: { kind: 'legacy' }, - }; - } - return { - environmentPath, - metadataBinding: - currentMetadataIdentity === sourceMetadataIdentity - ? { kind: 'matched', sourceIdentity: sourceMetadataIdentity } - : { kind: 'pending', sourceIdentity: sourceMetadataIdentity }, - }; - } - - private isSamePersistedAssociation( - first: PersistedAssociationRecord | undefined, - second: PersistedAssociationRecord | undefined, - ): boolean { - if (first === second) { - return true; - } - if (!first || !second) { - return false; - } - if (normalizePath(first.environmentPath) !== normalizePath(second.environmentPath)) { - return false; - } - if (first.metadataBinding.kind !== second.metadataBinding.kind) { - return false; - } - if (first.metadataBinding.kind === 'matched' && second.metadataBinding.kind === 'matched') { - return first.metadataBinding.sourceIdentity === second.metadataBinding.sourceIdentity; - } - if (first.metadataBinding.kind === 'pending' && second.metadataBinding.kind === 'pending') { - return first.metadataBinding.sourceIdentity === second.metadataBinding.sourceIdentity; - } - return true; - } - - private parsePersistedAssociationValue(value: unknown): - | { readonly kind: 'valid'; readonly record: PersistedAssociationRecord } - | { readonly kind: 'future' } - | { readonly kind: 'invalid' } { - if (typeof value === 'string' && value.length > 0) { - return { - kind: 'valid', - record: { - environmentPath: value, - metadataBinding: { kind: 'legacy' }, - }, - }; - } - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return { kind: 'invalid' }; - } - const association = value as Record; - const schemaVersion = association.schemaVersion; - if (typeof schemaVersion !== 'number') { - return { kind: 'invalid' }; - } - if (schemaVersion !== PERSISTED_ASSOCIATION_SCHEMA_VERSION) { - return { kind: 'future' }; - } - const environmentPath = association.environmentPath; - const metadataBinding = association.metadataBinding; - if (typeof environmentPath !== 'string' || environmentPath.length === 0) { - return { kind: 'invalid' }; - } - if (!metadataBinding || typeof metadataBinding !== 'object' || Array.isArray(metadataBinding)) { - return { kind: 'invalid' }; - } - const binding = metadataBinding as Record; - if (binding.kind === 'pending') { - if (typeof binding.sourceIdentity === 'string' && binding.sourceIdentity.trim().length > 0) { - return { - kind: 'valid', - record: { - environmentPath, - metadataBinding: { kind: 'pending', sourceIdentity: binding.sourceIdentity }, - }, - }; + const associations: PersistedInlineScriptEnvironments = {}; + for (const [scriptPath, environmentPath] of Object.entries(value)) { + if (typeof environmentPath === 'string' && environmentPath.length > 0) { + associations[scriptPath] = environmentPath; } - return { kind: 'invalid' }; } - if (binding.kind === 'legacy') { - return { - kind: 'valid', - record: { environmentPath, metadataBinding: { kind: 'legacy' } }, - }; - } - if ( - binding.kind === 'matched' && - typeof binding.sourceIdentity === 'string' && - binding.sourceIdentity.trim().length > 0 - ) { - return { - kind: 'valid', - record: { - environmentPath, - metadataBinding: { - kind: 'matched', - sourceIdentity: binding.sourceIdentity, - }, - }, - }; - } - return { kind: 'invalid' }; - } - - private serializePersistedAssociation( - association: PersistedAssociationRecord, - ): PersistedInlineScriptAssociationValue { - return { - schemaVersion: PERSISTED_ASSOCIATION_SCHEMA_VERSION, - environmentPath: association.environmentPath, - metadataBinding: - association.metadataBinding.kind === 'matched' - ? { kind: 'matched', sourceIdentity: association.metadataBinding.sourceIdentity } - : association.metadataBinding.kind === 'pending' - ? { kind: 'pending', sourceIdentity: association.metadataBinding.sourceIdentity } - : { kind: association.metadataBinding.kind }, - }; + return associations; } private enqueuePersistence(operation: (state: PersistentState) => Promise): Promise { @@ -1634,37 +769,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return run; } - private clearAssociationsForScripts(scripts: readonly Uri[]): Promise { - return this.enqueueSelection(async () => { - const changes = scripts - .filter((uri) => uri.scheme === 'file') - .map((uri) => ({ - uri, - scriptPath: normalizePath(uri.fsPath), - })) - .filter((script, index, all) => all.findIndex((candidate) => candidate.scriptPath === script.scriptPath) === index) - .filter( - (script) => - this.fsPathToEnv.has(script.scriptPath) || - this.fsPathToPersistedAssociation.has(script.scriptPath), - ); - - if (changes.length === 0) { - return; - } - - await this.updatePersistedAssociations(changes.map(({ scriptPath }) => ({ scriptPath }))); - for (const change of changes) { - this.bumpAssociationRevision(change.scriptPath); - this.pendingRehydrations.delete(change.scriptPath); - this.pendingMetadataRefreshes.delete(change.scriptPath); - this.fsPathToEnv.delete(change.scriptPath); - this.fsPathToPersistedAssociation.delete(change.scriptPath); - this.clearValidatedRouteableState(change.uri); - } - }); - } - private async isCacheEntryBusy(envDirPath: string): Promise { return ( this.pendingCreations.has(path.basename(envDirPath)) || @@ -1692,8 +796,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } return ( first.envId.managerId === second.envId.managerId && - normalizePath(first.environmentPath.fsPath) === normalizePath(second.environmentPath.fsPath) && - first.version === second.version + normalizePath(first.environmentPath.fsPath) === normalizePath(second.environmentPath.fsPath) ); } @@ -1949,128 +1052,61 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return installedPath; } - private async withCacheEntryLock( - envDir: Uri, - action: (lock: AcquiredFileLock) => Promise, - ): Promise { - const lock = await acquireFileLock(envDir.fsPath, { - timeoutMs: CACHE_LOCK_TIMEOUT_MS, - retryIntervalMs: CACHE_LOCK_RETRY_MS, - }); - try { - return await action(lock); - } finally { - try { - await lock.release(); - } catch (error) { - this.log.warn(`Failed to release inline-script cache lock: ${getErrorMessage(error)}`); - } - } - } - - private mergePendingCreationSourceMetadataIdentityHashes( - existing: readonly string[] | undefined, - pendingCreation: PendingCreationContext, - ): readonly string[] | undefined { - let merged = existing; - for (const sourceMetadataIdentityHash of pendingCreation.sourceMetadataIdentityHashes ?? []) { - merged = mergeSourceMetadataIdentityHashes(merged, sourceMetadataIdentityHash); - } - return merged; - } - - private async mergeCacheEntrySourceMetadataIdentityHash( - cacheKey: string, - sourceMetadataIdentityHash: string, - ): Promise { - const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); - try { - return await this.withCacheEntryLock(envDir, async () => { - const sidecarResult = await inspectMetaJson(envDir); - if (sidecarResult.kind !== 'valid') { - return { success: false }; - } - if (sidecarResult.metadata.sourceMetadataIdentityHashes?.includes(sourceMetadataIdentityHash)) { - return { - success: true, - sourceMetadataIdentityHashes: sidecarResult.metadata.sourceMetadataIdentityHashes, - }; - } - const sourceMetadataIdentityHashes = mergeSourceMetadataIdentityHashes( - sidecarResult.metadata.sourceMetadataIdentityHashes, - sourceMetadataIdentityHash, - ); - await writeMetaJson(envDir, { - ...sidecarResult.metadata, - ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), - }); - return { - success: true, - sourceMetadataIdentityHashes, - }; - }); - } catch (error) { - this.log.warn(`Failed to update inline-script cache provenance: ${getErrorMessage(error)}`); - return { success: false }; - } - } - private async createOrReuseEnvironment({ cacheKey, packages, metadata, selectedBase, - pendingCreation, }: CreateOrReuseEnvironmentOptions): Promise { const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri); const envDir = getScriptEnvDir(this.globalStorageUri, cacheKey); await fs.ensureDir(cacheRoot.fsPath); + let lock: AcquiredFileLock | undefined; try { - return await this.withCacheEntryLock(envDir, async (lock) => { - const cached = await this.inspectCacheEntry( - cacheRoot, - envDir, - metadata, - selectedBase, - pendingCreation, + 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}`, ); - 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; } - if (cached.kind === 'stale') { - if (!(await this.removeCacheEntry(envDir))) { - return undefined; - } - } + } - const build = await this.buildCacheEntry( - envDir, - cacheRoot, - packages, - selectedBase, - pendingCreation, - ); - if (build.retainLock) { - try { - await lock.retain(); - } catch (error) { - this.log.error( - `Failed to mark the inline-script cache lock as retained: ${getErrorMessage(error)}`, - ); - } + 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: ${getErrorMessage(error)}`, + ); } - return build.environment; - }); + } + return build.environment; } catch (error) { this.log.error(`Failed to create or reuse inline-script cache entry: ${getErrorMessage(error)}`); return undefined; + } finally { + if (lock) { + try { + await lock.release(); + } catch (error) { + this.log.warn(`Failed to release inline-script cache lock: ${getErrorMessage(error)}`); + } + } } } @@ -2079,7 +1115,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { envDir: Uri, metadata: InlineScriptMetadata, selectedBase: SelectedBaseInterpreter, - pendingCreation: PendingCreationContext, ): Promise { try { const stat = await fs.lstat(envDir.fsPath); @@ -2108,12 +1143,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { return { kind: 'uncertain' }; } if (sidecarResult.kind !== 'valid') { - return { - kind: - sidecarResult.kind === 'unavailable' || sidecarResult.kind === 'unsupported' - ? 'uncertain' - : 'stale', - }; + return { kind: sidecarResult.kind === 'unavailable' ? 'uncertain' : 'stale' }; } const sidecar = sidecarResult.metadata; if ( @@ -2149,18 +1179,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { if (requiresPython && !this.matchesInstallConstraint(requiresPython, environment.version)) { return { kind: 'stale' }; } + try { - pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true; - const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes( - sidecar.sourceMetadataIdentityHashes, - pendingCreation, - ); - await writeMetaJson(envDir, { - ...sidecar, - lastUsedAt: new Date().toISOString(), - ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), - }); - pendingCreation.recordedSourceMetadataIdentityHashes = sourceMetadataIdentityHashes; + await writeMetaJson(envDir, { ...sidecar, lastUsedAt: new Date().toISOString() }); } catch (error) { this.log.warn(`Failed to update inline-script cache metadata: ${getErrorMessage(error)}`); } @@ -2172,7 +1193,6 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { cacheRoot: Uri, packages: ReadonlyArray, selectedBase: SelectedBaseInterpreter, - pendingCreation: PendingCreationContext, ): Promise { let result; try { @@ -2214,20 +1234,14 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { await this.removeCacheEntry(envDir); return {}; } + try { - pendingCreation.hasStartedRecordingSourceMetadataIdentityHashes = true; - const sourceMetadataIdentityHashes = this.mergePendingCreationSourceMetadataIdentityHashes( - undefined, - pendingCreation, - ); await writeMetaJson(envDir, { schemaVersion: META_SCHEMA_VERSION, baseInterpreterPath: selectedBase.canonicalPath, baseInterpreterVersion: selectedBase.environment.version, lastUsedAt: new Date().toISOString(), - ...(sourceMetadataIdentityHashes ? { sourceMetadataIdentityHashes } : {}), }); - pendingCreation.recordedSourceMetadataIdentityHashes = sourceMetadataIdentityHashes; } catch (error) { this.log.error(`Failed to record inline-script cache metadata: ${getErrorMessage(error)}`); await this.removeCacheEntry(envDir); @@ -2269,49 +1283,16 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable { } dispose(): void { - this.pendingMetadataRefreshes.clear(); - this.subscriptions.forEach((subscription) => subscription.dispose()); this._onDidChangeEnvironments.dispose(); this._onDidChangeEnvironment.dispose(); } - - private applyPersistedAssociations(associations: PersistedInlineScriptEnvironments): void { - const nextPaths = new Set(Object.keys(associations)); - for (const scriptPath of this.fsPathToPersistedAssociation.keys()) { - if (!nextPaths.has(scriptPath)) { - this.fsPathToPersistedAssociation.delete(scriptPath); - this.clearValidatedRouteableState(scriptPath); - } - } - for (const [scriptPath, association] of Object.entries(associations)) { - this.fsPathToPersistedAssociation.set(scriptPath, association); - } - } -} - -type PersistedInlineScriptEnvironments = Record; -type PersistedInlineScriptAssociationValue = string | PersistedInlineScriptAssociationObject; - -type PersistedMetadataBinding = - | { readonly kind: 'legacy' } - | { readonly kind: 'pending'; readonly sourceIdentity: string } - | { readonly kind: 'matched'; readonly sourceIdentity: string }; - -interface PersistedInlineScriptAssociationObject { - readonly schemaVersion: typeof PERSISTED_ASSOCIATION_SCHEMA_VERSION; - readonly environmentPath: string; - readonly metadataBinding: PersistedMetadataBinding; } -interface PersistedAssociationRecord { - readonly environmentPath: string; - readonly metadataBinding: PersistedMetadataBinding; -} +type PersistedInlineScriptEnvironments = Record; interface PersistedAssociationChange { readonly scriptPath: string; - readonly persistedAssociation?: PersistedAssociationRecord; - readonly expectedPersistedAssociation?: PersistedAssociationRecord; + readonly environmentPath?: string; readonly expectedEnvironmentPath?: string; } @@ -2322,7 +1303,6 @@ interface ScriptReference { interface PendingScriptUpdate extends ScriptReference { readonly before: PythonEnvironment | undefined; - readonly persistedAssociation?: PersistedAssociationRecord; readonly needsPersistence: boolean; readonly shouldNotify: boolean; } diff --git a/src/managers/builtin/inlineScript/main.ts b/src/managers/builtin/inlineScript/main.ts index 8c8e7ee35..8c35fc6ed 100644 --- a/src/managers/builtin/inlineScript/main.ts +++ b/src/managers/builtin/inlineScript/main.ts @@ -3,7 +3,6 @@ import { Disposable, LogOutputChannel, Uri } from 'vscode'; import { EnvironmentManager, PythonEnvironmentApi } from '../../../api'; -import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; import { traceInfo, traceVerbose } from '../../../common/logging'; import { getPythonApi } from '../../../features/pythonApi'; import { isInlineScriptsFeatureEnabled } from '../../../helpers'; @@ -21,7 +20,6 @@ export async function registerInlineScriptFeatures( log: LogOutputChannel, baseManager: EnvironmentManager, globalStorageUri: Uri, - routingRegistry: InlineScriptRoutingRegistry, ): Promise { if (!isInlineScriptsFeatureEnabled()) { traceVerbose('Inline-script env manager: skipping registration (internal flag is off)'); @@ -29,7 +27,7 @@ export async function registerInlineScriptFeatures( } const api: PythonEnvironmentApi = await getPythonApi(); - const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log, routingRegistry); + 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/test/common/inlineScript/cacheLayout.unit.test.ts b/src/test/common/inlineScript/cacheLayout.unit.test.ts index 21951ba8d..d57be848b 100644 --- a/src/test/common/inlineScript/cacheLayout.unit.test.ts +++ b/src/test/common/inlineScript/cacheLayout.unit.test.ts @@ -11,20 +11,16 @@ import { Uri } from 'vscode'; import { PythonEnvironment } from '../../../api'; import { CacheEntrySummary, - MAX_SOURCE_METADATA_IDENTITY_HASHES, - SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH, INLINE_SCRIPT_CACHE_DIR_NAME, InlineScriptEnvMeta, META_JSON_FILENAME, META_SCHEMA_VERSION, getBaseInterpreterStatus, - hashSourceMetadataIdentity, getMetaJsonPath, getScriptEnvCacheRoot, getScriptEnvDir, inspectOwnedCacheEntry, inspectMetaJson, - mergeSourceMetadataIdentityHashes, readMetaJson, resolveCacheEntryPath, selectStaleEntries, @@ -93,9 +89,7 @@ suite('inlineScriptCacheLayout', () => { }); test('writeMetaJson then readMetaJson returns the same object', async () => { - const meta = makeMeta({ - sourceMetadataIdentityHashes: [hashSourceMetadataIdentity('{"requiresPython":">=3.11","dependencies":["requests"]}')], - }); + const meta = makeMeta(); await writeMetaJson(envDir, meta); const read = await readMetaJson(envDir); assert.deepStrictEqual(read, meta); @@ -196,11 +190,6 @@ suite('inlineScriptCacheLayout', () => { assert.deepStrictEqual(await inspectMetaJson(envDir), { kind: 'valid', metadata }); }); - test('classifies a newer schema as unsupported without treating it as malformed', async () => { - await writeRaw(JSON.stringify({ ...makeMeta(), schemaVersion: 99 })); - assert.deepStrictEqual(await inspectMetaJson(envDir), { kind: 'unsupported' }); - }); - test('classifies non-ENOENT sidecar stat failures as unavailable', async () => { sinon.stub(fsExtra, 'lstat').rejects(Object.assign(new Error('permission denied'), { code: 'EACCES' })); assert.deepStrictEqual(await inspectMetaJson(envDir), { kind: 'unavailable' }); @@ -244,10 +233,11 @@ suite('inlineScriptCacheLayout', () => { ); }); - test('classifies a newer schemaVersion as unsupported', async () => { + test('returns undefined for an unknown schemaVersion', async () => { await writeRaw(JSON.stringify({ ...makeMeta(), schemaVersion: 99 })); - const result = await inspectMetaJson(envDir); - assert.deepStrictEqual(result, { kind: 'unsupported' }); + const result = await readMetaJson(envDir); + assert.strictEqual(result, undefined); + assert.ok(traceWarnStub.called); }); test('returns undefined when baseInterpreterPath is missing', async () => { @@ -283,31 +273,6 @@ suite('inlineScriptCacheLayout', () => { assert.strictEqual(await readMetaJson(envDir), undefined); }); - test('returns undefined for malformed sourceMetadataIdentityHashes', async () => { - await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: 'not-an-array' })); - assert.strictEqual(await readMetaJson(envDir), undefined); - await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: [] })); - assert.strictEqual(await readMetaJson(envDir), undefined); - await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: ['bad-hash'] })); - assert.strictEqual(await readMetaJson(envDir), undefined); - }); - - test('returns undefined for duplicate or oversized sourceMetadataIdentityHashes', async () => { - const hash = hashSourceMetadataIdentity('same'); - await writeRaw(JSON.stringify({ ...makeMeta(), sourceMetadataIdentityHashes: [hash, hash] })); - assert.strictEqual(await readMetaJson(envDir), undefined); - await writeRaw( - JSON.stringify({ - ...makeMeta(), - sourceMetadataIdentityHashes: Array.from( - { length: MAX_SOURCE_METADATA_IDENTITY_HASHES + 1 }, - (_, index) => hashSourceMetadataIdentity(`id-${index}`), - ), - }), - ); - assert.strictEqual(await readMetaJson(envDir), undefined); - }); - test('returns undefined when lastUsedAt is not parseable', async () => { await writeRaw(JSON.stringify({ ...makeMeta(), lastUsedAt: 'not-a-date' })); const result = await readMetaJson(envDir); @@ -369,13 +334,6 @@ suite('inlineScriptCacheLayout', () => { assert.strictEqual('_internal' in result, false); }); - test('old sidecars without sourceMetadataIdentityHashes remain valid', async () => { - const result = await inspectMetaJson(envDir); - assert.deepStrictEqual(result, { kind: 'missing' }); - await writeRaw(JSON.stringify(makeMeta())); - assert.ok(await readMetaJson(envDir)); - }); - test('returns undefined when the sidecar path is a directory rather than a file', async () => { await fs.remove(getMetaJsonPath(envDir).fsPath).catch(() => undefined); await fs.ensureDir(getMetaJsonPath(envDir).fsPath); @@ -383,24 +341,6 @@ suite('inlineScriptCacheLayout', () => { assert.ok(traceWarnStub.called); }); - suite('source metadata hash helpers', () => { - test('hashSourceMetadataIdentity returns fixed-size lowercase hex', () => { - const hash = hashSourceMetadataIdentity('metadata-identity'); - assert.strictEqual(hash.length, SOURCE_METADATA_IDENTITY_HASH_HEX_LENGTH); - assert.ok(/^[0-9a-f]+$/.test(hash)); - }); - - test('mergeSourceMetadataIdentityHashes dedupes and caps the newest hashes', () => { - const hashes = Array.from({ length: MAX_SOURCE_METADATA_IDENTITY_HASHES }, (_, index) => - hashSourceMetadataIdentity(`id-${index}`), - ); - const merged = mergeSourceMetadataIdentityHashes(hashes, hashSourceMetadataIdentity('latest')); - assert.ok(merged); - assert.strictEqual(merged.length, MAX_SOURCE_METADATA_IDENTITY_HASHES); - assert.strictEqual(merged[merged.length - 1], hashSourceMetadataIdentity('latest')); - }); - }); - test('returns undefined when the sidecar exceeds the size cap (1 MiB)', async () => { const big = Buffer.alloc(1024 * 1024 + 1, 0x20); await fs.writeFile(getMetaJsonPath(envDir).fsPath, big); diff --git a/src/test/features/envCommands.unit.test.ts b/src/test/features/envCommands.unit.test.ts index 079ca9d73..48f49f92d 100644 --- a/src/test/features/envCommands.unit.test.ts +++ b/src/test/features/envCommands.unit.test.ts @@ -1,16 +1,41 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import * as typeMoq from 'typemoq'; -import { Uri } from 'vscode'; +import { Disposable, EventEmitter, Uri } from 'vscode'; import { PythonEnvironment, PythonProject } from '../../api'; import * as commandApi from '../../common/command.api'; +import { INLINE_SCRIPT_MANAGER_ID } from '../../common/constants'; +import * as inlineScriptMetadata from '../../common/inlineScript/metadata'; +import * as extensionApis from '../../common/extension.apis'; import * as managerApi from '../../common/pickers/managers'; import * as projectApi from '../../common/pickers/projects'; -import { createAnyEnvironmentCommand, removePythonProject, revealEnvInManagerView } from '../../features/envCommands'; +import * as logging from '../../common/logging'; +import * as telemetrySender from '../../common/telemetry/sender'; +import * as platformUtils from '../../common/utils/platformUtils'; +import * as windowApis from '../../common/window.apis'; +import * as workspaceApis from '../../common/workspace.apis'; +import * as helpers from '../../helpers'; +import { + _resetManagerReadyForTesting, + createManagerReady, + MANAGER_READY_TIMEOUT_MS, +} from '../../features/common/managerReady'; +import * as managerReady from '../../features/common/managerReady'; +import { + createAnyEnvironmentCommand, + removePythonProject, + revealEnvInManagerView, + setupInlineScriptEnvironmentCommand, +} from '../../features/envCommands'; import * as settingHelpers from '../../features/settings/settingHelpers'; import { EnvManagerView } from '../../features/views/envManagersView'; import { ProjectEnvironment, ProjectItem } from '../../features/views/treeViewItems'; -import { EnvironmentManagers, InternalEnvironmentManager, PythonProjectManager } from '../../internal.api'; +import { + DidChangeEnvironmentManagerEventArgs, + EnvironmentManagers, + InternalEnvironmentManager, + PythonProjectManager, +} from '../../internal.api'; import { setupNonThenable } from '../mocks/helper'; suite('Create Any Environment Command Tests', () => { @@ -261,3 +286,379 @@ suite('Reveal Env In Manager View Command Tests', () => { managerView.verify((m) => m.reveal(environment), typeMoq.Times.once()); }); }); + +suite('Setup Inline Script Environment Command Tests', () => { + let createStub: sinon.SinonStub; + let getEnvironmentManagerStub: sinon.SinonStub; + let setEnvironmentStub: sinon.SinonStub; + let activeTextEditorStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let getOpenTextDocumentsStub: sinon.SinonStub; + let readInlineScriptMetadataStub: sinon.SinonStub; + let isInlineScriptsFeatureEnabledStub: sinon.SinonStub; + let waitForEnvManagerIdStub: sinon.SinonStub; + + function createEnvironment(): PythonEnvironment { + return { + envId: { id: 'inline-env', managerId: INLINE_SCRIPT_MANAGER_ID }, + name: 'inline-env', + displayName: 'Inline Environment', + displayPath: '/path/to/inline-env', + version: '3.12.0', + environmentPath: Uri.file('/path/to/inline-env'), + execInfo: { run: { executable: '/path/to/inline-env/python' } }, + sysPrefix: '/path/to/inline-env', + }; + } + + function createDocument(uri: Uri, isDirty = false) { + return { uri, isDirty } as { uri: Uri; isDirty: boolean }; + } + + function createManagers(): EnvironmentManagers { + return { + getEnvironmentManager: getEnvironmentManagerStub, + setEnvironment: setEnvironmentStub, + } as unknown as EnvironmentManagers; + } + + function registerInlineManager(): void { + getEnvironmentManagerStub.withArgs(INLINE_SCRIPT_MANAGER_ID).returns({ + create: createStub, + } as unknown as InternalEnvironmentManager); + } + + setup(() => { + createStub = sinon.stub(); + getEnvironmentManagerStub = sinon.stub(); + setEnvironmentStub = sinon.stub().resolves(); + activeTextEditorStub = sinon.stub(windowApis, 'activeTextEditor').returns(undefined); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + getOpenTextDocumentsStub = sinon.stub(workspaceApis, 'getOpenTextDocuments').returns([]); + readInlineScriptMetadataStub = sinon + .stub(inlineScriptMetadata, 'readInlineScriptMetadataFromFile') + .resolves({ range: { start: 0, end: 0 } }); + isInlineScriptsFeatureEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled').returns(true); + waitForEnvManagerIdStub = sinon.stub(managerReady, 'waitForEnvManagerId').resolves(); + }); + + teardown(() => { + sinon.restore(); + }); + + test('uses the supplied uri and sets the created environment without persisting settings', async () => { + const uri = Uri.file('/workspace/script.py'); + const activeUri = Uri.file('/workspace/other.py'); + activeTextEditorStub.returns({ document: createDocument(activeUri) }); + getOpenTextDocumentsStub.returns([createDocument(uri)]); + const environment = createEnvironment(); + registerInlineManager(); + createStub.resolves(environment); + + const result = await setupInlineScriptEnvironmentCommand(uri, createManagers()); + + assert.strictEqual(result, environment); + sinon.assert.calledOnceWithExactly(readInlineScriptMetadataStub, uri); + sinon.assert.calledOnceWithExactly(waitForEnvManagerIdStub, [INLINE_SCRIPT_MANAGER_ID]); + sinon.assert.calledOnceWithExactly(createStub, uri, undefined); + sinon.assert.calledOnceWithExactly(setEnvironmentStub, uri, environment, false); + sinon.assert.notCalled(showErrorMessageStub); + }); + + test('falls back to the active editor when no uri is supplied', async () => { + const uri = Uri.file('/workspace/active.py'); + activeTextEditorStub.returns({ document: createDocument(uri) }); + const environment = createEnvironment(); + registerInlineManager(); + createStub.resolves(environment); + + const result = await setupInlineScriptEnvironmentCommand(undefined, createManagers()); + + assert.strictEqual(result, environment); + sinon.assert.calledOnceWithExactly(readInlineScriptMetadataStub, uri); + sinon.assert.calledOnceWithExactly(waitForEnvManagerIdStub, [INLINE_SCRIPT_MANAGER_ID]); + sinon.assert.calledOnceWithExactly(createStub, uri, undefined); + sinon.assert.calledOnceWithExactly(setEnvironmentStub, uri, environment, false); + }); + + test('shows an error when no active editor is available', async () => { + await setupInlineScriptEnvironmentCommand(undefined, createManagers()); + + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /Open or select a saved local \.py file/); + sinon.assert.notCalled(readInlineScriptMetadataStub); + sinon.assert.notCalled(waitForEnvManagerIdStub); + sinon.assert.notCalled(createStub); + }); + + test('rejects non-file uris', async () => { + await setupInlineScriptEnvironmentCommand(Uri.parse('untitled:script.py'), createManagers()); + + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /requires a local \.py file/); + sinon.assert.notCalled(readInlineScriptMetadataStub); + sinon.assert.notCalled(waitForEnvManagerIdStub); + sinon.assert.notCalled(createStub); + }); + + test('rejects non-python files', async () => { + await setupInlineScriptEnvironmentCommand(Uri.file('/workspace/script.txt'), createManagers()); + + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /requires a local \.py file/); + sinon.assert.notCalled(readInlineScriptMetadataStub); + sinon.assert.notCalled(waitForEnvManagerIdStub); + sinon.assert.notCalled(createStub); + }); + + test('requires dirty documents to be saved first', async () => { + const uri = Uri.file('/workspace/script.py'); + getOpenTextDocumentsStub.returns([createDocument(uri, true)]); + + await setupInlineScriptEnvironmentCommand(uri, createManagers()); + + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /Save the file before setting up/); + sinon.assert.notCalled(readInlineScriptMetadataStub); + sinon.assert.notCalled(waitForEnvManagerIdStub); + sinon.assert.notCalled(createStub); + }); + + test('matches equivalent Windows file uris when checking for a dirty open document', async () => { + const uri = Uri.file('/workspace/package/script.py'); + const equivalentOpenUri = { + scheme: 'file', + fsPath: '\\WORKSPACE\\package\\script.py', + toString: () => 'file:///WORKSPACE%5Cpackage%5Cscript.py', + } as unknown as Uri; + sinon.stub(platformUtils, 'isWindows').returns(true); + getOpenTextDocumentsStub.returns([createDocument(equivalentOpenUri, true)]); + + await setupInlineScriptEnvironmentCommand(uri, createManagers()); + + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /Save the file before setting up/); + sinon.assert.notCalled(readInlineScriptMetadataStub); + sinon.assert.notCalled(waitForEnvManagerIdStub); + sinon.assert.notCalled(createStub); + }); + + test('requires valid saved PEP 723 metadata', async () => { + const uri = Uri.file('/workspace/script.py'); + readInlineScriptMetadataStub.resolves(undefined); + + await setupInlineScriptEnvironmentCommand(uri, createManagers()); + + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /valid PEP 723 inline script metadata/); + sinon.assert.calledOnceWithExactly(readInlineScriptMetadataStub, uri); + sinon.assert.notCalled(waitForEnvManagerIdStub); + sinon.assert.notCalled(createStub); + }); + + test('shows the feature-disabled error before dirty, metadata, or readiness checks', async () => { + const uri = Uri.file('/workspace/script.py'); + getOpenTextDocumentsStub.returns([createDocument(uri, true)]); + isInlineScriptsFeatureEnabledStub.returns(false); + + await setupInlineScriptEnvironmentCommand(uri, createManagers()); + + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /python-envs\.inlineScripts\.enabled/); + sinon.assert.notCalled(readInlineScriptMetadataStub); + sinon.assert.notCalled(waitForEnvManagerIdStub); + sinon.assert.notCalled(createStub); + sinon.assert.notCalled(setEnvironmentStub); + }); + + test('leaves the existing association unchanged when setup is cancelled', async () => { + const uri = Uri.file('/workspace/script.py'); + registerInlineManager(); + createStub.resolves(undefined); + + const result = await setupInlineScriptEnvironmentCommand(uri, createManagers()); + + assert.strictEqual(result, undefined); + sinon.assert.calledOnceWithExactly(waitForEnvManagerIdStub, [INLINE_SCRIPT_MANAGER_ID]); + sinon.assert.calledOnceWithExactly(createStub, uri, undefined); + sinon.assert.notCalled(setEnvironmentStub); + sinon.assert.notCalled(showErrorMessageStub); + }); + + test('propagates create errors', async () => { + const uri = Uri.file('/workspace/script.py'); + registerInlineManager(); + createStub.rejects(new Error('create failed')); + + await assert.rejects(setupInlineScriptEnvironmentCommand(uri, createManagers()), /create failed/); + + sinon.assert.calledOnceWithExactly(waitForEnvManagerIdStub, [INLINE_SCRIPT_MANAGER_ID]); + sinon.assert.calledOnceWithExactly(createStub, uri, undefined); + sinon.assert.notCalled(setEnvironmentStub); + }); + + test('propagates set errors', async () => { + const uri = Uri.file('/workspace/script.py'); + const environment = createEnvironment(); + registerInlineManager(); + createStub.resolves(environment); + setEnvironmentStub.rejects(new Error('set failed')); + + await assert.rejects(setupInlineScriptEnvironmentCommand(uri, createManagers()), /set failed/); + + sinon.assert.calledOnceWithExactly(waitForEnvManagerIdStub, [INLINE_SCRIPT_MANAGER_ID]); + sinon.assert.calledOnceWithExactly(createStub, uri, undefined); + sinon.assert.calledOnceWithExactly(setEnvironmentStub, uri, environment, false); + }); + + test('re-runs setup on repeated invocation so metadata changes can affect the cache key', async () => { + const uri = Uri.file('/workspace/script.py'); + const environment = createEnvironment(); + registerInlineManager(); + createStub.resolves(environment); + + await setupInlineScriptEnvironmentCommand(uri, createManagers()); + await setupInlineScriptEnvironmentCommand(uri, createManagers()); + + sinon.assert.calledTwice(readInlineScriptMetadataStub); + sinon.assert.calledTwice(waitForEnvManagerIdStub); + sinon.assert.calledTwice(createStub); + sinon.assert.calledTwice(setEnvironmentStub); + assert.deepStrictEqual(createStub.firstCall.args, [uri, undefined]); + assert.deepStrictEqual(createStub.secondCall.args, [uri, undefined]); + assert.deepStrictEqual(setEnvironmentStub.firstCall.args, [uri, environment, false]); + assert.deepStrictEqual(setEnvironmentStub.secondCall.args, [uri, environment, false]); + }); +}); + +suite('Setup Inline Script Environment Command Manager Readiness Tests', () => { + let clock: sinon.SinonFakeTimers; + let envManagerEmitter: EventEmitter; + let disposables: Disposable[]; + let createStub: sinon.SinonStub; + let getEnvironmentManagerStub: sinon.SinonStub; + let setEnvironmentStub: sinon.SinonStub; + let showErrorMessageStub: sinon.SinonStub; + let readInlineScriptMetadataStub: sinon.SinonStub; + let managerAvailable: boolean; + + function createEnvironment(): PythonEnvironment { + return { + envId: { id: 'inline-env', managerId: INLINE_SCRIPT_MANAGER_ID }, + name: 'inline-env', + displayName: 'Inline Environment', + displayPath: '/path/to/inline-env', + version: '3.12.0', + environmentPath: Uri.file('/path/to/inline-env'), + execInfo: { run: { executable: '/path/to/inline-env/python' } }, + sysPrefix: '/path/to/inline-env', + }; + } + + function createManagers(): EnvironmentManagers { + return { + getEnvironmentManager: getEnvironmentManagerStub, + setEnvironment: setEnvironmentStub, + onDidChangeEnvironmentManager: envManagerEmitter.event, + onDidChangePackageManager: new EventEmitter().event, + } as unknown as EnvironmentManagers; + } + + setup(() => { + clock = sinon.useFakeTimers(); + disposables = []; + managerAvailable = false; + envManagerEmitter = new EventEmitter(); + createStub = sinon.stub().resolves(createEnvironment()); + getEnvironmentManagerStub = sinon.stub().callsFake((managerId: string) => { + if (managerId === INLINE_SCRIPT_MANAGER_ID && managerAvailable) { + return { create: createStub } as unknown as InternalEnvironmentManager; + } + return undefined; + }); + setEnvironmentStub = sinon.stub().resolves(); + showErrorMessageStub = sinon.stub(windowApis, 'showErrorMessage').resolves(undefined); + sinon.stub(windowApis, 'activeTextEditor').returns(undefined); + sinon.stub(workspaceApis, 'getOpenTextDocuments').returns([]); + readInlineScriptMetadataStub = sinon + .stub(inlineScriptMetadata, 'readInlineScriptMetadataFromFile') + .resolves({ range: { start: 0, end: 0 } }); + sinon.stub(helpers, 'isInlineScriptsFeatureEnabled').returns(true); + sinon.stub(logging, 'traceWarn'); + sinon.stub(logging, 'traceError'); + sinon.stub(logging, 'traceInfo'); + sinon.stub(telemetrySender, 'sendTelemetryEvent'); + sinon.stub(extensionApis, 'getExtension').returns({ + id: 'ms-python.python', + isActive: true, + } as unknown as ReturnType); + + _resetManagerReadyForTesting(); + createManagerReady( + { + onDidChangeEnvironmentManager: envManagerEmitter.event, + onDidChangePackageManager: new EventEmitter().event, + } as unknown as EnvironmentManagers, + { getProjects: () => [] } as unknown as PythonProjectManager, + disposables, + ); + }); + + teardown(() => { + clock.restore(); + disposables.forEach((disposable) => disposable.dispose()); + envManagerEmitter.dispose(); + sinon.restore(); + _resetManagerReadyForTesting(); + }); + + test('waits for inline manager readiness before direct lookup', async () => { + const uri = Uri.file('/workspace/script.py'); + let settled = false; + + const resultPromise = setupInlineScriptEnvironmentCommand(uri, createManagers()).then((result) => { + settled = true; + return result; + }); + + await clock.tickAsync(0); + assert.strictEqual(settled, false); + sinon.assert.notCalled(getEnvironmentManagerStub); + sinon.assert.notCalled(createStub); + + managerAvailable = true; + envManagerEmitter.fire({ + kind: 'registered', + manager: { id: INLINE_SCRIPT_MANAGER_ID } as unknown as InternalEnvironmentManager, + }); + + const result = await resultPromise; + + assert.strictEqual(settled, true); + assert.strictEqual(result?.envId.managerId, INLINE_SCRIPT_MANAGER_ID); + sinon.assert.calledOnceWithExactly(readInlineScriptMetadataStub, uri); + sinon.assert.calledOnceWithExactly(getEnvironmentManagerStub, INLINE_SCRIPT_MANAGER_ID); + sinon.assert.calledOnceWithExactly(createStub, uri, undefined); + sinon.assert.calledOnceWithExactly(setEnvironmentStub, uri, result, false); + sinon.assert.notCalled(showErrorMessageStub); + }); + + test('shows the not-registered error after manager-ready timeout', async () => { + const uri = Uri.file('/workspace/script.py'); + + const resultPromise = setupInlineScriptEnvironmentCommand(uri, createManagers()); + await clock.tickAsync(0); + clock.tick(MANAGER_READY_TIMEOUT_MS); + await clock.tickAsync(0); + + const result = await resultPromise; + + assert.strictEqual(result, undefined); + sinon.assert.calledOnceWithExactly(readInlineScriptMetadataStub, uri); + sinon.assert.calledOnceWithExactly(getEnvironmentManagerStub, INLINE_SCRIPT_MANAGER_ID); + sinon.assert.notCalled(createStub); + sinon.assert.notCalled(setEnvironmentStub); + sinon.assert.calledOnce(showErrorMessageStub); + assert.match(String(showErrorMessageStub.firstCall.args[0]), /preview manager is not registered/); + }); +}); diff --git a/src/test/features/envManagers.lastKnown.unit.test.ts b/src/test/features/envManagers.lastKnown.unit.test.ts index 14f00cd9c..589a1a25c 100644 --- a/src/test/features/envManagers.lastKnown.unit.test.ts +++ b/src/test/features/envManagers.lastKnown.unit.test.ts @@ -23,8 +23,6 @@ import { PythonProject, } from '../../api'; import * as extensionApis from '../../common/extension.apis'; -import { InlineScriptMetadata } from '../../common/inlineScript/metadata'; -import { InlineScriptRoutingRegistry } from '../../common/inlineScript/routingRegistry'; import { PythonEnvironmentManagers } from '../../features/envManagers'; import * as settingHelpers from '../../features/settings/settingHelpers'; import { InternalPackageManager, PythonProjectManager } from '../../internal.api'; @@ -36,13 +34,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { let projectsByUri: Map; let defaultManagerId: string; let exactManagerSettings: Map; - let routingRegistry: InlineScriptRoutingRegistry; - - const INLINE_METADATA: InlineScriptMetadata = { - requiresPython: '>=3.11', - dependencies: ['requests'], - range: { start: 0, end: 40 }, - }; function makeEnv(id: string): PythonEnvironment { const envId: PythonEnvironmentId = { id, managerId: 'test-manager' }; @@ -73,12 +64,11 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { setupNonThenable(projectManager); projectsByUri = new Map(); exactManagerSettings = new Map(); - routingRegistry = new InlineScriptRoutingRegistry(); projectManager .setup((pm) => pm.get(typeMoq.It.isAny())) .returns((uri) => projectsByUri.get(uri.toString())); - envManagers = new PythonEnvironmentManagers(projectManager.object, routingRegistry); + envManagers = new PythonEnvironmentManagers(projectManager.object); sinon.stub(settingHelpers, 'getDefaultEnvManagerSetting').callsFake(() => defaultManagerId); sinon .stub(settingHelpers, 'getProjectEnvironmentManagerSetting') @@ -124,11 +114,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { sinon.stub(envManagers, 'getPackageManager').returns(packageManager.object); } - function markInlineScript(uri: Uri, associated: boolean = true, metadata: InlineScriptMetadata = INLINE_METADATA): void { - routingRegistry.setMetadata(uri, metadata); - routingRegistry.setValidatedAssociation(uri, associated); - } - test('returns undefined before any environment has been resolved', () => { registerManager(async () => makeEnv('env1')); assert.strictEqual(envManagers.getLastKnownEnvironment(undefined), undefined); @@ -282,7 +267,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { settings.onSecondCall().resolves(); const events: DidChangeEnvironmentEventArgs[] = []; envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); - markInlineScript(scope); const olderSelection = envManagers.setEnvironment(scope, first); await firstWriteStarted; @@ -309,7 +293,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { }; const events: DidChangeEnvironmentEventArgs[] = []; envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); - markInlineScript(scope); await envManagers.setEnvironment(scope, first, false); await envManagers.setEnvironment(scope, second, false); @@ -339,7 +322,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { }; const events: DidChangeEnvironmentEventArgs[] = []; envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); - markInlineScript(scope); await envManagers.setEnvironment(scope, first, false); await envManagers.setEnvironment(scope, regenerated, false); @@ -387,8 +369,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { const managerId = registerManager(async () => undefined, async () => undefined, 'inline-script'); const first = { ...makeEnv('first'), envId: { id: 'first', managerId } }; const second = { ...makeEnv('second'), envId: { id: 'second', managerId } }; - markInlineScript(firstUri); - markInlineScript(secondUri); await envManagers.setEnvironment(firstUri, first, false); await envManagers.setEnvironment(secondUri, second, false); @@ -397,28 +377,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { assert.strictEqual(envManagers.getLastKnownEnvironment(secondUri), second); }); - test('does not route inline metadata without an associated environment', () => { - const script = Uri.file('/workspace/project/script.py'); - projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); - const defaultId = registerManager(async () => makeEnv('default'), async () => undefined, 'venv'); - registerManager(async () => makeEnv('inline'), async () => undefined, 'inline-script'); - defaultManagerId = defaultId; - routingRegistry.setMetadata(script, INLINE_METADATA); - - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); - }); - - test('does not route an associated inline environment without known metadata', () => { - const script = Uri.file('/workspace/project/script.py'); - projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); - const defaultId = registerManager(async () => makeEnv('default'), async () => undefined, 'venv'); - registerManager(async () => makeEnv('inline'), async () => undefined, 'inline-script'); - defaultManagerId = defaultId; - routingRegistry.setValidatedAssociation(script, true); - - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); - }); - test('routes an active inline-script selection before the containing project default', async () => { const script = Uri.file('/workspace/project/script.py'); projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); @@ -427,7 +385,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = defaultId; - markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); @@ -443,7 +400,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = selectedId; - markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); exactManagerSettings.set(script.toString(), selectedId); @@ -461,7 +417,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId: selectedId } }; inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = selectedId; - markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); await envManagers.setEnvironment(script, selectedEnvironment, false); @@ -469,33 +424,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, selectedId); }); - test('ignores routeability changes while an explicit non-inline override wins', async () => { - const script = Uri.file('/workspace/project/script.py'); - projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); - let selectedEnvironment: PythonEnvironment; - const selectedId = registerManager(async () => selectedEnvironment, async () => undefined, 'venv'); - let inlineEnvironment: PythonEnvironment; - const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); - selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId: selectedId } }; - inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; - defaultManagerId = selectedId; - markInlineScript(script); - - await envManagers.setEnvironment(script, inlineEnvironment, false); - await envManagers.setEnvironment(script, selectedEnvironment, false); - await new Promise((resolve) => setImmediate(resolve)); - - const events: DidChangeEnvironmentEventArgs[] = []; - envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); - routingRegistry.clearMetadata(script); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - assert.deepStrictEqual(events, []); - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, selectedId); - assert.strictEqual(envManagers.getLastKnownEnvironment(script), selectedEnvironment); - }); - test('clears inline routing after a no-op inline refresh during settings persistence', async () => { const script = Uri.file('/workspace/project/script.py'); projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); @@ -506,7 +434,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { selectedEnvironment = { ...makeEnv('selected'), envId: { id: 'selected', managerId: selectedId } }; inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; defaultManagerId = selectedId; - markInlineScript(script); await envManagers.setEnvironment(script, inlineEnvironment, false); stubPackageManager(); let releaseSettings: (() => void) | undefined; @@ -532,133 +459,6 @@ suite('PythonEnvironmentManagers getLastKnownEnvironment', () => { assert.strictEqual(envManagers.getLastKnownEnvironment(script), selectedEnvironment); }); - test('refreshes to the inline manager when a persisted association becomes routeable', async () => { - const script = Uri.file('/workspace/project/script.py'); - projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); - const defaultEnvironment = makeEnv('default'); - const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); - let inlineEnvironment: PythonEnvironment; - const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); - inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; - defaultManagerId = defaultId; - routingRegistry.setMetadata(script, INLINE_METADATA); - - await envManagers.refreshEnvironment(script); - routingRegistry.setValidatedAssociation(script, true); - await new Promise((resolve) => setImmediate(resolve)); - - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, inlineId); - assert.strictEqual(envManagers.getLastKnownEnvironment(script), inlineEnvironment); - }); - - test('does not publish an inline selection while routeability is false, then publishes once when it validates', async () => { - const script = Uri.file('/workspace/project/script.py'); - const project = { name: 'project', uri: Uri.file('/workspace/project') }; - projectsByUri.set(script.toString(), project); - const defaultEnvironment = makeEnv('default'); - const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); - let inlineEnvironment: PythonEnvironment; - const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); - inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; - defaultManagerId = defaultId; - - await envManagers.refreshEnvironment(script); - await new Promise((resolve) => setImmediate(resolve)); - const events: DidChangeEnvironmentEventArgs[] = []; - envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); - - await envManagers.setEnvironment(script, inlineEnvironment, false); - await new Promise((resolve) => setImmediate(resolve)); - - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); - assert.strictEqual(envManagers.getLastKnownEnvironment(script), defaultEnvironment); - assert.deepStrictEqual(events, []); - - routingRegistry.setMetadata(script, INLINE_METADATA); - routingRegistry.setValidatedAssociation(script, true); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, inlineId); - assert.strictEqual(envManagers.getLastKnownEnvironment(script), inlineEnvironment); - assert.deepStrictEqual(events, [{ uri: script, old: defaultEnvironment, new: inlineEnvironment }]); - }); - - test('does not publish batch inline selections until each script becomes routeable', async () => { - const first = Uri.file('/workspace/project/first.py'); - const second = Uri.file('/workspace/project/second.py'); - const project = { name: 'project', uri: Uri.file('/workspace/project') }; - projectsByUri.set(first.toString(), project); - projectsByUri.set(second.toString(), project); - const defaultEnvironment = makeEnv('default'); - const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); - let inlineEnvironment: PythonEnvironment; - const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); - inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; - defaultManagerId = defaultId; - - await envManagers.refreshEnvironment(first); - await envManagers.refreshEnvironment(second); - await new Promise((resolve) => setImmediate(resolve)); - const events: DidChangeEnvironmentEventArgs[] = []; - envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); - - await envManagers.setEnvironments([first, second], inlineEnvironment, false); - await new Promise((resolve) => setImmediate(resolve)); - - assert.strictEqual(envManagers.getLastKnownEnvironment(first), defaultEnvironment); - assert.strictEqual(envManagers.getLastKnownEnvironment(second), defaultEnvironment); - assert.deepStrictEqual(events, []); - - routingRegistry.setMetadata(first, INLINE_METADATA); - routingRegistry.setValidatedAssociation(first, true); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - assert.strictEqual(envManagers.getLastKnownEnvironment(first), inlineEnvironment); - assert.strictEqual(envManagers.getLastKnownEnvironment(second), defaultEnvironment); - assert.deepStrictEqual(events, [{ uri: first, old: defaultEnvironment, new: inlineEnvironment }]); - }); - - test('falls back when inline-script metadata is invalidated after routing', async () => { - const script = Uri.file('/workspace/project/script.py'); - const project = { name: 'project', uri: Uri.file('/workspace/project') }; - projectsByUri.set(script.toString(), project); - const defaultEnvironment = makeEnv('default'); - const defaultId = registerManager(async () => defaultEnvironment, async () => undefined, 'venv'); - let inlineEnvironment: PythonEnvironment; - const inlineId = registerManager(async () => inlineEnvironment, async () => undefined, 'inline-script'); - inlineEnvironment = { ...makeEnv('inline'), envId: { id: 'inline', managerId: inlineId } }; - defaultManagerId = defaultId; - await envManagers.refreshEnvironment(script); - markInlineScript(script); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - const events: DidChangeEnvironmentEventArgs[] = []; - envManagers.onDidChangeActiveEnvironment((event) => events.push(event)); - routingRegistry.clearMetadata(script); - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setImmediate(resolve)); - - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); - assert.strictEqual(envManagers.getLastKnownEnvironment(script), defaultEnvironment); - assert.deepStrictEqual(events[events.length - 1], { - uri: project.uri, - old: inlineEnvironment, - new: defaultEnvironment, - }); - }); - - test('ignores routeable inline state when the inline manager is not registered', () => { - const script = Uri.file('/workspace/project/script.py'); - projectsByUri.set(script.toString(), { name: 'project', uri: Uri.file('/workspace/project') }); - const defaultId = registerManager(async () => makeEnv('default'), async () => undefined, 'venv'); - defaultManagerId = defaultId; - markInlineScript(script); - - assert.strictEqual(envManagers.getEnvironmentManager(script)?.id, defaultId); - }); - test('does not persist an inline-script manager for the containing project', async () => { const script = Uri.file('/workspace/project/script.py'); const containingProject = { name: 'project', uri: Uri.file('/workspace/project') }; diff --git a/src/test/features/inlineScript/lazyDetector.unit.test.ts b/src/test/features/inlineScript/lazyDetector.unit.test.ts index 40fb56265..f8262e146 100644 --- a/src/test/features/inlineScript/lazyDetector.unit.test.ts +++ b/src/test/features/inlineScript/lazyDetector.unit.test.ts @@ -6,30 +6,25 @@ import * as path from 'path'; import * as sinon from 'sinon'; import { Disposable, TextDocument, TextDocumentChangeEvent, TextDocumentContentChangeEvent, Uri } from 'vscode'; import * as ism from '../../../common/inlineScript/metadata'; -import { InlineScriptRoutingRegistry } from '../../../common/inlineScript/routingRegistry'; import { EventNames } from '../../../common/telemetry/constants'; import * as telemetrySender from '../../../common/telemetry/sender'; import * as wapi from '../../../common/workspace.apis'; import { InlineScriptLazyDetector, shouldHandleUri } from '../../../features/inlineScript/lazyDetector'; -let docDirtyByUri = new Map(); - +// Build a minimal TextDocument stub. Only the `uri` field is read by +// the detector; the rest exists to satisfy the type. function makeDoc(uri: Uri): TextDocument { - return { - uri, - getText: () => '', - isDirty: docDirtyByUri.get(uri.toString()) ?? false, - } as TextDocument; + return { uri } as TextDocument; } +// A non-empty change event payload. The actual content of the +// changes is not inspected by the detector; only `contentChanges.length` +// matters. const NON_EMPTY_CHANGES: readonly TextDocumentContentChangeEvent[] = [ { range: undefined as never, rangeOffset: 0, rangeLength: 0, text: 'x' }, ]; -function makeChange( - uri: Uri, - changes: readonly TextDocumentContentChangeEvent[] = NON_EMPTY_CHANGES, -): TextDocumentChangeEvent { +function makeChange(uri: Uri, changes: readonly TextDocumentContentChangeEvent[] = NON_EMPTY_CHANGES): TextDocumentChangeEvent { return { document: makeDoc(uri), contentChanges: changes, @@ -48,27 +43,18 @@ suite('InlineScriptLazyDetector', () => { let onDidOpenStub: sinon.SinonStub; let onDidSaveStub: sinon.SinonStub; let onDidChangeStub: sinon.SinonStub; - let onDidDeleteStub: sinon.SinonStub; - let onDidRenameStub: sinon.SinonStub; let getOpenTextDocumentsStub: sinon.SinonStub; let getWorkspaceFolderStub: sinon.SinonStub; let readMetadataStub: sinon.SinonStub; let sendTelemetryStub: sinon.SinonStub; - let routingRegistry: InlineScriptRoutingRegistry; let openListener: ((doc: TextDocument) => unknown) | undefined; let saveListener: ((doc: TextDocument) => unknown) | undefined; let changeListener: ((e: TextDocumentChangeEvent) => unknown) | undefined; - let deleteListener: ((e: { files: readonly Uri[] }) => unknown) | undefined; - let renameListener: ((e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) | undefined; setup(() => { openListener = undefined; saveListener = undefined; changeListener = undefined; - deleteListener = undefined; - renameListener = undefined; - docDirtyByUri = new Map(); - routingRegistry = new InlineScriptRoutingRegistry(); onDidOpenStub = sinon.stub(wapi, 'onDidOpenTextDocument'); onDidOpenStub.callsFake((listener: (doc: TextDocument) => unknown) => { @@ -94,26 +80,15 @@ suite('InlineScriptLazyDetector', () => { }); }); - onDidDeleteStub = sinon.stub(wapi, 'onDidDeleteFiles'); - onDidDeleteStub.callsFake((listener: (e: { files: readonly Uri[] }) => unknown) => { - deleteListener = listener; - return new Disposable(() => { - deleteListener = undefined; - }); - }); - - onDidRenameStub = sinon.stub(wapi, 'onDidRenameFiles'); - onDidRenameStub.callsFake((listener: (e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) => { - renameListener = listener; - return new Disposable(() => { - renameListener = undefined; - }); - }); - + // Default to an empty list of open documents. Tests that + // exercise the catch-up replay override this. getOpenTextDocumentsStub = sinon.stub(wapi, 'getOpenTextDocuments'); getOpenTextDocumentsStub.returns([]); getWorkspaceFolderStub = sinon.stub(wapi, 'getWorkspaceFolder'); + // By default, every URI is treated as being inside a workspace + // folder. Tests that want to exercise the "not in workspace" + // branch override this. getWorkspaceFolderStub.callsFake((uri: Uri) => ({ uri: Uri.file(path.dirname(uri.fsPath)), name: 'mockWorkspace', @@ -130,12 +105,6 @@ suite('InlineScriptLazyDetector', () => { sinon.restore(); }); - function createDetector(): InlineScriptLazyDetector { - const detector = new InlineScriptLazyDetector(routingRegistry); - detector.activate(); - return detector; - } - async function fireOpen(uri: Uri): Promise { assert.ok(openListener, 'open listener should be registered after activate()'); await openListener!(makeDoc(uri)); @@ -151,72 +120,50 @@ suite('InlineScriptLazyDetector', () => { changeListener!(makeChange(uri, changes)); } - function makeContentChanges(rangeOffset: number): readonly TextDocumentContentChangeEvent[] { - return [{ range: undefined as never, rangeOffset, rangeLength: 0, text: 'x' }]; - } - - function setDocDirty(uri: Uri, isDirty: boolean): void { - docDirtyByUri.set(uri.toString(), isDirty); - } - - function fireDelete(...uris: Uri[]): void { - assert.ok(deleteListener, 'delete listener should be registered after activate()'); - deleteListener!({ files: uris }); - } - - function fireRename(oldUri: Uri, newUri: Uri): void { - assert.ok(renameListener, 'rename listener should be registered after activate()'); - renameListener!({ files: [{ oldUri, newUri }] }); - } - + // Filter `sendTelemetryStub.getCalls()` to a single inline script event name. function callsFor(name: EventNames): sinon.SinonSpyCall[] { return sendTelemetryStub.getCalls().filter((c) => c.args[0] === name); } - function flushImmediate(): Promise { - return new Promise((resolve) => setImmediate(resolve)); - } - - test('activate() subscribes to document and file events', () => { - const detector = createDetector(); + test('activate() subscribes to onDidOpen, onDidSave, and onDidChange', () => { + const detector = new InlineScriptLazyDetector(); + detector.activate(); assert.ok(onDidOpenStub.calledOnce, 'should subscribe to onDidOpenTextDocument'); assert.ok(onDidSaveStub.calledOnce, 'should subscribe to onDidSaveTextDocument'); assert.ok(onDidChangeStub.calledOnce, 'should subscribe to onDidChangeTextDocument'); - assert.ok(onDidDeleteStub.calledOnce, 'should subscribe to onDidDeleteFiles'); - assert.ok(onDidRenameStub.calledOnce, 'should subscribe to onDidRenameFiles'); detector.dispose(); }); test('skips non-file URI schemes', async () => { - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(Uri.parse('untitled:foo.py')); assert.ok(readMetadataStub.notCalled, 'should not read metadata for non-file URI'); detector.dispose(); }); test('skips non-.py files', async () => { - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(Uri.file(path.resolve('/ws/foo.txt'))); assert.ok(readMetadataStub.notCalled, 'should not read metadata for non-.py files'); detector.dispose(); }); - test('skips telemetry for files outside any workspace folder but still refreshes saved routing metadata', async () => { + test('skips files outside any workspace folder', async () => { getWorkspaceFolderStub.returns(undefined); - readMetadataStub.resolves(VALID_METADATA); - routingRegistry.setValidatedAssociation(Uri.file(path.resolve('/elsewhere/foo.py')), true); - const detector = createDetector(); - const uri = Uri.file(path.resolve('/elsewhere/foo.py')); - await fireOpen(uri); - assert.ok(readMetadataStub.calledOnceWithExactly(uri), 'should still read saved metadata for routing'); - assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_DETECTED).length, 0, 'should not emit telemetry'); + const detector = new InlineScriptLazyDetector(); + detector.activate(); + await fireOpen(Uri.file(path.resolve('/elsewhere/foo.py'))); + assert.ok(readMetadataStub.notCalled, 'should not read metadata for out-of-workspace files'); detector.dispose(); }); test('reads metadata for an in-workspace .py file on open', async () => { const uri = Uri.file(path.resolve('/ws/foo.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); assert.strictEqual(readMetadataStub.callCount, 1, 'open should trigger exactly one read'); assert.strictEqual((readMetadataStub.firstCall.args[0] as Uri).toString(), uri.toString()); @@ -226,31 +173,18 @@ suite('InlineScriptLazyDetector', () => { test('reads metadata for an in-workspace .py file on save', async () => { const uri = Uri.file(path.resolve('/ws/bar.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireSave(uri); assert.strictEqual(readMetadataStub.callCount, 1, 'save should trigger exactly one read'); detector.dispose(); }); - test('withholds routeability and skips disk reads for dirty documents on open', async () => { - const uri = Uri.file(path.resolve('/ws/dirty.py')); - setDocDirty(uri, true); - routingRegistry.setMetadata(uri, VALID_METADATA); - routingRegistry.setValidatedAssociation(uri, true); - const detector = createDetector(); - - await fireOpen(uri); - - assert.ok(readMetadataStub.notCalled, 'dirty open should not read saved metadata'); - assert.strictEqual(routingRegistry.getMetadata(uri), undefined); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - detector.dispose(); - }); - test('concurrent open + open coalesces to a single read', async () => { const uri = Uri.file(path.resolve('/ws/dedup.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await Promise.all([fireOpen(uri), fireOpen(uri)]); assert.strictEqual(readMetadataStub.callCount, 1, 'open+open should coalesce to a single read'); detector.dispose(); @@ -259,8 +193,12 @@ suite('InlineScriptLazyDetector', () => { test('concurrent open + save coalesces to a single read', async () => { const uri = Uri.file(path.resolve('/ws/race.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await Promise.all([fireOpen(uri), fireSave(uri)]); + // The slim observer has no cached state to keep fresh, so + // simple URI-level dedup is sufficient: a save concurrent + // with an in-flight open coalesces with it. assert.strictEqual(readMetadataStub.callCount, 1, 'concurrent open+save should coalesce to a single read'); detector.dispose(); }); @@ -274,160 +212,28 @@ suite('InlineScriptLazyDetector', () => { }), ); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); + // Kick off the open without awaiting it; the read is parked + // on our manual resolver above. const inFlight = openListener!(makeDoc(uri)) as Promise | undefined; + // Tear the detector down BEFORE the read settles. detector.dispose(); + // Now let the in-flight read complete with metadata. The + // `disposed` guard inside processOnce must prevent any + // further work — including the detection telemetry event. resolveRead!(VALID_METADATA); await assert.doesNotReject(inFlight ?? Promise.resolve()); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_DETECTED).length, 0, 'no detection event after dispose'); }); - test('tracks loose local .py files for routing even when telemetry skips them', async () => { - const uri = Uri.file(path.resolve('/elsewhere/loose.py')); - readMetadataStub.resolves(VALID_METADATA); - routingRegistry.setValidatedAssociation(uri.fsPath, true); - getWorkspaceFolderStub.returns(undefined); - const detector = createDetector(); - - await fireOpen(uri); - - assert.strictEqual(routingRegistry.shouldRoute(uri), true); - assert.ok(readMetadataStub.calledOnceWithExactly(uri), 'loose files should still refresh saved metadata'); - detector.dispose(); - }); - - test('replays already-open loose .py documents for routing on activation', async () => { - const uri = Uri.file(path.resolve('/elsewhere/replayed.py')); - readMetadataStub.resolves(VALID_METADATA); - routingRegistry.setValidatedAssociation(uri.fsPath, true); - getWorkspaceFolderStub.returns(undefined); - getOpenTextDocumentsStub.returns([makeDoc(uri)]); - - const detector = createDetector(); - await flushImmediate(); - - assert.strictEqual(routingRegistry.shouldRoute(uri), true); - assert.ok(readMetadataStub.calledOnceWithExactly(uri), 'loose replay should refresh saved metadata'); - detector.dispose(); - }); - - test('clears routeability on header edits and refreshes saved metadata on the next save', async () => { - const uri = Uri.file(path.resolve('/elsewhere/edited.py')); - routingRegistry.setValidatedAssociation(uri, true); - readMetadataStub.onFirstCall().resolves(VALID_METADATA); - readMetadataStub.onSecondCall().resolves(VALID_METADATA); - const detector = createDetector(); - - await fireOpen(uri); - assert.strictEqual(routingRegistry.shouldRoute(uri), true); - - fireChange(uri, makeContentChanges(0)); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - - await fireSave(uri); - assert.deepStrictEqual(routingRegistry.getMetadata(uri), VALID_METADATA); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - detector.dispose(); - }); - - test('preserves routing when edits are after the metadata block', async () => { - const uri = Uri.file(path.resolve('/elsewhere/bodyEdit.py')); - readMetadataStub.resolves(VALID_METADATA); - routingRegistry.setValidatedAssociation(uri, true); - const detector = createDetector(); - - await fireOpen(uri); - const metadata = routingRegistry.getMetadata(uri); - assert.ok(metadata, 'expected routing metadata after open'); - - fireChange(uri, makeContentChanges(metadata!.range.end + 5)); - - assert.strictEqual(routingRegistry.shouldRoute(uri), true); - detector.dispose(); - }); - - test('save rehydrates routing from saved file metadata rather than the live buffer', async () => { - const uri = Uri.file(path.resolve('/elsewhere/savedState.py')); - readMetadataStub.resolves(VALID_METADATA); - routingRegistry.setValidatedAssociation(uri, true); - const detector = createDetector(); - - await fireSave(uri); - - assert.strictEqual(routingRegistry.shouldRoute(uri), true); - detector.dispose(); - }); - - test('restored dirty open with removed metadata stays non-routeable until save', async () => { - const uri = Uri.file(path.resolve('/elsewhere/restoredDirtyRemoved.py')); - setDocDirty(uri, true); - routingRegistry.setMetadata(uri, VALID_METADATA); - routingRegistry.setValidatedAssociation(uri, true); - readMetadataStub.resolves(undefined); - const detector = createDetector(); - - await fireOpen(uri); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - assert.ok(readMetadataStub.notCalled); - - setDocDirty(uri, false); - await fireSave(uri); - assert.strictEqual(routingRegistry.getMetadata(uri), undefined); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - detector.dispose(); - }); - - test('restored dirty open with changed metadata stays non-routeable until save', async () => { - const uri = Uri.file(path.resolve('/elsewhere/restoredDirtyChanged.py')); - const changedMetadata = { - ...VALID_METADATA, - dependencies: ['urllib3'], - } satisfies ism.InlineScriptMetadata; - setDocDirty(uri, true); - routingRegistry.setMetadata(uri, VALID_METADATA); - routingRegistry.setValidatedAssociation(uri, true); - readMetadataStub.resolves(changedMetadata); - const detector = createDetector(); - - await fireOpen(uri); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - assert.ok(readMetadataStub.notCalled); - - setDocDirty(uri, false); - await fireSave(uri); - assert.deepStrictEqual(routingRegistry.getMetadata(uri), changedMetadata); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - detector.dispose(); - }); - - test('clears routing metadata and validation when a file is deleted', async () => { - const uri = Uri.file(path.resolve('/elsewhere/deleted.py')); - readMetadataStub.resolves(VALID_METADATA); - routingRegistry.setValidatedAssociation(uri, true); - const detector = createDetector(); - await fireOpen(uri); - - fireDelete(uri); - - assert.strictEqual(routingRegistry.getMetadata(uri), undefined); - assert.strictEqual(routingRegistry.shouldRoute(uri), false); - detector.dispose(); - }); - - test('clears routing metadata and validation for the old path when a file is renamed', async () => { - const oldUri = Uri.file(path.resolve('/elsewhere/old.py')); - const newUri = Uri.file(path.resolve('/elsewhere/new.py')); - readMetadataStub.resolves(VALID_METADATA); - routingRegistry.setValidatedAssociation(oldUri, true); - const detector = createDetector(); - await fireOpen(oldUri); - - fireRename(oldUri, newUri); + // ---------- catch-up replay over `getOpenTextDocuments` ---------- - assert.strictEqual(routingRegistry.getMetadata(oldUri), undefined); - assert.strictEqual(routingRegistry.shouldRoute(oldUri), false); - detector.dispose(); - }); + // Drain the microtask queue and the next `setImmediate` slot so + // the deferred catch-up replay can run before assertions. + function flushImmediate(): Promise { + return new Promise((resolve) => setImmediate(resolve)); + } test('activate() replays already-open .py documents via setImmediate', async () => { const uriWithMeta = Uri.file(path.resolve('/ws/withMeta.py')); @@ -438,10 +244,15 @@ suite('InlineScriptLazyDetector', () => { ); getOpenTextDocumentsStub.returns([makeDoc(uriWithMeta), makeDoc(uriPlain), makeDoc(uriNonPy)]); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); + // Wait for the deferred catch-up. await flushImmediate(); + // Then await any in-flight reads kicked off by the replay. await flushImmediate(); + // The non-`.py` URI must be filtered out by `shouldHandleUri` + // BEFORE the read is attempted. assert.strictEqual(readMetadataStub.callCount, 2, 'should read each candidate .py document exactly once'); const readUris = readMetadataStub.getCalls().map((c) => (c.args[0] as Uri).toString()); assert.ok(readUris.includes(uriWithMeta.toString())); @@ -452,16 +263,21 @@ suite('InlineScriptLazyDetector', () => { test('dispose() cancels the pending catch-up replay', async () => { getOpenTextDocumentsStub.returns([makeDoc(Uri.file(path.resolve('/ws/never.py')))]); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); + // Tear down BEFORE the `setImmediate` slot fires. detector.dispose(); await flushImmediate(); assert.ok(readMetadataStub.notCalled, 'dispose() must clear the pending setImmediate handle'); }); + // ---------- inlineScript.detected telemetry ---------- + test('inlineScript.detected fires once with trigger=open + dependencyCount + hasRequiresPython', async () => { const uri = Uri.file(path.resolve('/ws/detect.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); const detectedCalls = callsFor(EventNames.INLINE_SCRIPT_DETECTED); @@ -475,7 +291,8 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.detected fires with trigger=save when surfaced by a save event', async () => { const uri = Uri.file(path.resolve('/ws/detectOnSave.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireSave(uri); const detectedCalls = callsFor(EventNames.INLINE_SCRIPT_DETECTED); @@ -487,7 +304,8 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.detected does not fire when the file has no metadata block', async () => { const uri = Uri.file(path.resolve('/ws/plain.py')); readMetadataStub.resolves(undefined); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_DETECTED).length, 0); detector.dispose(); @@ -496,7 +314,8 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.detected is deduplicated across repeated opens and saves of the same URI', async () => { const uri = Uri.file(path.resolve('/ws/repeat.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); await fireSave(uri); await fireSave(uri); @@ -513,7 +332,8 @@ suite('InlineScriptLazyDetector', () => { tool: undefined, range: { start: 0, end: 20 }, } satisfies ism.InlineScriptMetadata); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); const [, measures, properties] = callsFor(EventNames.INLINE_SCRIPT_DETECTED)[0].args; @@ -522,15 +342,19 @@ suite('InlineScriptLazyDetector', () => { detector.dispose(); }); + // ---------- inlineScript.edited telemetry ---------- + test('inlineScript.edited fires once on first content change after detection', async () => { const uri = Uri.file(path.resolve('/ws/edit.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); fireChange(uri); const editedCalls = callsFor(EventNames.INLINE_SCRIPT_EDITED); assert.strictEqual(editedCalls.length, 1, 'edited event should fire exactly once'); + // Second arg is the measure (number → { duration }); accept either form. const measureArg = editedCalls[0].args[1]; assert.strictEqual(typeof measureArg, 'number', 'measure should be a number (latency ms)'); assert.ok((measureArg as number) >= 0, 'duration should be non-negative'); @@ -540,7 +364,8 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited is deduplicated across repeated edits of the same URI', async () => { const uri = Uri.file(path.resolve('/ws/multiEdit.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); fireChange(uri); fireChange(uri); @@ -552,7 +377,8 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited does not fire for changes on a URI that was never detected', async () => { const uri = Uri.file(path.resolve('/ws/notDetected.py')); readMetadataStub.resolves(undefined); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); fireChange(uri); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_EDITED).length, 0); @@ -562,10 +388,15 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited ignores change events with no content changes', async () => { const uri = Uri.file(path.resolve('/ws/noOpChange.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); + // VS Code can fire a change event with an empty contentChanges + // array for things like dirty-state toggles; that's not a user + // edit and must not count. fireChange(uri, []); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_EDITED).length, 0); + // A real edit still counts after the no-op was ignored. fireChange(uri); assert.strictEqual(callsFor(EventNames.INLINE_SCRIPT_EDITED).length, 1); detector.dispose(); @@ -574,7 +405,8 @@ suite('InlineScriptLazyDetector', () => { test('inlineScript.edited is suppressed after dispose()', async () => { const uri = Uri.file(path.resolve('/ws/disposedEdit.py')); readMetadataStub.resolves(VALID_METADATA); - const detector = createDetector(); + const detector = new InlineScriptLazyDetector(); + detector.activate(); await fireOpen(uri); const grabbedChangeListener = changeListener!; detector.dispose(); diff --git a/src/test/features/pythonApi.unit.test.ts b/src/test/features/pythonApi.unit.test.ts index bd464b4b0..0287828e5 100644 --- a/src/test/features/pythonApi.unit.test.ts +++ b/src/test/features/pythonApi.unit.test.ts @@ -1,119 +1,65 @@ import * as assert from 'assert'; -import * as sinon from 'sinon'; import { EventEmitter, Uri } from 'vscode'; -import { PythonEnvironment, PythonProject } from '../../api'; -import * as managerReady from '../../features/common/managerReady'; +import { PythonProject } from '../../api'; import { PythonEnvironmentApiImpl } from '../../features/pythonApi'; import { PythonProjectManager } from '../../internal.api'; suite('PythonEnvironmentApiImpl - onDidChangePythonProjects', () => { - test('fires event with correct added and removed projects', () => { + test('Fires event with correct added and removed projects', async () => { + // 1. Create a mock EventEmitter to simulate the internal project manager const onDidChangeProjectsEmitter = new EventEmitter(); + + // 2. Mock the PythonProjectManager let currentProjects: PythonProject[] = []; const mockProjectManager = { getProjects: () => currentProjects, onDidChangeProjects: onDidChangeProjectsEmitter.event, } as unknown as PythonProjectManager; + // 3. Mock the other required constructor arguments using ConstructorParameters type ApiArgs = ConstructorParameters; + const mockEnvManagers = { onDidChangeActiveEnvironment: new EventEmitter().event } as unknown as ApiArgs[0]; const mockProjectCreators = {} as unknown as ApiArgs[2]; const mockTerminalManager = {} as unknown as ApiArgs[3]; const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4]; + // 4. Initialize the API instance const api = new PythonEnvironmentApiImpl( mockEnvManagers, mockProjectManager, mockProjectCreators, mockTerminalManager, - mockEnvVarManager, + mockEnvVarManager ); + // 5. Listen to the public event we are testing let firedEventPayload: unknown = null; - api.onDidChangePythonProjects((event: unknown) => { - firedEventPayload = event; + api.onDidChangePythonProjects((e: unknown) => { + firedEventPayload = e; }); + // 6. Simulate adding a project const newProject = { uri: Uri.joinPath(Uri.file(process.cwd()), 'fake', 'path') } as unknown as PythonProject; - currentProjects = [newProject]; + currentProjects = [newProject]; // Update the mock's state + + // Fire the internal event onDidChangeProjectsEmitter.fire(); + // 7. Assert the public event fired with the correct delta assert.ok(firedEventPayload, 'Event should have fired'); - assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 1); + assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 1, 'Should have 1 added project'); assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added[0].uri.fsPath, newProject.uri.fsPath); - assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 0); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 0, 'Should have 0 removed projects'); + // 8. Simulate removing the project firedEventPayload = null; currentProjects = []; onDidChangeProjectsEmitter.fire(); assert.ok(firedEventPayload, 'Event should have fired'); - assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 0); - assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 1); - assert.strictEqual( - (firedEventPayload as { removed: PythonProject[] }).removed[0].uri.fsPath, - newProject.uri.fsPath, - ); - }); -}); - -suite('PythonEnvironmentApiImpl - getEnvironment timeout fallback', () => { - let clock: sinon.SinonFakeTimers; - - setup(() => { - clock = sinon.useFakeTimers(); - sinon.stub(managerReady, 'waitForEnvManager').resolves(); - }); - - teardown(() => { - sinon.restore(); - }); - - test('returns the last-known environment while a slower lookup continues in the background', async () => { - const scope = Uri.file('/workspace/script.py'); - const lastKnown: PythonEnvironment = { - envId: { id: 'default', managerId: 'ms-python.python:venv' }, - name: 'default', - displayName: 'default', - displayPath: '/env/default', - version: '3.11.0', - environmentPath: Uri.file('/env/default'), - execInfo: { run: { executable: '/env/default/python', args: [] } }, - sysPrefix: '/env/default', - }; - let resolveEnvironment: ((value: PythonEnvironment | undefined) => void) | undefined; - - const mockProjectManager = { - getProjects: () => [], - onDidChangeProjects: new EventEmitter().event, - } as unknown as PythonProjectManager; - - type ApiArgs = ConstructorParameters; - const mockEnvManagers = { - onDidChangeActiveEnvironment: new EventEmitter().event, - getEnvironment: sinon.stub().returns( - new Promise((resolve) => { - resolveEnvironment = resolve; - }), - ), - getLastKnownEnvironment: sinon.stub().withArgs(scope).returns(lastKnown), - } as unknown as ApiArgs[0]; - const mockProjectCreators = {} as unknown as ApiArgs[2]; - const mockTerminalManager = {} as unknown as ApiArgs[3]; - const mockEnvVarManager = { onDidChangeEnvironmentVariables: new EventEmitter().event } as unknown as ApiArgs[4]; - - const api = new PythonEnvironmentApiImpl( - mockEnvManagers, - mockProjectManager, - mockProjectCreators, - mockTerminalManager, - mockEnvVarManager, - ); - - const pending = api.getEnvironment(scope); - await clock.tickAsync(1_000); - - assert.strictEqual(await pending, lastKnown); - resolveEnvironment?.(undefined); + assert.strictEqual((firedEventPayload as { added: PythonProject[] }).added.length, 0, 'Should have 0 added projects'); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed.length, 1, 'Should have 1 removed project'); + assert.strictEqual((firedEventPayload as { removed: PythonProject[] }).removed[0].uri.fsPath, newProject.uri.fsPath); }); -}); +}); \ No newline at end of file diff --git a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts index dc4dca486..3d0488cae 100644 --- a/src/test/managers/builtin/inlineScript/envManager.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/envManager.unit.test.ts @@ -6,18 +6,16 @@ import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as sinon from 'sinon'; -import { Disposable, LogOutputChannel, TextDocument, Uri } from 'vscode'; +import { LogOutputChannel, Uri } from 'vscode'; import { 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'; -import { InlineScriptRoutingRegistry } from '../../../../common/inlineScript/routingRegistry'; import * as lockfileApis from '../../../../common/lockfile.apis'; import * as persistentState from '../../../../common/persistentState'; import { isWindows } from '../../../../common/utils/platformUtils'; import { normalizePath } from '../../../../common/utils/pathUtils'; import { getVenvPythonPath } from '../../../../common/utils/virtualEnvironment'; -import * as workspaceApis from '../../../../common/workspace.apis'; import { InlineScriptEnvManager, INLINE_SCRIPT_ENVS_KEY, @@ -34,10 +32,6 @@ const VALID_METADATA: metadataReader.InlineScriptMetadata = { dependencies: ['requests'], range: { start: 0, end: 40 }, }; -const VALID_METADATA_IDENTITY = JSON.stringify({ - requiresPython: '>=3.11', - dependencies: ['requests'], -}); function makeFakeLog(): LogOutputChannel { return { @@ -115,15 +109,9 @@ suite('InlineScriptEnvManager', () => { let releaseLockStub: sinon.SinonStub; let resolveSystemPythonStub: sinon.SinonStub; let resolveVenvStub: sinon.SinonStub; - let routingRegistry: InlineScriptRoutingRegistry; - let sidecarsByEnvDir: Map; - let environmentsByExecutablePath: Map; - let cacheKeysByInputs: Map; let tempRoot: string; let baseInterpreterStatusStub: sinon.SinonStub; let writeMetaStub: sinon.SinonStub; - let deleteFilesListener: ((e: { files: readonly Uri[] }) => unknown) | undefined; - let renameFilesListener: ((e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) | undefined; let workspaceState: { get: sinon.SinonStub; set: sinon.SinonStub; @@ -145,12 +133,6 @@ suite('InlineScriptEnvManager', () => { refreshEnvironments: apiRefreshEnvironmentsStub, } as unknown as PythonEnvironmentApi; nativeFinder = {} as NativePythonFinder; - routingRegistry = new InlineScriptRoutingRegistry(); - sidecarsByEnvDir = new Map(); - environmentsByExecutablePath = new Map(); - cacheKeysByInputs = new Map(); - deleteFilesListener = undefined; - renameFilesListener = undefined; baseManager = {} as EnvironmentManager; persistedAssociations = undefined; workspaceState = { @@ -167,75 +149,38 @@ suite('InlineScriptEnvManager', () => { sinon.stub(persistentState, 'getWorkspacePersistentState').resolves(workspaceState); readMetadataStub = sinon.stub(metadataReader, 'readInlineScriptMetadataFromFile').resolves(VALID_METADATA); - computeCacheKeyStub = sinon.stub(cacheKey, 'computeCacheKey').callsFake((inputs) => { - return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; - }); - registerCacheKey(CACHE_KEY, VALID_METADATA.dependencies ?? [], baseExecutable); + computeCacheKeyStub = sinon.stub(cacheKey, 'computeCacheKey').returns(CACHE_KEY); getAvailablePythonVersionsStub = sinon.stub(uvPythonInstaller, 'getAvailablePythonVersions').resolves([]); ensureUvForVersionLookupStub = sinon .stub(uvPythonInstaller, 'ensureUvForInlineScriptVersionLookup') .resolves(true); promptInstallPythonViaUvStub = sinon.stub(uvPythonInstaller, 'promptInstallPythonViaUv'); - inspectMetaStub = sinon.stub(cacheLayout, 'inspectMetaJson').callsFake(async (envDir: Uri) => { - const result = sidecarsByEnvDir.get(normalizePath(envDir.fsPath)) ?? 'missing'; - if (result === 'missing' || result === 'invalid' || result === 'unavailable') { - return { kind: result }; - } - return { kind: 'valid', metadata: result }; - }); + inspectMetaStub = sinon.stub(cacheLayout, 'inspectMetaJson').resolves({ kind: 'missing' }); baseInterpreterStatusStub = sinon.stub(cacheLayout, 'getBaseInterpreterStatus').resolves('available'); - writeMetaStub = sinon.stub(cacheLayout, 'writeMetaJson').callsFake(async (envDir: Uri, meta: cacheLayout.InlineScriptEnvMeta) => { - sidecarsByEnvDir.set(normalizePath(envDir.fsPath), meta); - }); + writeMetaStub = sinon.stub(cacheLayout, 'writeMetaJson').resolves(); retainLockStub = sinon.stub().resolves(); releaseLockStub = sinon.stub().resolves(); lockStub = sinon .stub(lockfileApis, 'acquireFileLock') .resolves({ release: releaseLockStub, retain: retainLockStub }); resolveSystemPythonStub = sinon.stub(builtinUtils, 'resolveSystemPythonEnvironmentPath').resolves(undefined); - resolveVenvStub = sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').callsFake(async (environmentPath: string) => { - return environmentsByExecutablePath.get(normalizePath(environmentPath)); - }); - sinon.stub(workspaceApis, 'onDidDeleteFiles').callsFake((listener: (e: { files: readonly Uri[] }) => unknown) => { - deleteFilesListener = listener; - return new Disposable(() => { - deleteFilesListener = undefined; - }); - }); - sinon - .stub(workspaceApis, 'onDidRenameFiles') - .callsFake((listener: (e: { files: readonly { oldUri: Uri; newUri: Uri }[] }) => unknown) => { - renameFilesListener = listener; - return new Disposable(() => { - renameFilesListener = undefined; - }); - }); - sinon.stub(workspaceApis, 'getOpenTextDocuments').returns([]); + resolveVenvStub = sinon.stub(venvUtils, 'resolveVenvPythonEnvironmentPath').resolves(undefined); createWithProgressStub = sinon.stub(venvUtils, 'createWithProgress').callsFake(async (...args: unknown[]) => { const envDir = args[6] as string; const selectedBase = args[4] as PythonEnvironment; await fs.outputFile(getVenvPythonPath(envDir), ''); - const environment = makeEnvironment( - 'ms-python.python:inline-script', - selectedBase.version, - getVenvPythonPath(envDir), - envDir, - ); - environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); return { - environment, + environment: makeEnvironment( + 'ms-python.python:inline-script', + selectedBase.version, + getVenvPythonPath(envDir), + envDir, + ), }; }); clock = sinon.useFakeTimers({ now: NOW, toFake: ['Date'] }); - manager = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - routingRegistry, - ); + manager = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); }); teardown(async () => { @@ -252,21 +197,8 @@ suite('InlineScriptEnvManager', () => { return cacheLayout.getScriptEnvDir(globalStorageUri, CACHE_KEY); } - function getCacheKeyInputKey(dependencies: readonly string[], interpreterPath: string): string { - return JSON.stringify({ - dependencies: Array.from( - new Set(dependencies.map((dependency) => cacheKey.normalizeDependency(dependency)).filter(Boolean)), - ).sort(), - interpreterPath: normalizePath(interpreterPath), - }); - } - - function registerCacheKey(cacheKeyValue: string, dependencies: readonly string[], interpreterPath: string): void { - cacheKeysByInputs.set(getCacheKeyInputKey(dependencies, interpreterPath), cacheKeyValue); - } - - function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta, targetEnvDir: Uri = envDir()): void { - sidecarsByEnvDir.set(normalizePath(targetEnvDir.fsPath), metadata); + function setSidecar(metadata: cacheLayout.InlineScriptEnvMeta): void { + inspectMetaStub.resolves({ kind: 'valid', metadata }); } async function createOwnedEnvironment( @@ -275,25 +207,11 @@ suite('InlineScriptEnvManager', () => { ): Promise { const location = cacheLayout.getScriptEnvDir(globalStorageUri, cacheKey).fsPath; const executable = getVenvPythonPath(location); - const baseInterpreterPath = - cacheKey === CACHE_KEY - ? baseExecutable - : path.join(tempRoot, `base-python-${cacheKey}`, isWindows() ? 'python.exe' : 'python'); - await fs.outputFile(baseInterpreterPath, ''); await fs.outputFile(executable, ''); - registerCacheKey(cacheKey, VALID_METADATA.dependencies ?? [], baseInterpreterPath); - setSidecar({ - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath, - baseInterpreterVersion: baseEnvironment.version, - lastUsedAt: NOW.toISOString(), - }, Uri.file(location)); - const environment = { + return { ...makeEnvironment('ms-python.python:inline-script', '3.12.4', executable, location), envId: { managerId: 'ms-python.python:inline-script', id: envId }, }; - environmentsByExecutablePath.set(normalizePath(executable), environment); - return environment; } async function waitForStubCall(stub: sinon.SinonStub): Promise { @@ -306,133 +224,10 @@ suite('InlineScriptEnvManager', () => { assert.fail('Expected the stub to be called'); } - async function waitForStubCallCount(stub: { callCount: number }, count: number): Promise { - for (let attempt = 0; attempt < 20; attempt += 1) { - if (stub.callCount >= count) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 5)); - } - assert.fail(`Expected the stub to be called at least ${count} times`); - } - function nextTurn(): Promise { return new Promise((resolve) => setImmediate(resolve)); } - function fireDelete(...files: Uri[]): void { - assert.ok(deleteFilesListener, 'delete listener should be registered'); - deleteFilesListener!({ files }); - } - - function fireRename(oldUri: Uri, newUri: Uri): void { - assert.ok(renameFilesListener, 'rename listener should be registered'); - renameFilesListener!({ files: [{ oldUri, newUri }] }); - } - - function workspaceStateSetCalls(key: string): readonly sinon.SinonSpyCall[] { - return workspaceState.set.getCalls().filter((call) => call.args[0] === key); - } - - function matchedAssociationRecord(environmentPath: string, metadataIdentity: string = VALID_METADATA_IDENTITY): unknown { - return { - schemaVersion: 1, - environmentPath, - metadataBinding: { - kind: 'matched', - sourceIdentity: metadataIdentity, - }, - }; - } - - function pendingAssociationRecord(environmentPath: string, metadataIdentity: string = VALID_METADATA_IDENTITY): unknown { - return { - schemaVersion: 1, - environmentPath, - metadataBinding: { - kind: 'pending', - sourceIdentity: metadataIdentity, - }, - }; - } - - function futureAssociationRecord(environmentPath: string): unknown { - return { - schemaVersion: 2, - environmentPath, - metadataBinding: { - kind: 'matched', - sourceIdentity: 'future', - }, - }; - } - - async function triggerSavedMetadataChange( - registry: InlineScriptRoutingRegistry, - managerInstance: InlineScriptEnvManager, - uri: Uri, - metadata: metadataReader.InlineScriptMetadata = VALID_METADATA, - ): Promise { - registry.setMetadata(uri, metadata); - await ( - managerInstance as unknown as { - handleSavedMetadataChange(event: { - uri: Uri; - metadata: metadataReader.InlineScriptMetadata; - metadataIdentity: string | undefined; - metadataRevision: number; - }): Promise; - } - ).handleSavedMetadataChange({ - uri, - metadata, - metadataIdentity: registry.getMetadataIdentity(uri), - metadataRevision: registry.getMetadataRevision(uri), - }); - } - - function asMetadataRefreshManager(managerInstance: InlineScriptEnvManager): { - refreshValidatedAssociationForMetadataInternal( - scriptPath: string, - uri: Uri, - metadata: metadataReader.InlineScriptMetadata, - metadataIdentity: string, - metadataRevision: number, - associationRevision: number, - ): Promise; - currentCacheEntryProvesSourceMetadataIdentity( - candidate: PythonEnvironment, - metadataIdentity: string, - metadata: metadataReader.InlineScriptMetadata, - ): Promise; - cachedAssociationValidatedAt: Map; - lastValidatedMetadataIdentities: Map; - lastValidatedMetadataIdentityProofs: Map; - associationRevisions: Map; - subscriptions: Disposable[]; - } { - return managerInstance as unknown as { - refreshValidatedAssociationForMetadataInternal( - scriptPath: string, - uri: Uri, - metadata: metadataReader.InlineScriptMetadata, - metadataIdentity: string, - metadataRevision: number, - associationRevision: number, - ): Promise; - currentCacheEntryProvesSourceMetadataIdentity( - candidate: PythonEnvironment, - metadataIdentity: string, - metadata: metadataReader.InlineScriptMetadata, - ): Promise; - cachedAssociationValidatedAt: Map; - lastValidatedMetadataIdentities: Map; - lastValidatedMetadataIdentityProofs: Map; - associationRevisions: Map; - subscriptions: Disposable[]; - }; - } - suite('static metadata and deferred methods', () => { test('exposes creation but leaves later-phase methods empty', async () => { const asInterface: EnvironmentManager = manager; @@ -1166,9 +961,6 @@ suite('InlineScriptEnvManager', () => { baseInterpreterPath: baseExecutable, baseInterpreterVersion: baseEnvironment.version, lastUsedAt: NOW.toISOString(), - sourceMetadataIdentityHashes: [ - cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), - ], }, ]); assert.strictEqual( @@ -1234,482 +1026,6 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(createWithProgressStub.callCount, 1); }); - test('records every successful same-key coalesced caller provenance for later set and restart routing', async () => { - const cacheKeyValue = 'fedcba9876543210'; - const firstUri = scriptUri('a.py'); - const secondUri = scriptUri('b.py'); - const secondMetadata = { - ...VALID_METADATA, - requiresPython: '>=3.12', - } satisfies metadataReader.InlineScriptMetadata; - const firstIdentity = VALID_METADATA_IDENTITY; - const secondIdentity = JSON.stringify({ - requiresPython: secondMetadata.requiresPython, - dependencies: secondMetadata.dependencies, - }); - const metadataByScript = new Map([ - [normalizePath(firstUri.fsPath), VALID_METADATA], - [normalizePath(secondUri.fsPath), secondMetadata], - ]); - readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); - routingRegistry.setMetadata(firstUri, VALID_METADATA); - routingRegistry.setMetadata(secondUri, secondMetadata); - registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); - - 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((inputs: cacheKey.CacheKeyInputs) => { - if (computeCacheKeyStub.callCount === 2) { - secondCallHashed!(); - } - return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; - }); - createWithProgressStub.callsFake(async (...args: unknown[]) => { - const target = args[6] as string; - await fs.outputFile(venvPythonPath(target), ''); - const environment = makeEnvironment( - 'ms-python.python:inline-script', - '3.12.4', - venvPythonPath(target), - target, - ); - environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); - creationStarted!(); - await gate; - return { environment }; - }); - - const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); - await started; - const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); - await secondHashed; - continueCreation!(); - const [firstEnvironment, secondEnvironment] = await Promise.all([first, second]); - - assert.ok(firstEnvironment); - assert.strictEqual(firstEnvironment, secondEnvironment); - assert.strictEqual(lockStub.callCount, 1); - assert.strictEqual(createWithProgressStub.callCount, 1); - assert.deepStrictEqual( - ( - sidecarsByEnvDir.get( - normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), - ) as cacheLayout.InlineScriptEnvMeta - ).sourceMetadataIdentityHashes, - [ - cacheLayout.hashSourceMetadataIdentity(firstIdentity), - cacheLayout.hashSourceMetadataIdentity(secondIdentity), - ], - ); - - await manager.set(firstUri, firstEnvironment); - await manager.set(secondUri, secondEnvironment); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(firstUri.fsPath)]: matchedAssociationRecord(firstEnvironment.environmentPath.fsPath, firstIdentity), - [normalizePath(secondUri.fsPath)]: matchedAssociationRecord(secondEnvironment!.environmentPath.fsPath, secondIdentity), - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(firstUri), true); - assert.strictEqual(routingRegistry.hasValidatedAssociation(secondUri), true); - - persistedAssociations = {}; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - restartRoutingRegistry.setMetadata(firstUri, VALID_METADATA); - restartRoutingRegistry.setMetadata(secondUri, secondMetadata); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - - await restarted.set(firstUri, firstEnvironment); - await restarted.set(secondUri, secondEnvironment); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(firstUri.fsPath)]: matchedAssociationRecord(firstEnvironment.environmentPath.fsPath, firstIdentity), - [normalizePath(secondUri.fsPath)]: matchedAssociationRecord(secondEnvironment!.environmentPath.fsPath, secondIdentity), - }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(firstUri), true); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(secondUri), true); - restarted.dispose(); - }); - - test('merges a late same-key caller that arrives while the initial sidecar write is in flight', async () => { - const cacheKeyValue = 'fedcba9876543210'; - const firstUri = scriptUri('a.py'); - const secondUri = scriptUri('b.py'); - const secondMetadata = { - ...VALID_METADATA, - requiresPython: '>=3.12', - } satisfies metadataReader.InlineScriptMetadata; - const secondIdentity = JSON.stringify({ - requiresPython: secondMetadata.requiresPython, - dependencies: secondMetadata.dependencies, - }); - const firstHash = cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY); - const secondHash = cacheLayout.hashSourceMetadataIdentity(secondIdentity); - const metadataByScript = new Map([ - [normalizePath(firstUri.fsPath), VALID_METADATA], - [normalizePath(secondUri.fsPath), secondMetadata], - ]); - readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); - registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); - let secondCallHashed: (() => void) | undefined; - const secondHashed = new Promise((resolve) => { - secondCallHashed = resolve; - }); - computeCacheKeyStub.callsFake((inputs: cacheKey.CacheKeyInputs) => { - if (computeCacheKeyStub.callCount === 2) { - secondCallHashed!(); - } - return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; - }); - - let releaseFirstWrite: (() => void) | undefined; - let firstWriteStarted: (() => void) | undefined; - const firstWriteGate = new Promise((resolve) => { - releaseFirstWrite = resolve; - }); - const firstWritePending = new Promise((resolve) => { - firstWriteStarted = resolve; - }); - let firstWrittenHashes: readonly string[] | undefined; - writeMetaStub.callsFake(async (envDir: Uri, meta: cacheLayout.InlineScriptEnvMeta) => { - if (writeMetaStub.callCount === 1) { - firstWrittenHashes = meta.sourceMetadataIdentityHashes; - firstWriteStarted!(); - await firstWriteGate; - } - sidecarsByEnvDir.set(normalizePath(envDir.fsPath), meta); - }); - createWithProgressStub.callsFake(async (...args: unknown[]) => { - const target = args[6] as string; - await fs.outputFile(venvPythonPath(target), ''); - const environment = makeEnvironment( - 'ms-python.python:inline-script', - '3.12.4', - venvPythonPath(target), - target, - ); - environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); - return { environment }; - }); - - const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); - await firstWritePending; - const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); - await secondHashed; - const pendingCreations = ( - manager as unknown as { - pendingCreations: Map; - } - ).pendingCreations; - for (let attempt = 0; attempt < 20; attempt += 1) { - if (pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes?.includes(secondHash)) { - break; - } - await nextTurn(); - } - - assert.deepStrictEqual(firstWrittenHashes, [firstHash]); - assert.strictEqual( - pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes?.includes(secondHash), - true, - ); - - releaseFirstWrite!(); - const [firstEnvironment, secondEnvironment] = await Promise.all([first, second]); - - assert.ok(firstEnvironment); - assert.strictEqual(firstEnvironment, secondEnvironment); - assert.strictEqual(createWithProgressStub.callCount, 1); - assert.strictEqual(lockStub.callCount, 2); - assert.deepStrictEqual( - ( - sidecarsByEnvDir.get( - normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), - ) as cacheLayout.InlineScriptEnvMeta - ).sourceMetadataIdentityHashes, - [firstHash, secondHash], - ); - }); - - for (const failureMode of ['lock', 'read', 'write'] as const) { - test(`late same-key caller returns undefined when durable provenance merge ${failureMode} fails, but first caller and retry succeed`, async () => { - const cacheKeyValue = 'fedcba9876543210'; - const firstUri = scriptUri('a.py'); - const secondUri = scriptUri('b.py'); - const secondMetadata = { - ...VALID_METADATA, - requiresPython: '>=3.12', - } satisfies metadataReader.InlineScriptMetadata; - const secondIdentity = JSON.stringify({ - requiresPython: secondMetadata.requiresPython, - dependencies: secondMetadata.dependencies, - }); - const firstHash = cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY); - const secondHash = cacheLayout.hashSourceMetadataIdentity(secondIdentity); - const metadataByScript = new Map([ - [normalizePath(firstUri.fsPath), VALID_METADATA], - [normalizePath(secondUri.fsPath), secondMetadata], - ]); - readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); - registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); - let secondCallHashed: (() => void) | undefined; - const secondHashed = new Promise((resolve) => { - secondCallHashed = resolve; - }); - computeCacheKeyStub.callsFake((inputs: cacheKey.CacheKeyInputs) => { - if (computeCacheKeyStub.callCount === 2) { - secondCallHashed!(); - } - return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; - }); - - let releaseFirstWrite: (() => void) | undefined; - let firstWriteStarted: (() => void) | undefined; - const firstWriteGate = new Promise((resolve) => { - releaseFirstWrite = resolve; - }); - const firstWritePending = new Promise((resolve) => { - firstWriteStarted = resolve; - }); - writeMetaStub.callsFake(async (envDir: Uri, meta: cacheLayout.InlineScriptEnvMeta) => { - if (writeMetaStub.callCount === 1) { - firstWriteStarted!(); - await firstWriteGate; - } - sidecarsByEnvDir.set(normalizePath(envDir.fsPath), meta); - }); - if (failureMode === 'lock') { - lockStub.onSecondCall().rejects(new Error('merge lock failed')); - } else if (failureMode === 'read') { - inspectMetaStub.onFirstCall().rejects(new Error('merge read failed')); - } else { - writeMetaStub.onSecondCall().rejects(new Error('merge write failed')); - } - createWithProgressStub.callsFake(async (...args: unknown[]) => { - const target = args[6] as string; - await fs.outputFile(venvPythonPath(target), ''); - const environment = makeEnvironment( - 'ms-python.python:inline-script', - '3.12.4', - venvPythonPath(target), - target, - ); - environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); - return { environment }; - }); - - const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); - await firstWritePending; - const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); - await secondHashed; - releaseFirstWrite!(); - const [firstEnvironment, secondEnvironment] = await Promise.all([first, second]); - - assert.ok(firstEnvironment); - assert.strictEqual(secondEnvironment, undefined); - assert.strictEqual(createWithProgressStub.callCount, 1); - assert.deepStrictEqual( - ( - sidecarsByEnvDir.get( - normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), - ) as cacheLayout.InlineScriptEnvMeta - ).sourceMetadataIdentityHashes, - [firstHash], - ); - - const retried = await manager.create(secondUri, { additionalPackages: ['pytest'] }); - - assert.ok(retried); - assert.strictEqual(normalizePath(retried!.environmentPath.fsPath), normalizePath(firstEnvironment.environmentPath.fsPath)); - assert.deepStrictEqual( - ( - sidecarsByEnvDir.get( - normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), - ) as cacheLayout.InlineScriptEnvMeta - ).sourceMetadataIdentityHashes, - [firstHash, secondHash], - ); - assert.strictEqual(createWithProgressStub.callCount, 1); - }); - } - - test('does not record provenance when a shared same-key creation fails', async () => { - const firstUri = scriptUri('a.py'); - const secondUri = scriptUri('b.py'); - const secondMetadata = { - ...VALID_METADATA, - requiresPython: '>=3.12', - } satisfies metadataReader.InlineScriptMetadata; - const metadataByScript = new Map([ - [normalizePath(firstUri.fsPath), VALID_METADATA], - [normalizePath(secondUri.fsPath), secondMetadata], - ]); - readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); - registerCacheKey(CACHE_KEY, ['requests', 'pytest'], baseExecutable); - - 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((inputs: cacheKey.CacheKeyInputs) => { - if (computeCacheKeyStub.callCount === 2) { - secondCallHashed!(); - } - return cacheKeysByInputs.get(getCacheKeyInputKey(inputs.dependencies, inputs.interpreterPath)) ?? CACHE_KEY; - }); - createWithProgressStub.callsFake(async () => { - creationStarted!(); - await gate; - return { envCreationErr: 'boom' }; - }); - - const first = manager.create(firstUri, { additionalPackages: ['pytest'] }); - await started; - const second = manager.create(secondUri, { additionalPackages: ['pytest'] }); - await secondHashed; - continueCreation!(); - - assert.deepStrictEqual(await Promise.all([first, second]), [undefined, undefined]); - assert.strictEqual(writeMetaStub.callCount, 0); - assert.strictEqual(sidecarsByEnvDir.size, 0); - }); - - test('dedupes and caps coalesced same-key provenance hashes before the first sidecar write', async () => { - const cacheKeyValue = 'fedcba9876543210'; - const scriptSpecs = [ - ['script-0.py', '>=3.0'], - ['script-1.py', '>=3.1'], - ['script-2.py', '>=3.2'], - ['script-3.py', '>=3.3'], - ['script-4.py', '>=3.4'], - ['script-5.py', '>=3.5'], - ['script-6.py', '>=3.6'], - ['script-7.py', '>=3.7'], - ['script-8.py', '>=3.8'], - ['script-9.py', '>=3.8'], - ] as const; - const metadataByScript = new Map( - scriptSpecs.map(([name, requiresPython]) => [ - normalizePath(scriptUri(name).fsPath), - { - ...VALID_METADATA, - requiresPython, - }, - ]), - ); - let expectedHashes: readonly string[] | undefined; - for (const [, requiresPython] of scriptSpecs) { - expectedHashes = cacheLayout.mergeSourceMetadataIdentityHashes( - expectedHashes, - cacheLayout.hashSourceMetadataIdentity( - JSON.stringify({ - requiresPython, - dependencies: ['requests'], - }), - ), - ); - } - readMetadataStub.callsFake(async (uri: Uri) => metadataByScript.get(normalizePath(uri.fsPath))); - registerCacheKey(cacheKeyValue, ['requests', 'pytest'], baseExecutable); - - let continueCreation: (() => void) | undefined; - let creationStarted: (() => void) | undefined; - const started = new Promise((resolve) => { - creationStarted = resolve; - }); - const gate = new Promise((resolve) => { - continueCreation = resolve; - }); - createWithProgressStub.callsFake(async (...args: unknown[]) => { - const target = args[6] as string; - await fs.outputFile(venvPythonPath(target), ''); - const environment = makeEnvironment( - 'ms-python.python:inline-script', - '3.12.4', - venvPythonPath(target), - target, - ); - environmentsByExecutablePath.set(normalizePath(environment.environmentPath.fsPath), environment); - creationStarted!(); - await gate; - return { environment }; - }); - - const pendingCreates = [manager.create(scriptUri(scriptSpecs[0][0]), { additionalPackages: ['pytest'] })]; - await started; - const pendingCreations = ( - manager as unknown as { - pendingCreations: Map; - } - ).pendingCreations; - const addPendingCreationSourceMetadataIdentityHashStub = sinon - .stub( - manager as unknown as { - addPendingCreationSourceMetadataIdentityHash( - pendingCreation: { sourceMetadataIdentityHashes?: readonly string[] }, - sourceMetadataIdentityHash: string | undefined, - ): void; - }, - 'addPendingCreationSourceMetadataIdentityHash', - ) - .callThrough(); - for (const [name, requiresPython] of scriptSpecs.slice(1)) { - const hash = cacheLayout.hashSourceMetadataIdentity( - JSON.stringify({ - requiresPython, - dependencies: ['requests'], - }), - ); - pendingCreates.push(manager.create(scriptUri(name), { additionalPackages: ['pytest'] })); - await waitForStubCallCount(addPendingCreationSourceMetadataIdentityHashStub, pendingCreates.length - 1); - assert.strictEqual( - pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes?.includes(hash), - true, - ); - } - assert.deepStrictEqual( - [...(pendingCreations.get(cacheKeyValue)?.sourceMetadataIdentityHashes ?? [])].sort(), - [...(expectedHashes ?? [])].sort(), - ); - continueCreation!(); - const environments = await Promise.all(pendingCreates); - - assert.ok(environments[0]); - assert.ok(environments.every((environment) => environment === environments[0])); - assert.strictEqual(lockStub.callCount, 1); - const sourceMetadataIdentityHashes = ( - sidecarsByEnvDir.get( - normalizePath(cacheLayout.getScriptEnvDir(globalStorageUri, cacheKeyValue).fsPath), - ) as cacheLayout.InlineScriptEnvMeta - ).sourceMetadataIdentityHashes; - assert.deepStrictEqual([...(sourceMetadataIdentityHashes ?? [])].sort(), [...(expectedHashes ?? [])].sort()); - assert.strictEqual(sourceMetadataIdentityHashes?.length, cacheLayout.MAX_SOURCE_METADATA_IDENTITY_HASHES); - assert.strictEqual(sourceMetadataIdentityHashes ? new Set(sourceMetadataIdentityHashes).size : 0, sourceMetadataIdentityHashes?.length); - }); - 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); @@ -1744,24 +1060,17 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(resolveVenvStub.firstCall.args[0], venvPythonPath(envDir().fsPath)); assert.deepStrictEqual(writeMetaStub.firstCall.args, [ envDir(), - { - ...sidecar, - lastUsedAt: NOW.toISOString(), - sourceMetadataIdentityHashes: [ - cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), - ], - }, + { ...sidecar, lastUsedAt: NOW.toISOString() }, ]); }); - test('merges the current metadata identity hash into a reused cache sidecar', async () => { + 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', - sourceMetadataIdentityHashes: [cacheLayout.hashSourceMetadataIdentity('{"requiresPython":">=3.12","dependencies":["rich"]}')], }); const cached = makeEnvironment( 'ms-python.python:inline-script', @@ -1771,121 +1080,48 @@ suite('InlineScriptEnvManager', () => { ); await fs.outputFile(venvPythonPath(envDir().fsPath), ''); resolveVenvStub.resolves(cached); + writeMetaStub.rejects(new Error('read-only filesystem')); - await manager.create(scriptUri()); + assert.strictEqual(await manager.create(scriptUri()), cached); + assert.strictEqual(createWithProgressStub.callCount, 0); + }); - assert.deepStrictEqual(writeMetaStub.firstCall.args[1], { + 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(), - sourceMetadataIdentityHashes: [ - cacheLayout.hashSourceMetadataIdentity('{"requiresPython":">=3.12","dependencies":["rich"]}'), - cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), - ], }); + 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('dedupes and caps reused cache provenance hashes', async () => { + test('removes and rebuilds a cache entry whose sidecar names another base', async () => { await fs.ensureDir(envDir().fsPath); - const currentHash = cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY); - const hashes = [ - currentHash, - ...Array.from({ length: cacheLayout.MAX_SOURCE_METADATA_IDENTITY_HASHES - 1 }, (_, index) => - cacheLayout.hashSourceMetadataIdentity(`identity-${index}`), - ), - ]; setSidecar({ schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: baseExecutable, + baseInterpreterPath: path.join(tempRoot, 'different-python'), baseInterpreterVersion: baseEnvironment.version, - lastUsedAt: '2026-07-01T00:00:00.000Z', - sourceMetadataIdentityHashes: hashes, + lastUsedAt: NOW.toISOString(), }); - 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); - await manager.create(scriptUri()); + const result = await manager.create(scriptUri()); - assert.strictEqual((writeMetaStub.firstCall.args[1] as cacheLayout.InlineScriptEnvMeta).sourceMetadataIdentityHashes?.length, cacheLayout.MAX_SOURCE_METADATA_IDENTITY_HASHES); + assert.ok(result); + assert.strictEqual(resolveVenvStub.callCount, 0); + assert.strictEqual(createWithProgressStub.callCount, 1); }); - test('preserves a cache entry with a future sidecar schema version', async () => { - await fs.ensureDir(envDir().fsPath); - await fs.outputFile(venvPythonPath(envDir().fsPath), ''); - const markerPath = path.join(envDir().fsPath, 'keep.txt'); - await fs.outputFile(markerPath, 'keep'); - sidecarsByEnvDir.set(normalizePath(envDir().fsPath), 'unavailable'); - inspectMetaStub.callsFake(async () => ({ kind: 'unsupported' } as cacheLayout.InlineScriptMetaReadResult)); - - assert.strictEqual(await manager.create(scriptUri()), undefined); - assert.strictEqual(await fs.pathExists(markerPath), true); - }); - - 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('rebuilds when the base version changed at the same canonical path', async () => { + test('rebuilds when the base version changed at the same canonical path', async () => { await fs.ensureDir(envDir().fsPath); setSidecar({ schemaVersion: cacheLayout.META_SCHEMA_VERSION, @@ -2231,1214 +1467,102 @@ suite('InlineScriptEnvManager', () => { 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', () => { - manager.dispose(); - assert.doesNotThrow(() => manager.dispose()); - }); - }); - - suite('script association persistence', () => { - test('sets, gets, unsets, persists, and reports only actual selection changes', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - - await manager.set(uri, environment); - - assert.strictEqual(await manager.get(uri), environment); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(workspaceState.set.firstCall.args[0], INLINE_SCRIPT_ENVS_KEY); - assert.strictEqual(listener.callCount, 1); - assert.deepStrictEqual(listener.firstCall.args[0], { uri, old: undefined, new: environment }); - - await manager.set(uri, environment); - assert.strictEqual(listener.callCount, 1); - - await manager.set(uri, undefined); - assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(listener.callCount, 2); - assert.deepStrictEqual(listener.secondCall.args[0], { uri, old: environment, new: undefined }); - }); - - test('updates validated routing state when selections are set and unset', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - routingRegistry.setMetadata(uri, VALID_METADATA); - - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - - await manager.set(uri, environment); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); - - await manager.set(uri, undefined); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - }); - - test('persists the saved metadata identity separately from the environment path', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - - await manager.set(uri, environment); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }); - }); - - test('routes an environment created with additional packages by saved metadata identity', async () => { - const uri = scriptUri(); - routingRegistry.setMetadata(uri, VALID_METADATA); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - - await manager.set(uri, environment!); - - assert.strictEqual(await manager.get(uri), environment); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); - }); - - test('reselecting the same matched additional-packages environment after restart preserves matched provenance', async () => { - const uri = scriptUri(); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - await manager.set(uri, environment!); - - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - - await restarted.set(uri, environment!); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), - }); - restarted.dispose(); - }); - - test('create with additional packages can route after reload before the first set via sidecar provenance', async () => { - const uri = scriptUri(); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - persistedAssociations = {}; - - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - - await restarted.set(uri, environment!); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), - }); - restarted.dispose(); - }); - - test('does not reuse matched provenance after the same cache path is rebuilt for a different generation', async () => { - const uri = scriptUri(); - const cacheKeyValue = 'fedcba9876543210'; - routingRegistry.setMetadata(uri, VALID_METADATA); - const environment = await createOwnedEnvironment(cacheKeyValue); - setSidecar( - { - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: path.join( - tempRoot, - `base-python-${cacheKeyValue}`, - isWindows() ? 'python.exe' : 'python', - ), - baseInterpreterVersion: baseEnvironment.version, - lastUsedAt: NOW.toISOString(), - sourceMetadataIdentityHashes: [ - cacheLayout.hashSourceMetadataIdentity(VALID_METADATA_IDENTITY), - ], - }, - Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), - ); - - await manager.set(uri, environment); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), - }); - - const rebuiltMetadata = { - ...VALID_METADATA, - requiresPython: '>=3.12', - } satisfies metadataReader.InlineScriptMetadata; - const rebuiltBaseExecutable = path.join(tempRoot, 'rebuilt-base', isWindows() ? 'python.exe' : 'python'); - await fs.outputFile(rebuiltBaseExecutable, ''); - setSidecar( - { - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: rebuiltBaseExecutable, - baseInterpreterVersion: '3.12.9', - lastUsedAt: NOW.toISOString(), - sourceMetadataIdentityHashes: [ - cacheLayout.hashSourceMetadataIdentity( - JSON.stringify({ - requiresPython: rebuiltMetadata.requiresPython, - dependencies: rebuiltMetadata.dependencies, - }), - ), - ], - }, - Uri.file(path.dirname(path.dirname(environment!.environmentPath.fsPath))), - ); - - await manager.set(uri, environment); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: { - schemaVersion: 1, - environmentPath: environment!.environmentPath.fsPath, - metadataBinding: { kind: 'legacy' }, - }, - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - }); - - test('does not infer matched provenance when the sidecar source identity hash does not match', async () => { - const sourceUri = scriptUri('source.py'); - const targetUri = scriptUri('target.py'); - const sourceMetadata = { - ...VALID_METADATA, - dependencies: ['rich'], - } satisfies metadataReader.InlineScriptMetadata; - routingRegistry.setMetadata(targetUri, VALID_METADATA); - registerCacheKey('fedcba9876543210', ['rich', 'pytest'], baseExecutable); - readMetadataStub.resolves(sourceMetadata); - const environment = await manager.create(sourceUri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - readMetadataStub.resolves(VALID_METADATA); - - await manager.set(targetUri, environment!); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(targetUri.fsPath)]: { - schemaVersion: 1, - environmentPath: environment!.environmentPath.fsPath, - metadataBinding: { kind: 'legacy' }, - }, - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(targetUri), false); - }); - - test('reselecting a different owned env after restart does not inherit matched provenance', async () => { - const uri = scriptUri(); - const otherUri = scriptUri('other.py'); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const matchedEnvironment = await manager.create(uri, { additionalPackages: ['pytest'] }); - const otherMetadata = { - ...VALID_METADATA, - dependencies: ['urllib3'], - } satisfies metadataReader.InlineScriptMetadata; - registerCacheKey('0011223344556677', ['urllib3', 'pytest', 'rich'], baseExecutable); - readMetadataStub.resolves(otherMetadata); - const differentOwnedEnvironment = await manager.create(otherUri, { additionalPackages: ['pytest', 'rich'] }); - readMetadataStub.resolves(VALID_METADATA); - assert.ok(matchedEnvironment); - assert.ok(differentOwnedEnvironment); - await manager.set(uri, matchedEnvironment!); - - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - - await restarted.set(uri, differentOwnedEnvironment!); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: { - schemaVersion: 1, - environmentPath: differentOwnedEnvironment!.environmentPath.fsPath, - metadataBinding: { kind: 'legacy' }, - }, - }); - restarted.dispose(); - }); - - test('old sidecars without provenance keep additional-packages envs conservative on reload', async () => { - const uri = scriptUri(); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - const envDirPath = path.dirname(path.dirname(environment!.environmentPath.fsPath)); - setSidecar({ - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: baseExecutable, - baseInterpreterVersion: baseEnvironment.version, - lastUsedAt: NOW.toISOString(), - }, Uri.file(envDirPath)); - persistedAssociations = {}; - - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - - await restarted.set(uri, environment); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: { - schemaVersion: 1, - environmentPath: environment!.environmentPath.fsPath, - metadataBinding: { kind: 'legacy' }, - }, - }); - restarted.dispose(); - }); - - test('stores a pending verified binding for a dirty selection and promotes it on matching save', async () => { - const uri = scriptUri(); - const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; - openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - - await manager.set(uri, environment!); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - - openDocumentsStub.returns([]); - await triggerSavedMetadataChange(routingRegistry, manager, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment!.environmentPath.fsPath), - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); - }); - - test('dirty pending binding for the same path after restart keeps pending until saved metadata is consistent', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment('fedcba9876543210'); - persistedAssociations = { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }; - const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; - openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - - await restarted.set(uri, environment); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); - }); - - test('keeps a dirty pending binding non-routeable when the saved metadata identity no longer matches', async () => { - const uri = scriptUri(); - const changedMetadata = { - ...VALID_METADATA, - dependencies: ['urllib3'], - } satisfies metadataReader.InlineScriptMetadata; - const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; - openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - - await manager.set(uri, environment!); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), - }); - - openDocumentsStub.returns([]); - routingRegistry.setMetadata(uri, changedMetadata); - await triggerSavedMetadataChange(routingRegistry, manager, uri, changedMetadata); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - }); - - test('failed pending bind invalidates warm validation before a retry within 5s', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment('fedcba9876543210'); - persistedAssociations = { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - resolveVenvStub.resolves(environment); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - workspaceState.set.onFirstCall().rejects(new Error('Memento unavailable')); - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - await fs.remove(environment.environmentPath.fsPath); - clock.tick(5_000 - 1); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); - }); - - test('removes a dirty pending binding when the environment was deleted before save validation', async () => { - const uri = scriptUri(); - const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; - openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - - await manager.set(uri, environment!); - await fs.remove(environment!.environmentPath.fsPath); - - openDocumentsStub.returns([]); - routingRegistry.setMetadata(uri, VALID_METADATA); - await triggerSavedMetadataChange(routingRegistry, manager, uri); - - assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - }); - - test('keeps a dirty pending binding non-routeable when validation is transiently unavailable on save', async () => { - const uri = scriptUri(); - const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; - openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - - await manager.set(uri, environment!); - resolveVenvStub.resolves(undefined); - openDocumentsStub.returns([]); - routingRegistry.setMetadata(uri, VALID_METADATA); - await triggerSavedMetadataChange(routingRegistry, manager, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - }); - - test('keeps a dirty pending binding non-routeable when ownership validation changes on save', async () => { - const uri = scriptUri(); - const openDocumentsStub = workspaceApis.getOpenTextDocuments as unknown as sinon.SinonStub; - openDocumentsStub.returns([{ uri, isDirty: true } as unknown as TextDocument]); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - - await manager.set(uri, environment!); - resolveVenvStub.resolves({ - ...environment!, - envId: { ...environment!.envId, managerId: 'ms-python.python:system' }, - }); - openDocumentsStub.returns([]); - routingRegistry.setMetadata(uri, VALID_METADATA); - await triggerSavedMetadataChange(routingRegistry, manager, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment!.environmentPath.fsPath), - }); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - }); - - test('removes only the requested malformed entry while preserving valid and legacy records', async () => { - const invalidUri = scriptUri('invalid.py'); - const validUri = scriptUri('valid.py'); - const legacyUri = scriptUri('legacy.py'); - const validEnvironment = await createOwnedEnvironment('fedcba9876543210'); - const legacyEnvironment = await createOwnedEnvironment('0011223344556677'); - persistedAssociations = { - [normalizePath(invalidUri.fsPath)]: { schemaVersion: 1, environmentPath: '', metadataBinding: { kind: 'pending' } }, - [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), - [normalizePath(legacyUri.fsPath)]: legacyEnvironment.environmentPath.fsPath, - }; - resolveVenvStub.callsFake(async (environmentPath: string) => { - const normalized = normalizePath(environmentPath); - if (normalized === normalizePath(validEnvironment.environmentPath.fsPath)) { - return validEnvironment; - } - if (normalized === normalizePath(legacyEnvironment.environmentPath.fsPath)) { - return legacyEnvironment; - } - return undefined; - }); - - assert.strictEqual(await manager.get(invalidUri), undefined); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), - [normalizePath(legacyUri.fsPath)]: legacyEnvironment.environmentPath.fsPath, - }); - assert.strictEqual(await manager.get(validUri), validEnvironment); - assert.strictEqual(await manager.get(legacyUri), legacyEnvironment); - }); - - test('preserves unknown future-version entries when repairing a malformed requested entry', async () => { - const invalidUri = scriptUri('invalid.py'); - const futureUri = scriptUri('future.py'); - const futureEnvironment = await createOwnedEnvironment('8899aabbccddeeff'); - persistedAssociations = { - [normalizePath(invalidUri.fsPath)]: { schemaVersion: 1, environmentPath: '', metadataBinding: { kind: 'pending' } }, - [normalizePath(futureUri.fsPath)]: futureAssociationRecord(futureEnvironment.environmentPath.fsPath), - }; - - assert.strictEqual(await manager.get(invalidUri), undefined); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(futureUri.fsPath)]: futureAssociationRecord(futureEnvironment.environmentPath.fsPath), - }); - assert.strictEqual(await manager.get(futureUri), undefined); - }); - - test('removes a requested record with an unknown current binding kind without affecting unrelated entries', async () => { - const invalidUri = scriptUri('invalid.py'); - const validUri = scriptUri('valid.py'); - const validEnvironment = await createOwnedEnvironment('fedcba9876543210'); - persistedAssociations = { - [normalizePath(invalidUri.fsPath)]: { - schemaVersion: 1, - environmentPath: validEnvironment.environmentPath.fsPath, - metadataBinding: { kind: 'mystery' }, - }, - [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), - }; - resolveVenvStub.resolves(validEnvironment); - - assert.strictEqual(await manager.get(invalidUri), undefined); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), - }); - assert.strictEqual(await manager.get(validUri), validEnvironment); - }); - - test('persists a batch atomically and reports each distinct script URI exactly once', async () => { - const first = scriptUri('first.py'); - const second = scriptUri('second.py'); - const environment = await createOwnedEnvironment(); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - - await manager.set([first, second, first], environment); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(first.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - [normalizePath(second.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(workspaceStateSetCalls(INLINE_SCRIPT_ENVS_KEY).length, 1); - assert.strictEqual(listener.callCount, 2); - assert.strictEqual(listener.firstCall.args[0].uri, first); - assert.strictEqual(listener.secondCall.args[0].uri, second); - assert.strictEqual(await manager.get(first), environment); - assert.strictEqual(await manager.get(second), environment); - }); - - test('serializes concurrent selections so neither persisted association is lost', async () => { - const firstUri = scriptUri('first.py'); - const secondUri = scriptUri('second.py'); - const firstEnvironment = await createOwnedEnvironment(); - const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); - - await Promise.all([ - manager.set(firstUri, firstEnvironment), - manager.set(secondUri, secondEnvironment), - ]); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(firstUri.fsPath)]: matchedAssociationRecord(firstEnvironment.environmentPath.fsPath), - [normalizePath(secondUri.fsPath)]: matchedAssociationRecord(secondEnvironment.environmentPath.fsPath), - }); - assert.strictEqual(await manager.get(firstUri), firstEnvironment); - assert.strictEqual(await manager.get(secondUri), secondEnvironment); - }); - - test('does not let pending binding overwrite a newer unset', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - let resolvePending: ((value: PythonEnvironment | undefined) => void) | undefined; - resolveVenvStub.callsFake( - () => - new Promise((resolve) => { - resolvePending = resolve; - }), - ); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - const pendingBind = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - await waitForStubCall(resolveVenvStub); - await restarted.set(uri, undefined); - resolvePending!(environment); - await pendingBind; - - assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); - }); - - test('does not let pending binding overwrite a newer matched selection', async () => { - const uri = scriptUri(); - const oldEnvironment = await createOwnedEnvironment(); - const newEnvironment = await createOwnedEnvironment('fedcba9876543210'); - persistedAssociations = { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(oldEnvironment.environmentPath.fsPath), - }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - let resolvePending: ((value: PythonEnvironment | undefined) => void) | undefined; - resolveVenvStub.callsFake( - () => - new Promise((resolve) => { - resolvePending = resolve; - }), - ); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - const pendingBind = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - await waitForStubCall(resolveVenvStub); - await restarted.set(uri, newEnvironment); - resolvePending!(oldEnvironment); - await pendingBind; - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(newEnvironment.environmentPath.fsPath), - }); - assert.strictEqual(await restarted.get(uri), newEnvironment); - restarted.dispose(); - }); - - test('preserves a concurrent valid set while repairing an unrelated malformed entry', async () => { - const invalidUri = scriptUri('invalid.py'); - const validUri = scriptUri('valid.py'); - const validEnvironment = await createOwnedEnvironment('fedcba9876543210'); - persistedAssociations = { - [normalizePath(invalidUri.fsPath)]: { schemaVersion: 1, environmentPath: '', metadataBinding: { kind: 'pending' } }, - }; - - await Promise.all([manager.get(invalidUri), manager.set(validUri, validEnvironment)]); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(validUri.fsPath)]: matchedAssociationRecord(validEnvironment.environmentPath.fsPath), - }); - assert.strictEqual(await manager.get(validUri), validEnvironment); - }); - - test('leaves a pending binding non-routeable when persistence fails', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - resolveVenvStub.resolves(environment); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - ((restarted as unknown as { subscriptions: Disposable[] }).subscriptions[0]).dispose(); - workspaceState.set.onFirstCall().rejects(new Error('Memento unavailable')); - workspaceState.set.onSecondCall().rejects(new Error('Memento unavailable')); - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); - }); - - test('does not publish routeability from raw persisted associations after startup', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); - }); - - test('legacy string associations stay non-routeable after restart but remain retrievable', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - assert.strictEqual(await restarted.get(uri), environment); - restarted.dispose(); - }); - - test('routes a persisted matched additional-packages association on restart when the current sidecar hash matches', async () => { - const uri = scriptUri(); - routingRegistry.setMetadata(uri, VALID_METADATA); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - persistedAssociations = { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); - restarted.dispose(); - }); - - test('does not route a persisted matched association on restart when the same cache path was rebuilt for another identity', async () => { - const uri = scriptUri(); - const cacheKeyValue = 'fedcba9876543210'; - const environment = await createOwnedEnvironment(cacheKeyValue); - persistedAssociations = { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }; - const rebuiltMetadata = { - ...VALID_METADATA, - requiresPython: '>=3.12', - } satisfies metadataReader.InlineScriptMetadata; - const rebuiltBaseExecutable = path.join(tempRoot, 'rebuilt-base-restart', isWindows() ? 'python.exe' : 'python'); - await fs.outputFile(rebuiltBaseExecutable, ''); - setSidecar( - { - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: rebuiltBaseExecutable, - baseInterpreterVersion: '3.12.9', - lastUsedAt: NOW.toISOString(), - sourceMetadataIdentityHashes: [ - cacheLayout.hashSourceMetadataIdentity( - JSON.stringify({ - requiresPython: rebuiltMetadata.requiresPython, - dependencies: rebuiltMetadata.dependencies, - }), - ), - ], - }, - Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), - ); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - assert.strictEqual(await restarted.get(uri), environment); - restarted.dispose(); - }); - - test('does not promote a pending association when the current sidecar hash does not prove its source identity', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment('fedcba9876543210'); - persistedAssociations = { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }; - setSidecar( - { - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: path.join( - tempRoot, - 'base-python-fedcba9876543210', - isWindows() ? 'python.exe' : 'python', - ), - baseInterpreterVersion: baseEnvironment.version, - lastUsedAt: NOW.toISOString(), - sourceMetadataIdentityHashes: [ - cacheLayout.hashSourceMetadataIdentity('{"requiresPython":">=3.12","dependencies":["requests"]}'), - ], - }, - Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), - ); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: pendingAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); - }); - - test('preserves a persisted matched association with a future sidecar but leaves it non-routeable', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - const markerPath = path.join(environment.sysPrefix, 'keep.txt'); - await fs.outputFile(markerPath, 'keep'); - persistedAssociations = { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }; - inspectMetaStub.callsFake(async (envDir: Uri) => - normalizePath(envDir.fsPath) === normalizePath(environment.sysPrefix) - ? ({ kind: 'unsupported' } as cacheLayout.InlineScriptMetaReadResult) - : ({ kind: 'missing' } as cacheLayout.InlineScriptMetaReadResult), - ); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - assert.strictEqual(await restarted.get(uri), environment); - assert.strictEqual(await fs.pathExists(markerPath), true); - restarted.dispose(); - }); - - test('keeps a persisted matched additional-packages association non-routeable on restart when only an old sidecar remains', async () => { - const uri = scriptUri(); - routingRegistry.setMetadata(uri, VALID_METADATA); - registerCacheKey('fedcba9876543210', ['requests', 'pytest'], baseExecutable); - const environment = await manager.create(uri, { additionalPackages: ['pytest'] }); - assert.ok(environment); - setSidecar( - { - schemaVersion: cacheLayout.META_SCHEMA_VERSION, - baseInterpreterPath: baseExecutable, - baseInterpreterVersion: baseEnvironment.version, - lastUsedAt: NOW.toISOString(), - }, - Uri.file(path.dirname(path.dirname(environment.environmentPath.fsPath))), - ); - persistedAssociations = { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); - }); - - test('enables routeability only after persisted validation succeeds on restart', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - resolveVenvStub.resolves(environment); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - - const pending = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - await waitForStubCall(resolveVenvStub); - await pending; - - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); - restarted.dispose(); - }); - - test('keeps routeability disabled while persisted restart validation is still in flight', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - let resolveRehydration: ((value: PythonEnvironment) => void) | undefined; - resolveVenvStub.callsFake( - () => - new Promise((resolve) => { - resolveRehydration = resolve; - }), - ); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); + const environmentsListener = sinon.spy(); + const environmentListener = sinon.spy(); + manager.onDidChangeEnvironments(environmentsListener); + manager.onDidChangeEnvironment(environmentListener); - const pending = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - await waitForStubCall(resolveVenvStub); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - resolveRehydration!(environment); - await pending; + 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); + }); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); - restarted.dispose(); + test('dispose is idempotent', () => { + manager.dispose(); + assert.doesNotThrow(() => manager.dispose()); }); + }); - test('ignores a stale saved-metadata refresh when metadata changes while sidecar proof awaits', async () => { + suite('script association persistence', () => { + test('sets, gets, unsets, persists, and reports only actual selection changes', async () => { const uri = scriptUri(); - const scriptPath = normalizePath(uri.fsPath); const environment = await createOwnedEnvironment(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); + await manager.set(uri, environment); - const refreshManager = asMetadataRefreshManager(manager); - refreshManager.subscriptions[0].dispose(); - const validatedAtBefore = refreshManager.cachedAssociationValidatedAt.get(scriptPath); - assert.ok(validatedAtBefore !== undefined); - const routeabilityListener = sinon.spy(); - routingRegistry.onDidChangeRouteability(routeabilityListener); - clock.tick(1); - routingRegistry.setMetadata(uri, VALID_METADATA); - const metadataIdentity = routingRegistry.getMetadataIdentity(uri)!; - const metadataRevision = routingRegistry.getMetadataRevision(uri); - let resolveProof: ((value: boolean) => void) | undefined; - const proofStub = sinon.stub(refreshManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); - proofStub.onFirstCall().returns( - new Promise((resolve) => { - resolveProof = resolve; - }), - ); - const pendingRefresh = refreshManager.refreshValidatedAssociationForMetadataInternal( - scriptPath, - uri, - VALID_METADATA, - metadataIdentity, - metadataRevision, - refreshManager.associationRevisions.get(scriptPath) ?? 0, - ); - await waitForStubCall(proofStub); - routingRegistry.setMetadata(uri, { - ...VALID_METADATA, - requiresPython: '>=3.12', + assert.strictEqual(await manager.get(uri), environment); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); - resolveProof!(true); - await pendingRefresh; - - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - assert.strictEqual(routeabilityListener.callCount, 0); - assert.strictEqual(refreshManager.cachedAssociationValidatedAt.get(scriptPath), validatedAtBefore); - assert.strictEqual(refreshManager.lastValidatedMetadataIdentities.get(scriptPath), VALID_METADATA_IDENTITY); - assert.strictEqual(refreshManager.lastValidatedMetadataIdentityProofs.has(scriptPath), false); - }); + assert.strictEqual(workspaceState.set.firstCall.args[0], INLINE_SCRIPT_ENVS_KEY); + assert.strictEqual(listener.callCount, 1); + assert.deepStrictEqual(listener.firstCall.args[0], { uri, old: undefined, new: environment }); - test('ignores a stale saved-metadata refresh when an unset wins while sidecar proof awaits', async () => { - const uri = scriptUri(); - const scriptPath = normalizePath(uri.fsPath); - const environment = await createOwnedEnvironment(); - const refreshManager = asMetadataRefreshManager(manager); - refreshManager.subscriptions[0].dispose(); - routingRegistry.setMetadata(uri, VALID_METADATA); await manager.set(uri, environment); - const routeabilityListener = sinon.spy(); - routingRegistry.onDidChangeRouteability(routeabilityListener); - clock.tick(1); - let resolveProof: ((value: boolean) => void) | undefined; - const proofStub = sinon.stub(refreshManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); - proofStub.onFirstCall().returns( - new Promise((resolve) => { - resolveProof = resolve; - }), - ); + assert.strictEqual(listener.callCount, 1); - const pendingRefresh = refreshManager.refreshValidatedAssociationForMetadataInternal( - scriptPath, - uri, - VALID_METADATA, - routingRegistry.getMetadataIdentity(uri)!, - routingRegistry.getMetadataRevision(uri), - refreshManager.associationRevisions.get(scriptPath) ?? 0, - ); - await waitForStubCall(proofStub); await manager.set(uri, undefined); - resolveProof!(true); - await pendingRefresh; - - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - sinon.assert.calledOnceWithExactly(routeabilityListener, { - uri, - previousRouteable: true, - routeable: false, - }); - assert.strictEqual(refreshManager.cachedAssociationValidatedAt.has(scriptPath), false); - assert.strictEqual(refreshManager.lastValidatedMetadataIdentities.has(scriptPath), false); - assert.strictEqual(refreshManager.lastValidatedMetadataIdentityProofs.has(scriptPath), false); assert.deepStrictEqual(persistedAssociations, {}); + assert.strictEqual(listener.callCount, 2); + assert.deepStrictEqual(listener.secondCall.args[0], { uri, old: environment, new: undefined }); }); - test('ignores a stale saved-metadata refresh when a replacement wins while sidecar proof awaits', async () => { - const uri = scriptUri(); - const scriptPath = normalizePath(uri.fsPath); - const oldEnvironment = await createOwnedEnvironment(); - const replacementEnvironment = await createOwnedEnvironment('fedcba9876543210'); - const refreshManager = asMetadataRefreshManager(manager); - refreshManager.subscriptions[0].dispose(); - routingRegistry.setMetadata(uri, VALID_METADATA); - await manager.set(uri, oldEnvironment); - const routeabilityListener = sinon.spy(); - routingRegistry.onDidChangeRouteability(routeabilityListener); - clock.tick(1); - let resolveProof: ((value: boolean) => void) | undefined; - const proofStub = sinon.stub(refreshManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); - proofStub.onFirstCall().returns( - new Promise((resolve) => { - resolveProof = resolve; - }), - ); - - const pendingRefresh = refreshManager.refreshValidatedAssociationForMetadataInternal( - scriptPath, - uri, - VALID_METADATA, - routingRegistry.getMetadataIdentity(uri)!, - routingRegistry.getMetadataRevision(uri), - refreshManager.associationRevisions.get(scriptPath) ?? 0, - ); - await waitForStubCall(proofStub); - await manager.set(uri, replacementEnvironment); - const validatedAtAfterReplacement = refreshManager.cachedAssociationValidatedAt.get(scriptPath); - resolveProof!(false); - await pendingRefresh; - - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), true); - assert.strictEqual(routeabilityListener.callCount, 0); - assert.strictEqual( - refreshManager.cachedAssociationValidatedAt.get(scriptPath), - validatedAtAfterReplacement, - ); - assert.strictEqual(refreshManager.lastValidatedMetadataIdentities.get(scriptPath), VALID_METADATA_IDENTITY); - assert.strictEqual(refreshManager.lastValidatedMetadataIdentityProofs.has(scriptPath), false); - assert.deepStrictEqual(persistedAssociations, { - [scriptPath]: matchedAssociationRecord(replacementEnvironment.environmentPath.fsPath), - }); - }); - - test('preserves a persisted restart candidate after transient validation failure and retries later', async () => { - const uri = scriptUri(); + test('persists a batch atomically and reports each distinct script URI exactly once', async () => { + const first = scriptUri('first.py'); + const second = scriptUri('second.py'); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - resolveVenvStub.onFirstCall().rejects(new Error('resolver unavailable')); - resolveVenvStub.onSecondCall().resolves(environment); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); + const listener = sinon.spy(); + manager.onDidChangeEnvironment(listener); - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - await waitForStubCall(resolveVenvStub); + await manager.set([first, second, first], environment); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(first.fsPath)]: environment.environmentPath.fsPath, + [normalizePath(second.fsPath)]: environment.environmentPath.fsPath, }); - - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); - restarted.dispose(); + assert.strictEqual(workspaceState.set.callCount, 1); + assert.strictEqual(listener.callCount, 2); + assert.strictEqual(listener.firstCall.args[0].uri, first); + assert.strictEqual(listener.secondCall.args[0].uri, second); + assert.strictEqual(await manager.get(first), environment); + assert.strictEqual(await manager.get(second), environment); }); - test('clears a stale persisted restart candidate instead of routing it', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - await fs.remove(environment.environmentPath.fsPath); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); + test('serializes concurrent selections so neither persisted association is lost', async () => { + const firstUri = scriptUri('first.py'); + const secondUri = scriptUri('second.py'); + const firstEnvironment = await createOwnedEnvironment(); + const secondEnvironment = await createOwnedEnvironment('fedcba9876543210'); - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); + await Promise.all([ + manager.set(firstUri, firstEnvironment), + manager.set(secondUri, secondEnvironment), + ]); - assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - restarted.dispose(); + assert.deepStrictEqual(persistedAssociations, { + [normalizePath(firstUri.fsPath)]: firstEnvironment.environmentPath.fsPath, + [normalizePath(secondUri.fsPath)]: secondEnvironment.environmentPath.fsPath, + }); + assert.strictEqual(await manager.get(firstUri), firstEnvironment); + assert.strictEqual(await manager.get(secondUri), secondEnvironment); }); test('rehydrates a persisted owned association on demand after restart', async () => { const uri = scriptUri(); const persistedEnvironment = await createOwnedEnvironment(); - persistedAssociations = { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(persistedEnvironment.environmentPath.fsPath), - }; + persistedAssociations = { [normalizePath(uri.fsPath)]: persistedEnvironment.environmentPath.fsPath }; const rehydrated = { ...persistedEnvironment, envId: { ...persistedEnvironment.envId, id: 'rehydrated' } }; resolveVenvStub.resolves(rehydrated); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); + const restarted = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); assert.strictEqual(await restarted.get(uri), rehydrated); assert.strictEqual(resolveVenvStub.callCount, 1); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(persistedEnvironment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: persistedEnvironment.environmentPath.fsPath, }); const listener = sinon.spy(); @@ -3452,13 +1576,13 @@ suite('InlineScriptEnvManager', () => { test('preserves and retries a cold association when resolution rejects', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; resolveVenvStub.onFirstCall().rejects(new Error('resolver unavailable')); resolveVenvStub.onSecondCall().resolves(environment); assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); assert.strictEqual(await manager.get(uri), environment); }); @@ -3466,7 +1590,7 @@ suite('InlineScriptEnvManager', () => { test('preserves and retries a cold association when ownership inspection rejects', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; resolveVenvStub.resolves(environment); const inspectionManager = manager as unknown as { inspectAssociationOwnership( @@ -3478,7 +1602,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); assert.strictEqual(await manager.get(uri), environment); }); @@ -3486,7 +1610,7 @@ suite('InlineScriptEnvManager', () => { test('notifies when a slow persisted association finishes rehydrating', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; let resolveRehydration: ((value: PythonEnvironment) => void) | undefined; resolveVenvStub.callsFake( () => @@ -3506,63 +1630,18 @@ suite('InlineScriptEnvManager', () => { sinon.assert.calledOnceWithExactly(listener, { uri, old: undefined, new: environment }); }); - test('coalesces repeated saved-metadata validation for the same identity', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - let resolveRehydration: ((value: PythonEnvironment | undefined) => void) | undefined; - resolveVenvStub.callsFake( - () => - new Promise((resolve) => { - resolveRehydration = resolve; - }), - ); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - const listener = sinon.spy(); - restarted.onDidChangeEnvironment(listener); - await nextTurn(); - - const first = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - await waitForStubCall(resolveVenvStub); - const second = triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - assert.strictEqual(resolveVenvStub.callCount, 1); - - resolveRehydration!(environment); - await Promise.all([first, second]); - - assert.strictEqual(listener.callCount, 1); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); - restarted.dispose(); - }); - test('does not rewrite or notify when a restart reselects the same persisted executable', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); + persistedAssociations = { [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath }; + const restarted = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, makeFakeLog()); const listener = sinon.spy(); restarted.onDidChangeEnvironment(listener); await restarted.set(uri, environment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); assert.strictEqual(workspaceState.set.callCount, 0); assert.strictEqual(listener.callCount, 0); @@ -3579,44 +1658,23 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), - }); - - readMetadataStub.resolves(VALID_METADATA); - assert.strictEqual(await manager.get(uri), environment); - }); - - test('does not return a retained association when current metadata dependencies changed', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - await manager.set(uri, environment); - computeCacheKeyStub - .withArgs( - sinon.match((inputs: cacheKey.CacheKeyInputs) => inputs.dependencies.length === 1 && inputs.dependencies[0] === 'urllib3'), - ) - .returns('different-cache-key'); - readMetadataStub.resolves({ ...VALID_METADATA, dependencies: ['urllib3'] }); - - assert.strictEqual(await manager.get(uri), undefined); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); readMetadataStub.resolves(VALID_METADATA); assert.strictEqual(await manager.get(uri), environment); }); - test('does not return a retained association when current requires-python identity changed, even if compatible', async () => { + test('uses full PEP 440 semantics when validating a retained association', async () => { const uri = scriptUri(); const environment = { ...(await createOwnedEnvironment()), version: '3.15.0', }; await manager.set(uri, environment); - resolveVenvStub.resolves(environment); readMetadataStub.resolves({ ...VALID_METADATA, requiresPython: '!=3.15.0rc2' }); - assert.strictEqual(await manager.get(uri), undefined); + assert.strictEqual(await manager.get(uri), environment); }); test('does not resolve or discard an association when metadata is absent or unreadable', async () => { @@ -3647,62 +1705,6 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(resolveVenvStub.callCount, 0); }); - test('clears the routing registry when a stale persisted association is removed', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - persistedAssociations = { [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - resolveVenvStub.resolves(environment); - const restartRoutingRegistry = new InlineScriptRoutingRegistry(); - - const restarted = new InlineScriptEnvManager( - nativeFinder, - api, - baseManager, - globalStorageUri, - makeFakeLog(), - restartRoutingRegistry, - ); - await nextTurn(); - await triggerSavedMetadataChange(restartRoutingRegistry, restarted, uri); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), true); - - await fs.remove(environment.environmentPath.fsPath); - clock.tick(5_000); - assert.strictEqual(await restarted.get(uri), undefined); - assert.strictEqual(restartRoutingRegistry.hasValidatedAssociation(uri), false); - - restarted.dispose(); - }); - - test('clears persisted association state when the script path is deleted', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - await manager.set(uri, environment); - - fireDelete(uri); - await nextTurn(); - await nextTurn(); - - assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(await manager.get(uri), undefined); - assert.strictEqual(routingRegistry.hasValidatedAssociation(uri), false); - }); - - test('clears persisted association state for the old path when a script is renamed', async () => { - const oldUri = scriptUri('old.py'); - const newUri = scriptUri('new.py'); - const environment = await createOwnedEnvironment(); - await manager.set(oldUri, environment); - - fireRename(oldUri, newUri); - await nextTurn(); - await nextTurn(); - - assert.deepStrictEqual(persistedAssociations, {}); - assert.strictEqual(await manager.get(oldUri), undefined); - assert.strictEqual(routingRegistry.hasValidatedAssociation(oldUri), false); - }); - test('removes and notifies for a warm association whose executable was deleted', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); @@ -3753,7 +1755,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); assert.strictEqual(listener.callCount, 0); }); @@ -3792,94 +1794,6 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(listener.callCount, 0); }); - test('refreshes warm validation timestamps when validation keeps the same environment', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - await manager.set(uri, environment); - resolveVenvStub.resolves({ - ...environment, - envId: { ...environment.envId, id: 'new-generated-id' }, - }); - clock.tick(5_000); - - assert.strictEqual(await manager.get(uri), environment); - assert.strictEqual(resolveVenvStub.callCount, 1); - assert.strictEqual(await manager.get(uri), environment); - assert.strictEqual(resolveVenvStub.callCount, 1); - }); - - test('lets an unset win while warm validation awaits sidecar proof', async () => { - const uri = scriptUri(); - const environment = await createOwnedEnvironment(); - await manager.set(uri, environment); - const validationManager = manager as unknown as { - currentCacheEntryProvesSourceMetadataIdentity( - candidate: PythonEnvironment, - metadataIdentity: string, - metadata: metadataReader.InlineScriptMetadata, - ): Promise; - }; - let resolveProof: ((value: boolean) => void) | undefined; - const proofStub = sinon.stub(validationManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); - proofStub.onFirstCall().returns( - new Promise((resolve) => { - resolveProof = resolve; - }), - ); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - clock.tick(5_000); - - const pendingGet = manager.get(uri); - await waitForStubCall(proofStub); - await manager.set(uri, undefined); - resolveProof!(true); - - assert.strictEqual(await pendingGet, undefined); - assert.strictEqual(await manager.get(uri), undefined); - sinon.assert.calledOnceWithExactly(listener, { uri, old: environment, new: undefined }); - }); - - test('lets a replacement win while warm validation awaits sidecar proof', async () => { - const uri = scriptUri(); - const oldEnvironment = await createOwnedEnvironment(); - const replacementEnvironment = await createOwnedEnvironment('fedcba9876543210'); - await manager.set(uri, oldEnvironment); - const validationManager = manager as unknown as { - currentCacheEntryProvesSourceMetadataIdentity( - candidate: PythonEnvironment, - metadataIdentity: string, - metadata: metadataReader.InlineScriptMetadata, - ): Promise; - }; - let resolveProof: ((value: boolean) => void) | undefined; - const proofStub = sinon.stub(validationManager, 'currentCacheEntryProvesSourceMetadataIdentity').callThrough(); - proofStub.onFirstCall().returns( - new Promise((resolve) => { - resolveProof = resolve; - }), - ); - const listener = sinon.spy(); - manager.onDidChangeEnvironment(listener); - clock.tick(5_000); - - const pendingGet = manager.get(uri); - await waitForStubCall(proofStub); - await manager.set(uri, replacementEnvironment); - resolveProof!(true); - - assert.strictEqual(await pendingGet, replacementEnvironment); - assert.strictEqual(await manager.get(uri), replacementEnvironment); - assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(replacementEnvironment.environmentPath.fsPath), - }); - sinon.assert.calledOnceWithExactly(listener, { - uri, - old: oldEnvironment, - new: replacementEnvironment, - }); - }); - test('coalesces concurrent validation of an expired warm association', async () => { const uri = scriptUri(); const environment = await createOwnedEnvironment(); @@ -3942,7 +1856,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await pendingGet, selectedEnvironment); assert.strictEqual(await manager.get(uri), selectedEnvironment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(selectedEnvironment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: selectedEnvironment.environmentPath.fsPath, }); assert.strictEqual(resolveVenvStub.callCount, 0); sinon.assert.calledOnceWithExactly(listener, { @@ -4004,22 +1918,14 @@ suite('InlineScriptEnvManager', () => { const environment = await createOwnedEnvironment(); const scriptPath = normalizePath(uri.fsPath); persistedAssociations = { [scriptPath]: 42 }; - let envKeyReads = 0; - workspaceState.get.callsFake(async (key: string) => { - if (key === INLINE_SCRIPT_ENVS_KEY) { - envKeyReads += 1; - if (envKeyReads === 1) { - return { [scriptPath]: 42 }; - } - persistedAssociations = { [scriptPath]: matchedAssociationRecord(environment.environmentPath.fsPath) }; - return persistedAssociations; - } - return undefined; + workspaceState.get.onSecondCall().callsFake(async () => { + persistedAssociations = { [scriptPath]: environment.environmentPath.fsPath }; + return persistedAssociations; }); - assert.strictEqual(await manager.get(uri), environment); + assert.strictEqual(await manager.get(uri), undefined); assert.deepStrictEqual(persistedAssociations, { - [scriptPath]: matchedAssociationRecord(environment.environmentPath.fsPath), + [scriptPath]: environment.environmentPath.fsPath, }); assert.strictEqual(workspaceState.set.callCount, 0); }); @@ -4102,7 +2008,7 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await manager.get(uri), first); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(first.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: first.environmentPath.fsPath, }); assert.strictEqual(listener.callCount, 1); }); @@ -4119,7 +2025,7 @@ suite('InlineScriptEnvManager', () => { await assert.rejects(manager.set(uri, undefined), /Memento unavailable/); assert.strictEqual(await manager.get(uri), environment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); assert.strictEqual(listener.callCount, 1); }); @@ -4201,9 +2107,9 @@ suite('InlineScriptEnvManager', () => { assert.strictEqual(await pendingGet, environment); assert.strictEqual(await manager.get(uri), environment); assert.deepStrictEqual(persistedAssociations, { - [normalizePath(uri.fsPath)]: matchedAssociationRecord(environment.environmentPath.fsPath), + [normalizePath(uri.fsPath)]: environment.environmentPath.fsPath, }); - assert.strictEqual(workspaceStateSetCalls(INLINE_SCRIPT_ENVS_KEY).length, 1); + assert.strictEqual(workspaceState.set.callCount, 0); }); test('retains a pending rehydration when a competing persistence write fails', async () => { diff --git a/src/test/managers/builtin/inlineScript/main.unit.test.ts b/src/test/managers/builtin/inlineScript/main.unit.test.ts index a090ff80b..d109e318d 100644 --- a/src/test/managers/builtin/inlineScript/main.unit.test.ts +++ b/src/test/managers/builtin/inlineScript/main.unit.test.ts @@ -5,7 +5,6 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { Disposable, LogOutputChannel, Uri } from 'vscode'; import { EnvironmentManager, PythonEnvironmentApi } from '../../../../api'; -import { InlineScriptRoutingRegistry } from '../../../../common/inlineScript/routingRegistry'; import * as pythonApi from '../../../../features/pythonApi'; import * as helpers from '../../../../helpers'; import { registerInlineScriptFeatures } from '../../../../managers/builtin/inlineScript/main'; @@ -35,7 +34,6 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { const nativeFinder = {} as NativePythonFinder; const baseManager = {} as EnvironmentManager; const globalStorageUri = Uri.file('inline-script-global-storage'); - const routingRegistry = new InlineScriptRoutingRegistry(); setup(() => { isEnabledStub = sinon.stub(helpers, 'isInlineScriptsFeatureEnabled'); @@ -53,14 +51,7 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(false); const disposables: Disposable[] = []; - await registerInlineScriptFeatures( - nativeFinder, - disposables, - makeFakeLog(), - baseManager, - globalStorageUri, - routingRegistry, - ); + 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'); @@ -71,14 +62,7 @@ suite('registerInlineScriptFeatures (feature-flag gate)', () => { isEnabledStub.returns(true); const disposables: Disposable[] = []; - await registerInlineScriptFeatures( - nativeFinder, - disposables, - makeFakeLog(), - baseManager, - globalStorageUri, - routingRegistry, - ); + await registerInlineScriptFeatures(nativeFinder, disposables, makeFakeLog(), baseManager, globalStorageUri); assert.strictEqual(getPythonApiStub.callCount, 1); assert.strictEqual(registerEnvironmentManagerStub.callCount, 1); diff --git a/src/test/smoke/registration.smoke.test.ts b/src/test/smoke/registration.smoke.test.ts index bd8d469e0..57c827659 100644 --- a/src/test/smoke/registration.smoke.test.ts +++ b/src/test/smoke/registration.smoke.test.ts @@ -57,6 +57,7 @@ suite('Smoke: Registration Checks', function () { // Environment management 'python-envs.create', 'python-envs.createAny', + 'python-envs.setupInlineScriptEnvironment', 'python-envs.set', 'python-envs.setEnv', 'python-envs.setEnvSelected',