Skip to content

Commit faa729f

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 59c1815 commit faa729f

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
@@ -64,7 +64,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
6464
const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
6565
const CACHE_LOCK_RETRY_MS = 500;
6666
const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000;
67-
const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000] as const;
67+
const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000, 30_000] as const;
6868
/** Workspace-state key for PEP 723 script path to environment executable associations. */
6969
export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`;
7070

@@ -85,6 +85,11 @@ interface BuildCacheEntryResult {
8585
readonly retainLock?: boolean;
8686
}
8787

88+
interface DiscoveryRefreshPass {
89+
readonly promise: Promise<boolean>;
90+
readonly checksForSnapshotChanges: boolean;
91+
}
92+
8893
type CacheEntryInspection =
8994
| { readonly kind: 'absent' | 'stale' | 'uncertain' }
9095
| { readonly kind: 'reusable'; readonly environment: PythonEnvironment };
@@ -101,7 +106,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
101106
private readonly fsPathToPersistedEnvPath = new Map<string, string>();
102107
private readonly cachedAssociationValidatedAt = new Map<string, number>();
103108
private readonly associationRevisions = new Map<string, number>();
104-
private pendingRefresh: Promise<boolean> | undefined;
109+
private pendingRefresh: DiscoveryRefreshPass | undefined;
110+
private pendingSnapshotRefresh: Promise<boolean> | undefined;
105111
private activationDiscoveryActive = false;
106112
private discoveryRetryAttempt = 0;
107113
private discoveryRetryTimer: ReturnType<typeof setTimeout> | undefined;
@@ -241,7 +247,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
241247
return;
242248
}
243249
this.stopActivationDiscovery();
244-
await this.getOrStartRefreshPass();
250+
await this.getOrStartRefreshPass(false);
245251
}
246252

247253
async getEnvironments(scope: GetEnvironmentsScope): Promise<PythonEnvironment[]> {
@@ -272,29 +278,82 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
272278
this.runActivationDiscoveryPass();
273279
}
274280

275-
private async getOrStartRefreshPass(): Promise<boolean> {
281+
private getOrStartRefreshPass(checkForSnapshotChanges: boolean): Promise<boolean> {
276282
const pending = this.pendingRefresh;
283+
if (pending) {
284+
return checkForSnapshotChanges && !pending.checksForSnapshotChanges
285+
? this.getOrScheduleSnapshotRefresh(pending)
286+
: pending.promise;
287+
}
288+
289+
return this.startRefreshPass(checkForSnapshotChanges);
290+
}
291+
292+
private startRefreshPass(checkForSnapshotChanges: boolean): Promise<boolean> {
293+
const pass: DiscoveryRefreshPass = {
294+
promise: this.refreshDiscoveredEnvironments(checkForSnapshotChanges),
295+
checksForSnapshotChanges: checkForSnapshotChanges,
296+
};
297+
this.pendingRefresh = pass;
298+
void pass.promise.then(
299+
() => {
300+
if (this.pendingRefresh === pass) {
301+
this.pendingRefresh = undefined;
302+
}
303+
},
304+
() => {
305+
if (this.pendingRefresh === pass) {
306+
this.pendingRefresh = undefined;
307+
}
308+
},
309+
);
310+
return pass.promise;
311+
}
312+
313+
private getOrScheduleSnapshotRefresh(sharedPass: DiscoveryRefreshPass): Promise<boolean> {
314+
const pending = this.pendingSnapshotRefresh;
277315
if (pending) {
278316
return pending;
279317
}
280318

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;
319+
const followUp = this.startSnapshotRefreshAfter(sharedPass);
320+
this.pendingSnapshotRefresh = followUp;
321+
void followUp.then(
322+
() => {
323+
if (this.pendingSnapshotRefresh === followUp) {
324+
this.pendingSnapshotRefresh = undefined;
325+
}
326+
},
327+
() => {
328+
if (this.pendingSnapshotRefresh === followUp) {
329+
this.pendingSnapshotRefresh = undefined;
330+
}
331+
},
332+
);
333+
return followUp;
334+
}
335+
336+
private startSnapshotRefreshAfter(sharedPass: DiscoveryRefreshPass): Promise<boolean> {
337+
return sharedPass.promise.then(() => {
338+
if (this.disposed || !this.activationDiscoveryActive) {
339+
return false;
288340
}
289-
}
341+
const pending = this.pendingRefresh;
342+
if (pending && pending !== sharedPass) {
343+
return pending.checksForSnapshotChanges
344+
? pending.promise
345+
: this.startSnapshotRefreshAfter(pending);
346+
}
347+
return this.startRefreshPass(true);
348+
});
290349
}
291350

292351
private runActivationDiscoveryPass(): void {
293352
if (this.disposed || !this.activationDiscoveryActive) {
294353
return;
295354
}
296355

297-
void this.getOrStartRefreshPass()
356+
void this.getOrStartRefreshPass(true)
298357
.then((shouldRetry) => {
299358
if (this.disposed || !this.activationDiscoveryActive) {
300359
return;
@@ -314,7 +373,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
314373
});
315374
}
316375

317-
private async refreshDiscoveredEnvironments(): Promise<boolean> {
376+
private async refreshDiscoveredEnvironments(checkForSnapshotChanges: boolean): Promise<boolean> {
318377
const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri);
319378
const previousByKey = new Map(
320379
this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]),
@@ -339,7 +398,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
339398
let shouldRetry = false;
340399
for (const entryName of entryNames.sort()) {
341400
if (entryName.endsWith('.lock')) {
342-
lockedKeys.add(normalizePath(Uri.joinPath(cacheRoot, entryName.slice(0, -5)).fsPath));
401+
lockedKeys.add(this.getDiscoveryEntryKey(entryName.slice(0, -5)));
343402
shouldRetry = true;
344403
continue;
345404
}
@@ -349,7 +408,7 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
349408
}
350409

351410
const envDir = Uri.joinPath(cacheRoot, entryName);
352-
const key = normalizePath(envDir.fsPath);
411+
const key = this.getDiscoveryEntryKey(entryName);
353412
const discovered = await this.inspectDiscoveredCacheEntry(cacheRoot, envDir);
354413
if (discovered.kind === 'resolved') {
355414
nextByKey.set(key, discovered.environment);
@@ -371,6 +430,25 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
371430
return false;
372431
}
373432

433+
if (checkForSnapshotChanges) {
434+
try {
435+
const finalEntryNames = await fs.readdir(cacheRoot.fsPath);
436+
const initialEntries = new Set(entryNames);
437+
if (
438+
finalEntryNames.length !== entryNames.length ||
439+
finalEntryNames.some((entryName) => !initialEntries.has(entryName))
440+
) {
441+
shouldRetry = true;
442+
}
443+
} catch {
444+
shouldRetry = true;
445+
}
446+
}
447+
448+
if (this.disposed) {
449+
return false;
450+
}
451+
374452
// Preserve previously known entries when a refresh cannot safely classify
375453
// them because a build is in progress or the filesystem is transiently unavailable.
376454
this.replaceDiscoveredEnvironments(sortEnvironments(Array.from(nextByKey.values())));
@@ -468,8 +546,12 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
468546
}
469547
}
470548

549+
private getDiscoveryEntryKey(entryName: string): string {
550+
return normalizePath(entryName);
551+
}
552+
471553
private getDiscoveredEnvironmentKey(environment: PythonEnvironment): string {
472-
return normalizePath(environment.sysPrefix);
554+
return this.getDiscoveryEntryKey(path.basename(environment.sysPrefix));
473555
}
474556

475557
private isSameDiscoveredEnvironment(first: PythonEnvironment, second: PythonEnvironment): boolean {
@@ -1037,10 +1119,15 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
10371119
}
10381120

10391121
private async isCacheEntryBusy(envDirPath: string): Promise<boolean> {
1040-
return (
1041-
this.pendingCreations.has(path.basename(envDirPath)) ||
1042-
(await fs.pathExists(`${path.resolve(envDirPath)}.lock`))
1043-
);
1122+
if (this.pendingCreations.has(path.basename(envDirPath))) {
1123+
return true;
1124+
}
1125+
try {
1126+
await fs.lstat(`${path.resolve(envDirPath)}.lock`);
1127+
return true;
1128+
} catch (error) {
1129+
return !isFileNotFoundError(error);
1130+
}
10441131
}
10451132

10461133
private bumpAssociationRevision(scriptPath: string): void {

0 commit comments

Comments
 (0)