Skip to content

Commit 63ae981

Browse files
Add opportunistic inline-script TTL eviction (PEP 723 PR 14/16) (#1745)
> Part of #1602 (PEP 723 inline script env support). Design doc: #1601. ### Roadmap context This is **PR 14 of 16** in the PEP 723 inline-script roadmap. PR 13 added explicit, user-confirmed cache cleanup; this PR adds the separate best-effort TTL path. | Phase 5: Lifecycle and polish | PR | Status | |---|---|---| | | PR 7: persistence (`get` / `set` + Memento) | merged (#1697) | | | PR 13: Clear Script Environment Cache | merged (#1724) | | | **PR 14: opportunistic 14-day TTL eviction** | **this PR** | | | PR 15: lifecycle telemetry | merged (#1723) | | | PR 16: status-bar decision | resolved; no code PR | ### Why this PR Inline-script environments are dependency/interpreter-keyed and intentionally rebuilt instead of synchronized in place. Without lifecycle cleanup, old cache keys accumulate whenever dependencies or the selected Python change. The design calls for a pipx-style 14-day TTL in addition to the explicit clear command. Because TTL cleanup is silent, it must be more conservative than user-confirmed cleanup and must never prevent the requested environment from being created. ### What this PR does - Attempts one TTL sweep per `InlineScriptEnvManager` session. - Runs the sweep before the first environment creation/reuse reaches the cache. - Reads only valid `.meta.json` sidecars and selects entries whose `lastUsedAt` is strictly older than 14 days. - Reuses PR 13's physical-root, normal-directory, direct-child, and entry-lock safety checks. - Re-reads `lastUsedAt` under the entry lock before deletion. - Preserves held, retained, malformed, redirected, unavailable, or failed-to-delete entries. - Treats all TTL failures as best-effort warnings and continues the triggering creation. - Invalidates persisted and warm script associations only for entries confirmed deleted or definitively removed by another host. - Removes evicted environments from the discovered collection and emits the existing environment-change events. - Prevents a concurrent discovery refresh from publishing a stale snapshot after eviction. ### Eviction semantics | State | Behavior | |---|---| | `lastUsedAt` older than 14 days | Lock, revalidate, and delete | | Age exactly 14 days | Keep | | Recent or future timestamp | Keep | | Missing, invalid, unsupported, or unreadable sidecar | Keep | | Timestamp becomes fresh before lock acquisition | Keep | | Entry is actively locked | Keep | | Entry has a retained cancellation lock | Keep; only explicit cleanup may reclaim it | | Entry deletion fails | Keep and continue creation | | Another host already deleted the entry | Confirm with `lstat`, then invalidate local associations | | Association persistence fails after deletion | Keep in-memory state consistent, log, and continue creation | | Refresh started before deletion | Reject its stale collection snapshot | | Refresh starts during maintenance | Wait, then scan the post-eviction cache | ### Concurrency and safety - The once-per-session latch is set synchronously, so concurrent creates share one sweep. - The sweep uses the existing cache-maintenance then selection-queue ordering. - The active-create counter is incremented before the sweep, preserving PR 13's clear-vs-create behavior. - Each deletion is protected by the existing cross-host cache-entry lock. - Physical root and entry containment are revalidated after lock acquisition. - A cache mutation revision prevents stale discovery publication without waiting on, or deadlocking with, a refresh already blocked by maintenance. - Only `ENOENT` counts as confirmed cross-host deletion; permission and transient access errors preserve associations. ### Performance - The cache is scanned once per extension-host session, not on every lookup. - Entries are inspected and deleted sequentially to avoid I/O spikes. - Activation remains unchanged and is not blocked by TTL work. - Environments with missing or uncertain metadata are not repeatedly modified. ### User impact The feature remains behind `python-envs.inlineScripts.enabled`. Users without inline-script environments see no behavior change. Existing environments are retained unless a valid sidecar proves they have not been successfully reused for more than 14 days. ### Tests - `npm run compile-tests` - `npm run compile` - `npm run lint` - Complete unit suite: **1,928 passing**, 6 pending Focused coverage includes: - strict 14-day cutoff; - once-per-session and failed-sweep behavior; - under-lock freshness revalidation; - held and retained lock preservation; - deletion and persistence failure isolation; - cross-host deletion confirmation; - association and discovered-collection invalidation; - refresh-versus-eviction publication races; - unchanged explicit clear-cache behavior. ### Scope and follow-up This PR does not add telemetry, activation-time cleanup, a cache-root lock, project-setting cleanup, UX, or retry loops. Explicit clear-cache behavior remains unchanged. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7d70c65 commit 63ae981

2 files changed

Lines changed: 495 additions & 24 deletions

File tree

‎src/managers/builtin/inlineScript/envManager.ts‎

Lines changed: 241 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { getErrorMessage } from '../../../common/errors/utils';
2626
import { computeCacheKey, normalizeDependency } from '../../../common/inlineScript/cacheKey';
2727
import {
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';
4345
import { extractLowerBoundVersion, pickCompatibleInterpreter } from '../../../common/inlineScript/interpreter';
@@ -89,6 +91,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
8991

9092
const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
9193
const CACHE_LOCK_RETRY_MS = 500;
94+
const CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
9295
const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000;
9396
const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000, 30_000] as const;
9497
const 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+
147156
type 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,153 @@ 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+
// Never evict an environment that a script association still points to. `lastUsedAt` is only
2937+
// refreshed when an environment is created or reused (never when it is resolved for run, debug,
2938+
// or Pylance), so an actively-used environment can look stale here. Reclaim only orphaned entries
2939+
// (e.g. superseded by a dependency change, or left behind by a deleted or deselected script).
2940+
const referencedEnvDirs = this.getReferencedCacheEntryDirs(persistedAssociations, scriptPaths);
2941+
const evictableStaleEntries = staleEntries.filter(
2942+
(staleEntry) => !referencedEnvDirs.has(normalizePath(staleEntry)),
2943+
);
2944+
if (evictableStaleEntries.length === 0) {
2945+
return;
2946+
}
2947+
const removedCacheEntries = new Set<string>();
2948+
for (const staleEntry of evictableStaleEntries) {
2949+
try {
2950+
const removed = await this.removeCacheEntryForClear(
2951+
cacheRoot,
2952+
physicalCacheRootPath,
2953+
path.basename(staleEntry),
2954+
{
2955+
reclaimRetainedLock: false,
2956+
afterRemove: () => {
2957+
this.cacheMutationRevision += 1;
2958+
},
2959+
shouldRemove: async (entryPath) => {
2960+
const sidecar = await inspectMetaJson(Uri.file(entryPath));
2961+
return (
2962+
sidecar.kind === 'valid' &&
2963+
selectStaleEntries(
2964+
[
2965+
{
2966+
envDirPath: entryPath,
2967+
lastUsedAt: new Date(sidecar.metadata.lastUsedAt),
2968+
},
2969+
],
2970+
now,
2971+
CACHE_TTL_MS,
2972+
).length === 1
2973+
);
2974+
},
2975+
},
2976+
);
2977+
if (removed) {
2978+
removedCacheEntries.add(normalizePath(removed));
2979+
} else if (await this.isCacheEntryDefinitelyMissing(staleEntry)) {
2980+
this.cacheMutationRevision += 1;
2981+
removedCacheEntries.add(normalizePath(staleEntry));
2982+
}
2983+
} catch (error) {
2984+
this.log.warn(
2985+
`Unable to evict stale inline-script cache entry ${staleEntry}: ${getErrorMessage(error)}`,
2986+
);
2987+
if (await this.isCacheEntryDefinitelyMissing(staleEntry)) {
2988+
this.cacheMutationRevision += 1;
2989+
removedCacheEntries.add(normalizePath(staleEntry));
2990+
}
2991+
}
2992+
}
2993+
2994+
if (removedCacheEntries.size === 0) {
2995+
return;
2996+
}
2997+
2998+
this.replaceDiscoveredEnvironments(
2999+
this.collection.filter(
3000+
(environment) => !removedCacheEntries.has(normalizePath(environment.sysPrefix)),
3001+
),
3002+
);
3003+
const invalidatedScriptPaths = await this.getInvalidatedAssociationPaths(
3004+
scriptPaths,
3005+
persistedAssociations,
3006+
removedCacheEntries,
3007+
);
3008+
await this.clearInvalidatedAssociations(
3009+
invalidatedScriptPaths,
3010+
persistedAssociations,
3011+
priorSelections,
3012+
);
3013+
}
3014+
3015+
private async isCacheEntryDefinitelyMissing(entryPath: string): Promise<boolean> {
3016+
try {
3017+
await fs.lstat(entryPath);
3018+
return false;
3019+
} catch (error) {
3020+
if (isFileNotFoundError(error)) {
3021+
return true;
3022+
}
3023+
this.log.warn(
3024+
`Unable to verify stale inline-script cache entry ${entryPath}: ${getErrorMessage(error)}`,
3025+
);
3026+
return false;
3027+
}
3028+
}
3029+
28523030
private async clearCacheInternal(activeCreatesAtStart: number): Promise<void> {
28533031
if (activeCreatesAtStart > 0) {
28543032
const message = l10n.t(
@@ -2861,21 +3039,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
28613039
const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri);
28623040
const physicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot);
28633041
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-
});
3042+
const scriptPaths = this.getTrackedScriptPaths(persistedAssociations);
3043+
const priorSelections = this.getPriorSelections(scriptPaths);
28793044

