@@ -25,6 +25,7 @@ import {
2525import { getErrorMessage } from '../../../common/errors/utils' ;
2626import { computeCacheKey , normalizeDependency } from '../../../common/inlineScript/cacheKey' ;
2727import {
28+ CacheEntrySummary ,
2829 CacheEnvironmentInspection ,
2930 INLINE_SCRIPT_CACHE_DIR_NAME ,
3031 InlineScriptEnvMeta ,
@@ -38,6 +39,7 @@ import {
3839 inspectMetaJson ,
3940 restoreMetaJsonBackupUnderLock ,
4041 resolveCacheEntryPath ,
42+ selectStaleEntries ,
4143 writeMetaJson ,
4244} from '../../../common/inlineScript/cacheLayout' ;
4345import { extractLowerBoundVersion , pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter' ;
@@ -89,6 +91,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
8991
9092const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000 ;
9193const CACHE_LOCK_RETRY_MS = 500 ;
94+ const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000 ;
9295const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000 ;
9396const DISCOVERY_RETRY_DELAYS_MS = [ 1_000 , 5_000 , 30_000 ] as const ;
9497const PERSISTED_ASSOCIATION_SCHEMA_VERSION = 1 as const ;
@@ -144,6 +147,12 @@ interface MergeCacheEntrySourceMetadataIdentityHashResult {
144147 readonly sourceMetadataIdentityHashes ?: readonly string [ ] ;
145148}
146149
150+ interface CacheEntryRemovalOptions {
151+ readonly shouldRemove ?: ( entryPath : string ) => Promise < boolean > ;
152+ readonly afterRemove ?: ( ) => void ;
153+ readonly reclaimRetainedLock ?: boolean ;
154+ }
155+
147156type CacheEntryInspection =
148157 | { readonly kind : 'absent' | 'stale' | 'uncertain' }
149158 | { readonly kind : 'reusable' ; readonly environment : PythonEnvironment } ;
@@ -200,6 +209,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
200209 private cacheMaintenanceBarrier : Deferred < void > | undefined ;
201210 private pendingCacheMaintenances = 0 ;
202211 private activeCreateOperations = 0 ;
212+ private ttlEviction : Promise < void > | undefined ;
213+ private cacheMutationRevision = 0 ;
203214 private disposed = false ;
204215
205216 private readonly _onDidChangeEnvironments = new EventEmitter < DidChangeEnvironmentsEventArgs > ( ) ;
@@ -260,6 +271,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
260271 ) : Promise < PythonEnvironment | undefined > {
261272 this . activeCreateOperations += 1 ;
262273 try {
274+ await this . runTtlEvictionOnce ( ) ;
263275 return await this . waitForCacheMaintenance ( async ( ) => {
264276 try {
265277 const scriptUri = this . getScriptUri ( scope ) ;
@@ -583,6 +595,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
583595 }
584596
585597 private async refreshDiscoveredEnvironments ( checkForSnapshotChanges : boolean ) : Promise < boolean > {
598+ const cacheMaintenance = this . cacheMaintenanceBarrier ;
599+ if ( cacheMaintenance ) {
600+ await cacheMaintenance . promise ;
601+ }
602+ const cacheMutationRevision = this . cacheMutationRevision ;
586603 const cacheRoot = getScriptEnvCacheRoot ( this . globalStorageUri ) ;
587604 const previousByKey = new Map (
588605 this . collection . map ( ( environment ) => [ this . getDiscoveredEnvironmentKey ( environment ) , environment ] ) ,
@@ -699,6 +716,9 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
699716 if ( this . disposed ) {
700717 return false ;
701718 }
719+ if ( cacheMutationRevision !== this . cacheMutationRevision ) {
720+ return true ;
721+ }
702722
703723 // Preserve previously known entries when a refresh cannot safely classify
704724 // them because a build is in progress or the filesystem is transiently unavailable.
@@ -2139,6 +2159,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
21392159 return this . associationStore . clear ( ) ;
21402160 }
21412161
2162+ private runTtlEvictionOnce ( ) : Promise < void > {
2163+ if ( ! this . ttlEviction ) {
2164+ this . ttlEviction = this . enqueueCacheMaintenance ( ( ) =>
2165+ this . enqueueSelection ( ( ) => this . evictStaleCacheEntries ( ) ) ,
2166+ ) . catch ( ( error ) => {
2167+ this . log . warn ( `Unable to evict stale inline-script environments: ${ getErrorMessage ( error ) } ` ) ;
2168+ } ) ;
2169+ }
2170+ return this . ttlEviction ;
2171+ }
2172+
21422173 private async waitForCacheMaintenance < T > ( operation : ( ) => Promise < T > ) : Promise < T > {
21432174 const barrier = this . cacheMaintenanceBarrier ;
21442175 if ( barrier ) {
@@ -2849,6 +2880,142 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
28492880 return { environment : result . environment } ;
28502881 }
28512882
2883+ private async evictStaleCacheEntries ( ) : Promise < void > {
2884+ const cacheRoot = getScriptEnvCacheRoot ( this . globalStorageUri ) ;
2885+ const physicalCacheRootPath = await this . getPhysicalOwnedCacheRootPath ( cacheRoot ) ;
2886+ if ( ! physicalCacheRootPath ) {
2887+ return ;
2888+ }
2889+
2890+ let entryNames : string [ ] ;
2891+ try {
2892+ entryNames = await fs . readdir ( physicalCacheRootPath ) ;
2893+ } catch ( error ) {
2894+ if ( isFileNotFoundError ( error ) ) {
2895+ return ;
2896+ }
2897+ throw error ;
2898+ }
2899+
2900+ const now = new Date ( ) ;
2901+ const entries : CacheEntrySummary [ ] = [ ] ;
2902+ for ( const entryName of entryNames . sort ( ) ) {
2903+ if ( entryName . endsWith ( FILE_LOCK_DIR_SUFFIX ) ) {
2904+ continue ;
2905+ }
2906+ const entryPath = path . join ( physicalCacheRootPath , entryName ) ;
2907+ try {
2908+ const stat = await fs . lstat ( entryPath ) ;
2909+ if ( ! stat . isDirectory ( ) || stat . isSymbolicLink ( ) ) {
2910+ continue ;
2911+ }
2912+ const sidecar = await inspectMetaJson ( Uri . file ( entryPath ) ) ;
2913+ if ( sidecar . kind === 'valid' ) {
2914+ entries . push ( {
2915+ envDirPath : entryPath ,
2916+ lastUsedAt : new Date ( sidecar . metadata . lastUsedAt ) ,
2917+ } ) ;
2918+ }
2919+ } catch ( error ) {
2920+ if ( ! isFileNotFoundError ( error ) ) {
2921+ this . log . warn (
2922+ `Unable to inspect inline-script cache entry for TTL eviction ${ entryPath } : ${ getErrorMessage ( error ) } ` ,
2923+ ) ;
2924+ }
2925+ }
2926+ }
2927+
2928+ const staleEntries = selectStaleEntries ( entries , now , CACHE_TTL_MS ) ;
2929+ if ( staleEntries . length === 0 ) {
2930+ return ;
2931+ }
2932+
2933+ const persistedAssociations = await this . getPersistedAssociationSnapshot ( ) ;
2934+ const scriptPaths = this . getTrackedScriptPaths ( persistedAssociations ) ;
2935+ const priorSelections = this . getPriorSelections ( scriptPaths ) ;
2936+ const removedCacheEntries = new Set < string > ( ) ;
2937+ for ( const staleEntry of staleEntries ) {
2938+ try {
2939+ const removed = await this . removeCacheEntryForClear (
2940+ cacheRoot ,
2941+ physicalCacheRootPath ,
2942+ path . basename ( staleEntry ) ,
2943+ {
2944+ reclaimRetainedLock : false ,
2945+ afterRemove : ( ) => {
2946+ this . cacheMutationRevision += 1 ;
2947+ } ,
2948+ shouldRemove : async ( entryPath ) => {
2949+ const sidecar = await inspectMetaJson ( Uri . file ( entryPath ) ) ;
2950+ return (
2951+ sidecar . kind === 'valid' &&
2952+ selectStaleEntries (
2953+ [
2954+ {
2955+ envDirPath : entryPath ,
2956+ lastUsedAt : new Date ( sidecar . metadata . lastUsedAt ) ,
2957+ } ,
2958+ ] ,
2959+ now ,
2960+ CACHE_TTL_MS ,
2961+ ) . length === 1
2962+ ) ;
2963+ } ,
2964+ } ,
2965+ ) ;
2966+ if ( removed ) {
2967+ removedCacheEntries . add ( normalizePath ( removed ) ) ;
2968+ } else if ( await this . isCacheEntryDefinitelyMissing ( staleEntry ) ) {
2969+ this . cacheMutationRevision += 1 ;
2970+ removedCacheEntries . add ( normalizePath ( staleEntry ) ) ;
2971+ }
2972+ } catch ( error ) {
2973+ this . log . warn (
2974+ `Unable to evict stale inline-script cache entry ${ staleEntry } : ${ getErrorMessage ( error ) } ` ,
2975+ ) ;
2976+ if ( await this . isCacheEntryDefinitelyMissing ( staleEntry ) ) {
2977+ this . cacheMutationRevision += 1 ;
2978+ removedCacheEntries . add ( normalizePath ( staleEntry ) ) ;
2979+ }
2980+ }
2981+ }
2982+
2983+ if ( removedCacheEntries . size === 0 ) {
2984+ return ;
2985+ }
2986+
2987+ this . replaceDiscoveredEnvironments (
2988+ this . collection . filter (
2989+ ( environment ) => ! removedCacheEntries . has ( normalizePath ( environment . sysPrefix ) ) ,
2990+ ) ,
2991+ ) ;
2992+ const invalidatedScriptPaths = await this . getInvalidatedAssociationPaths (
2993+ scriptPaths ,
2994+ persistedAssociations ,
2995+ removedCacheEntries ,
2996+ ) ;
2997+ await this . clearInvalidatedAssociations (
2998+ invalidatedScriptPaths ,
2999+ persistedAssociations ,
3000+ priorSelections ,
3001+ ) ;
3002+ }
3003+
3004+ private async isCacheEntryDefinitelyMissing ( entryPath : string ) : Promise < boolean > {
3005+ try {
3006+ await fs . lstat ( entryPath ) ;
3007+ return false ;
3008+ } catch ( error ) {
3009+ if ( isFileNotFoundError ( error ) ) {
3010+ return true ;
3011+ }
3012+ this . log . warn (
3013+ `Unable to verify stale inline-script cache entry ${ entryPath } : ${ getErrorMessage ( error ) } ` ,
3014+ ) ;
3015+ return false ;
3016+ }
3017+ }
3018+
28523019 private async clearCacheInternal ( activeCreatesAtStart : number ) : Promise < void > {
28533020 if ( activeCreatesAtStart > 0 ) {
28543021 const message = l10n . t (
@@ -2861,21 +3028,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
28613028 const cacheRoot = getScriptEnvCacheRoot ( this . globalStorageUri ) ;
28623029 const physicalCacheRootPath = await this . getPhysicalOwnedCacheRootPath ( cacheRoot ) ;
28633030 const persistedAssociations = await this . getPersistedAssociationSnapshot ( ) ;
2864- const scriptPaths = new Set < string > ( [
2865- ...Object . keys ( persistedAssociations ) ,
2866- ...this . associationRevisions . keys ( ) ,
2867- ...this . cachedAssociationValidatedAt . keys ( ) ,
2868- ...this . lastValidatedMetadataIdentities . keys ( ) ,
2869- ...this . lastValidatedMetadataIdentityProofs . keys ( ) ,
2870- ...this . fsPathToEnv . keys ( ) ,
2871- ...this . fsPathToPersistedAssociation . keys ( ) ,
2872- ...this . pendingRehydrations . keys ( ) ,
2873- ...this . pendingMetadataRefreshes . keys ( ) ,
2874- ] ) ;
2875- const priorSelections = new Map < string , PythonEnvironment | undefined > ( ) ;
2876- scriptPaths . forEach ( ( scriptPath ) => {
2877- priorSelections . set ( scriptPath , this . fsPathToEnv . get ( scriptPath ) ) ;
2878- } ) ;
3031+ const scriptPaths = this . getTrackedScriptPaths ( persistedAssociations ) ;
3032+ const priorSelections = this . getPriorSelections ( scriptPaths ) ;
28793033
28803034 const removedCacheEntries = new Set < string > ( ) ;
28813035 const deletionErrors : unknown [ ] = [ ] ;
@@ -2953,11 +3107,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29533107 cacheRoot : Uri ,
29543108 originalPhysicalCacheRootPath : string ,
29553109 entryName : string ,
3110+ options : CacheEntryRemovalOptions = { } ,
29563111 ) : Promise < string | undefined > {
29573112 const envDirPath = path . join ( originalPhysicalCacheRootPath , entryName ) ;
29583113 let lock : AcquiredFileLock | undefined ;
29593114 try {
2960- lock = await this . acquireCacheEntryLockForClear ( envDirPath ) ;
3115+ lock = await this . acquireCacheEntryLockForClear (
3116+ envDirPath ,
3117+ options . reclaimRetainedLock !== false ,
3118+ ) ;
29613119 const currentPhysicalCacheRootPath = await this . getPhysicalOwnedCacheRootPath ( cacheRoot ) ;
29623120 if ( ! currentPhysicalCacheRootPath ) {
29633121 return undefined ;
@@ -2981,7 +3139,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29813139 if ( ! entryPath ) {
29823140 return undefined ;
29833141 }
3142+ if ( options . shouldRemove && ! ( await options . shouldRemove ( entryPath ) ) ) {
3143+ return undefined ;
3144+ }
29843145 await this . deleteCacheEntryForClear ( entryPath ) ;
3146+ options . afterRemove ?.( ) ;
29853147 return entryPath ;
29863148 } finally {
29873149 if ( lock ) {
@@ -2990,7 +3152,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29903152 }
29913153 }
29923154
2993- private async acquireCacheEntryLockForClear ( envDirPath : string ) : Promise < AcquiredFileLock > {
3155+ private async acquireCacheEntryLockForClear (
3156+ envDirPath : string ,
3157+ reclaimRetainedLock : boolean = true ,
3158+ ) : Promise < AcquiredFileLock > {
29943159 for ( let attempt = 0 ; attempt < 3 ; attempt += 1 ) {
29953160 try {
29963161 return await acquireFileLock ( envDirPath , { timeoutMs : 0 , retryIntervalMs : CACHE_LOCK_RETRY_MS } ) ;
@@ -2999,10 +3164,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29993164 throw error ;
30003165 }
30013166 const lockState = await inspectFileLock ( envDirPath ) ;
3002- if ( lockState === 'stale' || lockState === 'retained' ) {
3167+ if ( lockState === 'stale' || ( lockState === 'retained' && reclaimRetainedLock ) ) {
30033168 await reclaimFileLock ( envDirPath ) ;
30043169 continue ;
30053170 }
3171+ if ( lockState === 'retained' ) {
3172+ throw error ;
3173+ }
30063174 if ( lockState === 'missing' ) {
30073175 continue ;
30083176 }
@@ -3260,6 +3428,30 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
32603428 return this . parsePersistedAssociations ( await this . associationStore . read < unknown > ( ) ) ?. records ?? { } ;
32613429 }
32623430
3431+ private getTrackedScriptPaths (
3432+ persistedAssociations : PersistedInlineScriptEnvironments ,
3433+ ) : Set < string > {
3434+ return new Set ( [
3435+ ...Object . keys ( persistedAssociations ) ,
3436+ ...this . associationRevisions . keys ( ) ,
3437+ ...this . cachedAssociationValidatedAt . keys ( ) ,
3438+ ...this . lastValidatedMetadataIdentities . keys ( ) ,
3439+ ...this . lastValidatedMetadataIdentityProofs . keys ( ) ,
3440+ ...this . fsPathToEnv . keys ( ) ,
3441+ ...this . fsPathToPersistedAssociation . keys ( ) ,
3442+ ...this . pendingRehydrations . keys ( ) ,
3443+ ...this . pendingMetadataRefreshes . keys ( ) ,
3444+ ] ) ;
3445+ }
3446+
3447+ private getPriorSelections (
3448+ scriptPaths : ReadonlySet < string > ,
3449+ ) : Map < string , PythonEnvironment | undefined > {
3450+ return new Map (
3451+ Array . from ( scriptPaths , ( scriptPath ) => [ scriptPath , this . fsPathToEnv . get ( scriptPath ) ] ) ,
3452+ ) ;
3453+ }
3454+
32633455 private async removeCacheEntry ( envDir : Uri ) : Promise < boolean > {
32643456 try {
32653457 await fs . remove ( envDir . fsPath ) ;
0 commit comments