@@ -11,6 +11,7 @@ import {
1111 DidChangeEnvironmentEventArgs ,
1212 DidChangeEnvironmentsEventArgs ,
1313 EnvironmentManager ,
14+ EnvironmentChangeKind ,
1415 GetEnvironmentScope ,
1516 GetEnvironmentsScope ,
1617 IconPath ,
@@ -49,6 +50,7 @@ import { normalizePath } from '../../../common/utils/pathUtils';
4950import { compareReleaseSegments , parseReleaseSegments } from '../../../common/utils/pep440Release' ;
5051import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment' ;
5152import { NativePythonFinder } from '../../common/nativePythonFinder' ;
53+ import { sortEnvironments } from '../../common/utils' ;
5254import { resolveSystemPythonEnvironmentPath } from '../utils' ;
5355import * as uvPythonInstaller from '../uvPythonInstaller' ;
5456import { createWithProgress , resolveVenvPythonEnvironmentPath } from '../venvUtils' ;
@@ -62,6 +64,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
6264const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000 ;
6365const CACHE_LOCK_RETRY_MS = 500 ;
6466const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000 ;
67+ const DISCOVERY_RETRY_DELAYS_MS = [ 1_000 , 5_000 ] as const ;
6568/** Workspace-state key for PEP 723 script path to environment executable associations. */
6669export const INLINE_SCRIPT_ENVS_KEY = `${ ENVS_EXTENSION_ID } :inline-script:SCRIPT_ENVIRONMENTS` ;
6770
@@ -92,13 +95,19 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
9295 private readonly pendingCreations = new Map < string , Promise < PythonEnvironment | undefined > > ( ) ;
9396 private readonly directlyResolvedBaseInterpreters = new Map < string , PythonEnvironment > ( ) ;
9497 private baseInterpreterInstallationQueue : Promise < void > = Promise . resolve ( ) ;
98+ private collection : PythonEnvironment [ ] = [ ] ;
9599 private readonly pendingRehydrations = new Map < string , Promise < PythonEnvironment | undefined > > ( ) ;
96100 private readonly fsPathToEnv = new Map < string , PythonEnvironment > ( ) ;
97101 private readonly fsPathToPersistedEnvPath = new Map < string , string > ( ) ;
98102 private readonly cachedAssociationValidatedAt = new Map < string , number > ( ) ;
99103 private readonly associationRevisions = new Map < string , number > ( ) ;
104+ private pendingRefresh : Promise < boolean > | undefined ;
105+ private activationDiscoveryActive = false ;
106+ private discoveryRetryAttempt = 0 ;
107+ private discoveryRetryTimer : ReturnType < typeof setTimeout > | undefined ;
100108 private persistenceQueue : Promise < void > = Promise . resolve ( ) ;
101109 private selectionQueue : Promise < void > = Promise . resolve ( ) ;
110+ private disposed = false ;
102111
103112 private readonly _onDidChangeEnvironments = new EventEmitter < DidChangeEnvironmentsEventArgs > ( ) ;
104113 public readonly onDidChangeEnvironments : Event < DidChangeEnvironmentsEventArgs > =
@@ -228,10 +237,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
228237 }
229238
230239 async refresh ( _scope : RefreshEnvironmentsScope ) : Promise < void > {
231- return ;
240+ if ( this . disposed ) {
241+ return ;
242+ }
243+ this . stopActivationDiscovery ( ) ;
244+ await this . getOrStartRefreshPass ( ) ;
232245 }
233246
234- async getEnvironments ( _scope : GetEnvironmentsScope ) : Promise < PythonEnvironment [ ] > {
247+ async getEnvironments ( scope : GetEnvironmentsScope ) : Promise < PythonEnvironment [ ] > {
248+ if ( scope === 'all' ) {
249+ return Array . from ( this . collection ) ;
250+ }
235251 return [ ] ;
236252 }
237253
@@ -247,6 +263,257 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
247263 return undefined ;
248264 }
249265
266+ public startActivationDiscovery ( ) : void {
267+ if ( this . disposed || this . activationDiscoveryActive ) {
268+ return ;
269+ }
270+ this . activationDiscoveryActive = true ;
271+ this . discoveryRetryAttempt = 0 ;
272+ this . runActivationDiscoveryPass ( ) ;
273+ }
274+
275+ private async getOrStartRefreshPass ( ) : Promise < boolean > {
276+ const pending = this . pendingRefresh ;
277+ if ( pending ) {
278+ return pending ;
279+ }
280+
281+ const refresh = this . refreshDiscoveredEnvironments ( ) ;
282+ this . pendingRefresh = refresh ;
283+ try {
284+ return await refresh ;
285+ } finally {
286+ if ( this . pendingRefresh === refresh ) {
287+ this . pendingRefresh = undefined ;
288+ }
289+ }
290+ }
291+
292+ private runActivationDiscoveryPass ( ) : void {
293+ if ( this . disposed || ! this . activationDiscoveryActive ) {
294+ return ;
295+ }
296+
297+ void this . getOrStartRefreshPass ( )
298+ . then ( ( shouldRetry ) => {
299+ if ( this . disposed || ! this . activationDiscoveryActive ) {
300+ return ;
301+ }
302+ if ( ! shouldRetry ) {
303+ this . stopActivationDiscovery ( ) ;
304+ return ;
305+ }
306+ this . scheduleActivationDiscoveryRetry ( ) ;
307+ } )
308+ . catch ( ( error ) => {
309+ if ( this . disposed || ! this . activationDiscoveryActive ) {
310+ return ;
311+ }
312+ this . log . warn ( `Activation-time inline-script discovery failed: ${ getErrorMessage ( error ) } ` ) ;
313+ this . stopActivationDiscovery ( ) ;
314+ } ) ;
315+ }
316+
317+ private async refreshDiscoveredEnvironments ( ) : Promise < boolean > {
318+ const cacheRoot = getScriptEnvCacheRoot ( this . globalStorageUri ) ;
319+ const previousByKey = new Map (
320+ this . collection . map ( ( environment ) => [ this . getDiscoveredEnvironmentKey ( environment ) , environment ] ) ,
321+ ) ;
322+
323+ let entryNames : string [ ] ;
324+ try {
325+ entryNames = await fs . readdir ( cacheRoot . fsPath ) ;
326+ } catch ( error ) {
327+ if ( this . isDefinitivelyStalePathError ( error ) ) {
328+ entryNames = [ ] ;
329+ } else {
330+ this . log . warn (
331+ `Unable to inspect the inline-script cache root ${ cacheRoot . fsPath } : ${ getErrorMessage ( error ) } ` ,
332+ ) ;
333+ return true ;
334+ }
335+ }
336+
337+ const lockedKeys = new Set < string > ( ) ;
338+ const nextByKey = new Map < string , PythonEnvironment > ( ) ;
339+ let shouldRetry = false ;
340+ for ( const entryName of entryNames . sort ( ) ) {
341+ if ( entryName . endsWith ( '.lock' ) ) {
342+ lockedKeys . add ( normalizePath ( Uri . joinPath ( cacheRoot , entryName . slice ( 0 , - 5 ) ) . fsPath ) ) ;
343+ shouldRetry = true ;
344+ continue ;
345+ }
346+
347+ if ( this . disposed ) {
348+ return false ;
349+ }
350+
351+ const envDir = Uri . joinPath ( cacheRoot , entryName ) ;
352+ const key = normalizePath ( envDir . fsPath ) ;
353+ const discovered = await this . inspectDiscoveredCacheEntry ( cacheRoot , envDir ) ;
354+ if ( discovered . kind === 'resolved' ) {
355+ nextByKey . set ( key , discovered . environment ) ;
356+ } else if ( discovered . kind === 'preserve' ) {
357+ shouldRetry = true ;
358+ const previous = previousByKey . get ( key ) ;
359+ if ( previous ) {
360+ nextByKey . set ( key , previous ) ;
361+ }
362+ }
363+ }
364+ for ( const [ key , previous ] of previousByKey ) {
365+ if ( ! nextByKey . has ( key ) && lockedKeys . has ( key ) ) {
366+ nextByKey . set ( key , previous ) ;
367+ }
368+ }
369+
370+ if ( this . disposed ) {
371+ return false ;
372+ }
373+
374+ // Preserve previously known entries when a refresh cannot safely classify
375+ // them because a build is in progress or the filesystem is transiently unavailable.
376+ this . replaceDiscoveredEnvironments ( sortEnvironments ( Array . from ( nextByKey . values ( ) ) ) ) ;
377+ return shouldRetry ;
378+ }
379+
380+ private async inspectDiscoveredCacheEntry (
381+ cacheRoot : Uri ,
382+ envDir : Uri ,
383+ ) : Promise < DiscoveredCacheEntryResult > {
384+ try {
385+ const stat = await fs . lstat ( envDir . fsPath ) ;
386+ if ( ! stat . isDirectory ( ) || stat . isSymbolicLink ( ) ) {
387+ return { kind : 'skip' } ;
388+ }
389+ } catch ( error ) {
390+ return this . isDefinitivelyStalePathError ( error ) ? { kind : 'skip' } : { kind : 'preserve' } ;
391+ }
392+
393+ if ( await this . isCacheEntryBusy ( envDir . fsPath ) ) {
394+ return { kind : 'preserve' } ;
395+ }
396+
397+ try {
398+ if ( ! ( await resolveCacheEntryPath ( cacheRoot , envDir ) ) ) {
399+ return { kind : 'skip' } ;
400+ }
401+ } catch ( error ) {
402+ return this . isDefinitivelyStalePathError ( error ) ? { kind : 'skip' } : { kind : 'preserve' } ;
403+ }
404+
405+ const sidecarResult = await inspectMetaJson ( envDir ) ;
406+ if ( sidecarResult . kind !== 'valid' ) {
407+ return { kind : sidecarResult . kind === 'unavailable' ? 'preserve' : 'skip' } ;
408+ }
409+
410+ const baseInterpreterStatus = await getBaseInterpreterStatus ( envDir ) ;
411+ if ( baseInterpreterStatus !== 'available' ) {
412+ return { kind : baseInterpreterStatus === 'unavailable' ? 'preserve' : 'skip' } ;
413+ }
414+
415+ let environment : PythonEnvironment | undefined ;
416+ try {
417+ environment = await resolveVenvPythonEnvironmentPath (
418+ getVenvPythonPath ( envDir . fsPath ) ,
419+ this . nativeFinder ,
420+ this . api ,
421+ this ,
422+ this . baseManager ,
423+ ) ;
424+ } catch ( error ) {
425+ this . log . warn (
426+ `Unable to resolve inline-script cache entry ${ envDir . fsPath } : ${ getErrorMessage ( error ) } ` ,
427+ ) ;
428+ return { kind : 'preserve' } ;
429+ }
430+ if ( ! environment ) {
431+ return { kind : 'preserve' } ;
432+ }
433+
434+ const ownership = await inspectOwnedCacheEntry ( environment , cacheRoot , envDir ) ;
435+ if ( ownership !== 'expected' ) {
436+ return { kind : ownership === 'uncertain' ? 'preserve' : 'skip' } ;
437+ }
438+ if ( ! this . areEqualPythonReleases ( environment . version , sidecarResult . metadata . baseInterpreterVersion ) ) {
439+ return { kind : 'skip' } ;
440+ }
441+
442+ return { kind : 'resolved' , environment } ;
443+ }
444+
445+ private replaceDiscoveredEnvironments ( next : PythonEnvironment [ ] ) : void {
446+ const previousByKey = new Map (
447+ this . collection . map ( ( environment ) => [ this . getDiscoveredEnvironmentKey ( environment ) , environment ] ) ,
448+ ) ;
449+ const nextByKey = new Map ( next . map ( ( environment ) => [ this . getDiscoveredEnvironmentKey ( environment ) , environment ] ) ) ;
450+ const changes : DidChangeEnvironmentsEventArgs = [ ] ;
451+
452+ for ( const [ key , previous ] of previousByKey ) {
453+ const current = nextByKey . get ( key ) ;
454+ if ( ! current || ! this . isSameDiscoveredEnvironment ( previous , current ) ) {
455+ changes . push ( { kind : EnvironmentChangeKind . remove , environment : previous } ) ;
456+ }
457+ }
458+ for ( const [ key , current ] of nextByKey ) {
459+ const previous = previousByKey . get ( key ) ;
460+ if ( ! previous || ! this . isSameDiscoveredEnvironment ( previous , current ) ) {
461+ changes . push ( { kind : EnvironmentChangeKind . add , environment : current } ) ;
462+ }
463+ }
464+
465+ this . collection = next ;
466+ if ( changes . length > 0 ) {
467+ this . _onDidChangeEnvironments . fire ( changes ) ;
468+ }
469+ }
470+
471+ private getDiscoveredEnvironmentKey ( environment : PythonEnvironment ) : string {
472+ return normalizePath ( environment . sysPrefix ) ;
473+ }
474+
475+ private isSameDiscoveredEnvironment ( first : PythonEnvironment , second : PythonEnvironment ) : boolean {
476+ return (
477+ first . envId . managerId === second . envId . managerId &&
478+ normalizePath ( first . environmentPath . fsPath ) === normalizePath ( second . environmentPath . fsPath ) &&
479+ first . version === second . version
480+ ) ;
481+ }
482+
483+ private scheduleActivationDiscoveryRetry ( ) : void {
484+ if ( this . discoveryRetryTimer ) {
485+ return ;
486+ }
487+
488+ const delayMs = this . getDiscoveryRetryDelayMs ( this . discoveryRetryAttempt ) ;
489+ if ( delayMs === undefined ) {
490+ this . stopActivationDiscovery ( ) ;
491+ return ;
492+ }
493+
494+ this . discoveryRetryAttempt += 1 ;
495+ this . discoveryRetryTimer = setTimeout ( ( ) => {
496+ this . discoveryRetryTimer = undefined ;
497+ if ( this . disposed || ! this . activationDiscoveryActive ) {
498+ return ;
499+ }
500+ this . runActivationDiscoveryPass ( ) ;
501+ } , delayMs ) ;
502+ }
503+
504+ private getDiscoveryRetryDelayMs ( attempt : number ) : number | undefined {
505+ return DISCOVERY_RETRY_DELAYS_MS [ attempt ] ;
506+ }
507+
508+ private stopActivationDiscovery ( ) : void {
509+ if ( this . discoveryRetryTimer ) {
510+ clearTimeout ( this . discoveryRetryTimer ) ;
511+ this . discoveryRetryTimer = undefined ;
512+ }
513+ this . activationDiscoveryActive = false ;
514+ this . discoveryRetryAttempt = 0 ;
515+ }
516+
250517 private getScriptUri ( scope : CreateEnvironmentScope ) : Uri | undefined {
251518 const uri = scope instanceof Uri ? scope : Array . isArray ( scope ) && scope . length === 1 ? scope [ 0 ] : undefined ;
252519 return uri ?. scheme === 'file' ? uri : undefined ;
@@ -1283,6 +1550,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
12831550 }
12841551
12851552 dispose ( ) : void {
1553+ this . disposed = true ;
1554+ this . stopActivationDiscovery ( ) ;
12861555 this . _onDidChangeEnvironments . dispose ( ) ;
12871556 this . _onDidChangeEnvironment . dispose ( ) ;
12881557 }
@@ -1306,3 +1575,7 @@ interface PendingScriptUpdate extends ScriptReference {
13061575 readonly needsPersistence : boolean ;
13071576 readonly shouldNotify : boolean ;
13081577}
1578+
1579+ type DiscoveredCacheEntryResult =
1580+ | { readonly kind : 'preserve' | 'skip' }
1581+ | { readonly kind : 'resolved' ; readonly environment : PythonEnvironment } ;
0 commit comments