Skip to content

Commit 3bda932

Browse files
Harden inline script activation discovery
Use stable cache identities, fail closed on lock probes, and retry snapshot changes safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6b12d843-8011-4bfc-9ba9-f75761eadee2
1 parent 7bb919a commit 3bda932

2 files changed

Lines changed: 332 additions & 21 deletions

File tree

src/managers/builtin/inlineScript/envManager.ts

Lines changed: 108 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
6666
const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
6767
const CACHE_LOCK_RETRY_MS = 500;
6868
const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000;
69-
const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000] as const;
69+
const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000, 30_000] as const;
7070
/** Workspace-state key for PEP 723 script path to environment executable associations. */
7171
export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`;
7272

@@ -103,6 +103,11 @@ type InstallPythonAndRefreshResult =
103103
| { readonly kind: 'declined' }
104104
| { readonly kind: 'failed' };
105105

106+
interface DiscoveryRefreshPass {
107+
readonly promise: Promise<boolean>;
108+
readonly checksForSnapshotChanges: boolean;
109+
}
110+
106111
type CacheEntryInspection =
107112
| { readonly kind: 'absent' | 'stale' | 'uncertain' }
108113
| { readonly kind: 'reusable'; readonly environment: PythonEnvironment };
@@ -119,7 +124,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
119124
private readonly fsPathToPersistedEnvPath = new Map<string, string>();
120125
private readonly cachedAssociationValidatedAt = new Map<string, number>();
121126
private readonly associationRevisions = new Map<string, number>();
122-
private pendingRefresh: Promise<boolean> | undefined;
127+
private pendingRefresh: DiscoveryRefreshPass | undefined;
128+
private pendingSnapshotRefresh: Promise<boolean> | undefined;
123129
private activationDiscoveryActive = false;
124130
private discoveryRetryAttempt = 0;
125131
private discoveryRetryTimer: ReturnType<typeof setTimeout> | undefined;
@@ -277,7 +283,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
277283
return;
278284
}
279285
this.stopActivationDiscovery();
280-
await this.getOrStartRefreshPass();
286+
await this.getOrStartRefreshPass(false);
281287
}
282288

283289
async getEnvironments(scope: GetEnvironmentsScope): Promise<PythonEnvironment[]> {
@@ -308,29 +314,82 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
308314
this.runActivationDiscoveryPass();
309315
}
310316

311-
private async getOrStartRefreshPass(): Promise<boolean> {
317+
private getOrStartRefreshPass(checkForSnapshotChanges: boolean): Promise<boolean> {
312318
const pending = this.pendingRefresh;
319+
if (pending) {
320+
return checkForSnapshotChanges && !pending.checksForSnapshotChanges
321+
? this.getOrScheduleSnapshotRefresh(pending)
322+
: pending.promise;
323+
}
324+
325+
return this.startRefreshPass(checkForSnapshotChanges);
326+
}
327+
328+
private startRefreshPass(checkForSnapshotChanges: boolean): Promise<boolean> {
329+
const pass: DiscoveryRefreshPass = {
330+
promise: this.refreshDiscoveredEnvironments(checkForSnapshotChanges),
331+
checksForSnapshotChanges: checkForSnapshotChanges,
332+
};
333+
this.pendingRefresh = pass;
334+
void pass.promise.then(
335+
() => {
336+
if (this.pendingRefresh === pass) {
337+
this.pendingRefresh = undefined;
338+
}
339+
},
340+
() => {
341+
if (this.pendingRefresh === pass) {
342+
this.pendingRefresh = undefined;
343+
}
344+
},
345+
);
346+
return pass.promise;
347+
}
348+
349+
private getOrScheduleSnapshotRefresh(sharedPass: DiscoveryRefreshPass): Promise<boolean> {
350+
const pending = this.pendingSnapshotRefresh;
313351
if (pending) {
314352
return pending;
315353
}
316354

317-
const refresh = this.refreshDiscoveredEnvironments();
318-
this.pendingRefresh = refresh;
319-
try {
320-
return await refresh;
321-
} finally {
322-
if (this.pendingRefresh === refresh) {
323-
this.pendingRefresh = undefined;
355+
const followUp = this.startSnapshotRefreshAfter(sharedPass);
356+
this.pendingSnapshotRefresh = followUp;
357+
void followUp.then(
358+
() => {
359+
if (this.pendingSnapshotRefresh === followUp) {
360+
this.pendingSnapshotRefresh = undefined;
361+
}
362+
},
363+
() => {
364+
if (this.pendingSnapshotRefresh === followUp) {
365+
this.pendingSnapshotRefresh = undefined;
366+
}
367+
},
368+
);
369+
return followUp;
370+
}
371+
372+
private startSnapshotRefreshAfter(sharedPass: DiscoveryRefreshPass): Promise<boolean> {
373+
return sharedPass.promise.then(() => {
374+
if (this.disposed || !this.activationDiscoveryActive) {
375+
return false;
324376
}
325-
}
377+
const pending = this.pendingRefresh;
378+
if (pending && pending !== sharedPass) {
379+
return pending.checksForSnapshotChanges
380+
? pending.promise
381+
: this.startSnapshotRefreshAfter(pending);
382+
}
383+
return this.startRefreshPass(true);
384+
});
326385
}
327386

328387
private runActivationDiscoveryPass(): void {
329388
if (this.disposed || !this.activationDiscoveryActive) {
330389
return;
331390
}
332391

333-
void this.getOrStartRefreshPass()
392+
void this.getOrStartRefreshPass(true)
334393
.then((shouldRetry) => {
335394
if (this.disposed || !this.activationDiscoveryActive) {
336395
return;
@@ -350,7 +409,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
350409
});
351410
}
352411

353-
private async refreshDiscoveredEnvironments(): Promise<boolean> {
412+
private async refreshDiscoveredEnvironments(checkForSnapshotChanges: boolean): Promise<boolean> {
354413
const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri);
355414
const previousByKey = new Map(
356415
this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]),
@@ -375,7 +434,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
375434
let shouldRetry = false;
376435
for (const entryName of entryNames.sort()) {
377436
if (entryName.endsWith('.lock')) {
378-
lockedKeys.add(normalizePath(Uri.joinPath(cacheRoot, entryName.slice(0, -5)).fsPath));
437+
lockedKeys.add(this.getDiscoveryEntryKey(entryName.slice(0, -5)));
379438
shouldRetry = true;
380439
continue;
381440
}
@@ -385,7 +444,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
385444
}
386445

387446
const envDir = Uri.joinPath(cacheRoot, entryName);
388-
const key = normalizePath(envDir.fsPath);
447+
const key = this.getDiscoveryEntryKey(entryName);
389448
const discovered = await this.inspectDiscoveredCacheEntry(cacheRoot, envDir);
390449
if (discovered.kind === 'resolved') {
391450
nextByKey.set(key, discovered.environment);
@@ -407,6 +466,25 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
407466
return false;
408467
}
409468

469+
if (checkForSnapshotChanges) {
470+
try {
471+
const finalEntryNames = await fs.readdir(cacheRoot.fsPath);
472+
const initialEntries = new Set(entryNames);
473+
if (
474+
finalEntryNames.length !== entryNames.length ||
475+
finalEntryNames.some((entryName) => !initialEntries.has(entryName))
476+
) {
477+
shouldRetry = true;
478+
}
479+
} catch {
480+
shouldRetry = true;
481+
}
482+
}
483+
484+
if (this.disposed) {
485+
return false;
486+
}
487+
410488
// Preserve previously known entries when a refresh cannot safely classify
411489
// them because a build is in progress or the filesystem is transiently unavailable.
412490
this.replaceDiscoveredEnvironments(sortEnvironments(Array.from(nextByKey.values())));
@@ -504,8 +582,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
504582
}
505583
}
506584

585+
private getDiscoveryEntryKey(entryName: string): string {
586+
return normalizePath(entryName);
587+
}
588+
507589
private getDiscoveredEnvironmentKey(environment: PythonEnvironment): string {
508-
return normalizePath(environment.sysPrefix);
590+
return this.getDiscoveryEntryKey(path.basename(environment.sysPrefix));
509591
}
510592

511593
private isSameDiscoveredEnvironment(first: PythonEnvironment, second: PythonEnvironment): boolean {
@@ -1073,10 +1155,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
10731155
}
10741156

10751157
private async isCacheEntryBusy(envDirPath: string): Promise<boolean> {
1076-
return (
1077-
this.pendingCreations.has(path.basename(envDirPath)) ||
1078-
(await fs.pathExists(`${path.resolve(envDirPath)}.lock`))
1079-
);
1158+
if (this.pendingCreations.has(path.basename(envDirPath))) {
1159+
return true;
1160+
}
1161+
try {
1162+
await fs.lstat(`${path.resolve(envDirPath)}.lock`);
1163+
return true;
1164+
} catch (error) {
1165+
return !isFileNotFoundError(error);
1166+
}
10801167
}
10811168

10821169
private bumpAssociationRevision(scriptPath: string): void {

0 commit comments

Comments
 (0)