28803045
const removedCacheEntries = new Set<string>();
28813046
const deletionErrors: unknown[] = [];
@@ -2953,11 +3118,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29533118
cacheRoot: Uri,
29543119
originalPhysicalCacheRootPath: string,
29553120
entryName: string,
3121+
options: CacheEntryRemovalOptions = {},
29563122
): Promise<string | undefined> {
29573123
const envDirPath = path.join(originalPhysicalCacheRootPath, entryName);
29583124
let lock: AcquiredFileLock | undefined;
29593125
try {
2960-
lock = await this.acquireCacheEntryLockForClear(envDirPath);
3126+
lock = await this.acquireCacheEntryLockForClear(
3127+
envDirPath,
3128+
options.reclaimRetainedLock !== false,
3129+
);
29613130
const currentPhysicalCacheRootPath = await this.getPhysicalOwnedCacheRootPath(cacheRoot);
29623131
if (!currentPhysicalCacheRootPath) {
29633132
return undefined;
@@ -2981,7 +3150,11 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29813150
if (!entryPath) {
29823151
return undefined;
29833152
}
3153+
if (options.shouldRemove && !(await options.shouldRemove(entryPath))) {
3154+
return undefined;
3155+
}
29843156
await this.deleteCacheEntryForClear(entryPath);
3157+
options.afterRemove?.();
29853158
return entryPath;
29863159
} finally {
29873160
if (lock) {
@@ -2990,7 +3163,10 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29903163
}
29913164
}
29923165

2993-
private async acquireCacheEntryLockForClear(envDirPath: string): Promise<AcquiredFileLock> {
3166+
private async acquireCacheEntryLockForClear(
3167+
envDirPath: string,
3168+
reclaimRetainedLock: boolean = true,
3169+
): Promise<AcquiredFileLock> {
29943170
for (let attempt = 0; attempt < 3; attempt += 1) {
29953171
try {
29963172
return await acquireFileLock(envDirPath, { timeoutMs: 0, retryIntervalMs: CACHE_LOCK_RETRY_MS });
@@ -2999,10 +3175,13 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
29993175
throw error;
30003176
}
30013177
const lockState = await inspectFileLock(envDirPath);
3002-
if (lockState === 'stale' || lockState === 'retained') {
3178+
if (lockState === 'stale' || (lockState === 'retained' && reclaimRetainedLock)) {
30033179
await reclaimFileLock(envDirPath);
30043180
continue;
30053181
}
3182+
if (lockState === 'retained') {
3183+
throw error;
3184+
}
30063185
if (lockState === 'missing') {
30073186
continue;
30083187
}
@@ -3155,6 +3334,26 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
31553334
return fs.remove(entryPath);
31563335
}
31573336

3337+
private getReferencedCacheEntryDirs(
3338+
persistedAssociations: PersistedInlineScriptEnvironments,
3339+
scriptPaths: ReadonlySet<string>,
3340+
): Set<string> {
3341+
const referenced = new Set<string>();
3342+
for (const scriptPath of scriptPaths) {
3343+
const environmentPaths = [
3344+
persistedAssociations[scriptPath]?.environmentPath,
3345+
this.fsPathToPersistedAssociation.get(scriptPath)?.environmentPath,
3346+
this.fsPathToEnv.get(scriptPath)?.environmentPath.fsPath,
3347+
].filter((value): value is string => value !== undefined);
3348+
for (const environmentPath of environmentPaths) {
3349+
// Mirror isRemovedOrMissingCacheAssociation: the cache-entry dir is two levels above the
3350+
// interpreter executable (e.g. <envDir>/bin/python -> <envDir>).
3351+
referenced.add(normalizePath(path.dirname(path.dirname(environmentPath))));
3352+
}
3353+
}
3354+
return referenced;
3355+
}
3356+
31583357
private async getInvalidatedAssociationPaths(
31593358
scriptPaths: ReadonlySet<string>,
31603359
persistedAssociations: PersistedInlineScriptEnvironments,
@@ -3260,6 +3459,30 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
32603459
return this.parsePersistedAssociations(await this.associationStore.read<unknown>())?.records ?? {};
32613460
}
32623461

3462+
private getTrackedScriptPaths(
3463+
persistedAssociations: PersistedInlineScriptEnvironments,
3464+
): Set<string> {
3465+
return new Set([
3466+
...Object.keys(persistedAssociations),
3467+
...this.associationRevisions.keys(),
3468+
...this.cachedAssociationValidatedAt.keys(),
3469+
...this.lastValidatedMetadataIdentities.keys(),
3470+
...this.lastValidatedMetadataIdentityProofs.keys(),
3471+
...this.fsPathToEnv.keys(),
3472+
...this.fsPathToPersistedAssociation.keys(),
3473+
...this.pendingRehydrations.keys(),
3474+
...this.pendingMetadataRefreshes.keys(),
3475+
]);
3476+
}
3477+
3478+
private getPriorSelections(
3479+
scriptPaths: ReadonlySet<string>,
3480+
): Map<string, PythonEnvironment | undefined> {
3481+
return new Map(
3482+
Array.from(scriptPaths, (scriptPath) => [scriptPath, this.fsPathToEnv.get(scriptPath)]),
3483+
);
3484+
}
3485+
32633486
private async removeCacheEntry(envDir: Uri): Promise<boolean> {
32643487
try {
32653488
await fs.remove(envDir.fsPath);

0 commit comments

Comments
 (0)