Skip to content

Commit 59c1815

Browse files
Add inline script activation-time discovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3cb82ae9-7424-40a4-9156-8c54ac6e0895
1 parent 8666b65 commit 59c1815

6 files changed

Lines changed: 673 additions & 4 deletions

File tree

src/common/inlineScript/cacheLayout.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export function selectStaleEntries(entries: ReadonlyArray<CacheEntrySummary>, no
204204
}
205205

206206
/**
207-
* Verify that a cached env's base interpreter still exists on disk.
207+
* Verify that a cached env's launcher and base interpreter still exist on disk.
208208
*/
209209
export async function verifyBaseInterpreterExists(envDir: Uri): Promise<boolean> {
210210
return (await getBaseInterpreterStatus(envDir)) === 'available';
@@ -221,6 +221,11 @@ async function getPosixBaseInterpreterStatus(envDir: Uri): Promise<BaseInterpret
221221
}
222222

223223
async function getWindowsBaseInterpreterStatus(envDir: Uri): Promise<BaseInterpreterStatus> {
224+
const launcherStatus = await getRegularFileStatus(getVenvPythonPath(envDir.fsPath), 'cached interpreter launcher');
225+
if (launcherStatus !== 'available') {
226+
return launcherStatus;
227+
}
228+
224229
const pyvenvPath = Uri.joinPath(envDir, 'pyvenv.cfg').fsPath;
225230
let raw: string;
226231
try {

src/managers/builtin/inlineScript/envManager.ts

Lines changed: 275 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
4950
import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release';
5051
import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment';
5152
import { NativePythonFinder } from '../../common/nativePythonFinder';
53+
import { sortEnvironments } from '../../common/utils';
5254
import { resolveSystemPythonEnvironmentPath } from '../utils';
5355
import * as uvPythonInstaller from '../uvPythonInstaller';
5456
import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils';
@@ -62,6 +64,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
6264
const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
6365
const CACHE_LOCK_RETRY_MS = 500;
6466
const 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. */
6669
export 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 };

src/managers/builtin/inlineScript/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,6 @@ export async function registerInlineScriptFeatures(
2929
const api: PythonEnvironmentApi = await getPythonApi();
3030
const mgr = new InlineScriptEnvManager(nativeFinder, api, baseManager, globalStorageUri, log);
3131
disposables.push(mgr, api.registerEnvironmentManager(mgr));
32+
setImmediate(() => mgr.startActivationDiscovery());
3233
traceInfo('Inline-script env manager: registered (internal flag is on)');
3334
}

0 commit comments

Comments
 (0)