Skip to content

Commit 7bb919a

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 655b075 commit 7bb919a

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,
@@ -51,6 +52,7 @@ import { normalizePath } from '../../../common/utils/pathUtils';
5152
import { compareReleaseSegments, parseReleaseSegments } from '../../../common/utils/pep440Release';
5253
import { getVenvPythonPath } from '../../../common/utils/virtualEnvironment';
5354
import { NativePythonFinder } from '../../common/nativePythonFinder';
55+
import { sortEnvironments } from '../../common/utils';
5456
import { resolveSystemPythonEnvironmentPath } from '../utils';
5557
import * as uvPythonInstaller from '../uvPythonInstaller';
5658
import { createWithProgress, resolveVenvPythonEnvironmentPath } from '../venvUtils';
@@ -64,6 +66,7 @@ const BASE_INTERPRETER_MANAGER_IDS = new Set([
6466
const CACHE_LOCK_TIMEOUT_MS = 5 * 60 * 1000;
6567
const CACHE_LOCK_RETRY_MS = 500;
6668
const CACHED_ASSOCIATION_VALIDATION_INTERVAL_MS = 5_000;
69+
const DISCOVERY_RETRY_DELAYS_MS = [1_000, 5_000] as const;
6770
/** Workspace-state key for PEP 723 script path to environment executable associations. */
6871
export const INLINE_SCRIPT_ENVS_KEY = `${ENVS_EXTENSION_ID}:inline-script:SCRIPT_ENVIRONMENTS`;
6972

@@ -110,13 +113,19 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
110113
private readonly pendingCreations = new Map<string, Promise<PythonEnvironment | undefined>>();
111114
private readonly directlyResolvedBaseInterpreters = new Map<string, PythonEnvironment>();
112115
private baseInterpreterInstallationQueue: Promise<void> = Promise.resolve();
116+
private collection: PythonEnvironment[] = [];
113117
private readonly pendingRehydrations = new Map<string, Promise<PythonEnvironment | undefined>>();
114118
private readonly fsPathToEnv = new Map<string, PythonEnvironment>();
115119
private readonly fsPathToPersistedEnvPath = new Map<string, string>();
116120
private readonly cachedAssociationValidatedAt = new Map<string, number>();
117121
private readonly associationRevisions = new Map<string, number>();
122+
private pendingRefresh: Promise<boolean> | undefined;
123+
private activationDiscoveryActive = false;
124+
private discoveryRetryAttempt = 0;
125+
private discoveryRetryTimer: ReturnType<typeof setTimeout> | undefined;
118126
private persistenceQueue: Promise<void> = Promise.resolve();
119127
private selectionQueue: Promise<void> = Promise.resolve();
128+
private disposed = false;
120129

121130
private readonly _onDidChangeEnvironments = new EventEmitter<DidChangeEnvironmentsEventArgs>();
122131
public readonly onDidChangeEnvironments: Event<DidChangeEnvironmentsEventArgs> =
@@ -264,10 +273,17 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
264273
}
265274

266275
async refresh(_scope: RefreshEnvironmentsScope): Promise<void> {
267-
return;
276+
if (this.disposed) {
277+
return;
278+
}
279+
this.stopActivationDiscovery();
280+
await this.getOrStartRefreshPass();
268281
}
269282

270-
async getEnvironments(_scope: GetEnvironmentsScope): Promise<PythonEnvironment[]> {
283+
async getEnvironments(scope: GetEnvironmentsScope): Promise<PythonEnvironment[]> {
284+
if (scope === 'all') {
285+
return Array.from(this.collection);
286+
}
271287
return [];
272288
}
273289

@@ -283,6 +299,257 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
283299
return undefined;
284300
}
285301

302+
public startActivationDiscovery(): void {
303+
if (this.disposed || this.activationDiscoveryActive) {
304+
return;
305+
}
306+
this.activationDiscoveryActive = true;
307+
this.discoveryRetryAttempt = 0;
308+
this.runActivationDiscoveryPass();
309+
}
310+
311+
private async getOrStartRefreshPass(): Promise<boolean> {
312+
const pending = this.pendingRefresh;
313+
if (pending) {
314+
return pending;
315+
}
316+
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;
324+
}
325+
}
326+
}
327+
328+
private runActivationDiscoveryPass(): void {
329+
if (this.disposed || !this.activationDiscoveryActive) {
330+
return;
331+
}
332+
333+
void this.getOrStartRefreshPass()
334+
.then((shouldRetry) => {
335+
if (this.disposed || !this.activationDiscoveryActive) {
336+
return;
337+
}
338+
if (!shouldRetry) {
339+
this.stopActivationDiscovery();
340+
return;
341+
}
342+
this.scheduleActivationDiscoveryRetry();
343+
})
344+
.catch((error) => {
345+
if (this.disposed || !this.activationDiscoveryActive) {
346+
return;
347+
}
348+
this.log.warn(`Activation-time inline-script discovery failed: ${getErrorMessage(error)}`);
349+
this.stopActivationDiscovery();
350+
});
351+
}
352+
353+
private async refreshDiscoveredEnvironments(): Promise<boolean> {
354+
const cacheRoot = getScriptEnvCacheRoot(this.globalStorageUri);
355+
const previousByKey = new Map(
356+
this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]),
357+
);
358+
359+
let entryNames: string[];
360+
try {
361+
entryNames = await fs.readdir(cacheRoot.fsPath);
362+
} catch (error) {
363+
if (this.isDefinitivelyStalePathError(error)) {
364+
entryNames = [];
365+
} else {
366+
this.log.warn(
367+
`Unable to inspect the inline-script cache root ${cacheRoot.fsPath}: ${getErrorMessage(error)}`,
368+
);
369+
return true;
370+
}
371+
}
372+
373+
const lockedKeys = new Set<string>();
374+
const nextByKey = new Map<string, PythonEnvironment>();
375+
let shouldRetry = false;
376+
for (const entryName of entryNames.sort()) {
377+
if (entryName.endsWith('.lock')) {
378+
lockedKeys.add(normalizePath(Uri.joinPath(cacheRoot, entryName.slice(0, -5)).fsPath));
379+
shouldRetry = true;
380+
continue;
381+
}
382+
383+
if (this.disposed) {
384+
return false;
385+
}
386+
387+
const envDir = Uri.joinPath(cacheRoot, entryName);
388+
const key = normalizePath(envDir.fsPath);
389+
const discovered = await this.inspectDiscoveredCacheEntry(cacheRoot, envDir);
390+
if (discovered.kind === 'resolved') {
391+
nextByKey.set(key, discovered.environment);
392+
} else if (discovered.kind === 'preserve') {
393+
shouldRetry = true;
394+
const previous = previousByKey.get(key);
395+
if (previous) {
396+
nextByKey.set(key, previous);
397+
}
398+
}
399+
}
400+
for (const [key, previous] of previousByKey) {
401+
if (!nextByKey.has(key) && lockedKeys.has(key)) {
402+
nextByKey.set(key, previous);
403+
}
404+
}
405+
406+
if (this.disposed) {
407+
return false;
408+
}
409+
410+
// Preserve previously known entries when a refresh cannot safely classify
411+
// them because a build is in progress or the filesystem is transiently unavailable.
412+
this.replaceDiscoveredEnvironments(sortEnvironments(Array.from(nextByKey.values())));
413+
return shouldRetry;
414+
}
415+
416+
private async inspectDiscoveredCacheEntry(
417+
cacheRoot: Uri,
418+
envDir: Uri,
419+
): Promise<DiscoveredCacheEntryResult> {
420+
try {
421+
const stat = await fs.lstat(envDir.fsPath);
422+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
423+
return { kind: 'skip' };
424+
}
425+
} catch (error) {
426+
return this.isDefinitivelyStalePathError(error) ? { kind: 'skip' } : { kind: 'preserve' };
427+
}
428+
429+
if (await this.isCacheEntryBusy(envDir.fsPath)) {
430+
return { kind: 'preserve' };
431+
}
432+
433+
try {
434+
if (!(await resolveCacheEntryPath(cacheRoot, envDir))) {
435+
return { kind: 'skip' };
436+
}
437+
} catch (error) {
438+
return this.isDefinitivelyStalePathError(error) ? { kind: 'skip' } : { kind: 'preserve' };
439+
}
440+
441+
const sidecarResult = await inspectMetaJson(envDir);
442+
if (sidecarResult.kind !== 'valid') {
443+
return { kind: sidecarResult.kind === 'unavailable' ? 'preserve' : 'skip' };
444+
}
445+
446+
const baseInterpreterStatus = await getBaseInterpreterStatus(envDir);
447+
if (baseInterpreterStatus !== 'available') {
448+
return { kind: baseInterpreterStatus === 'unavailable' ? 'preserve' : 'skip' };
449+
}
450+
451+
let environment: PythonEnvironment | undefined;
452+
try {
453+
environment = await resolveVenvPythonEnvironmentPath(
454+
getVenvPythonPath(envDir.fsPath),
455+
this.nativeFinder,
456+
this.api,
457+
this,
458+
this.baseManager,
459+
);
460+
} catch (error) {
461+
this.log.warn(
462+
`Unable to resolve inline-script cache entry ${envDir.fsPath}: ${getErrorMessage(error)}`,
463+
);
464+
return { kind: 'preserve' };
465+
}
466+
if (!environment) {
467+
return { kind: 'preserve' };
468+
}
469+
470+
const ownership = await inspectOwnedCacheEntry(environment, cacheRoot, envDir);
471+
if (ownership !== 'expected') {
472+
return { kind: ownership === 'uncertain' ? 'preserve' : 'skip' };
473+
}
474+
if (!this.areEqualPythonReleases(environment.version, sidecarResult.metadata.baseInterpreterVersion)) {
475+
return { kind: 'skip' };
476+
}
477+
478+
return { kind: 'resolved', environment };
479+
}
480+
481+
private replaceDiscoveredEnvironments(next: PythonEnvironment[]): void {
482+
const previousByKey = new Map(
483+
this.collection.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]),
484+
);
485+
const nextByKey = new Map(next.map((environment) => [this.getDiscoveredEnvironmentKey(environment), environment]));
486+
const changes: DidChangeEnvironmentsEventArgs = [];
487+
488+
for (const [key, previous] of previousByKey) {
489+
const current = nextByKey.get(key);
490+
if (!current || !this.isSameDiscoveredEnvironment(previous, current)) {
491+
changes.push({ kind: EnvironmentChangeKind.remove, environment: previous });
492+
}
493+
}
494+
for (const [key, current] of nextByKey) {
495+
const previous = previousByKey.get(key);
496+
if (!previous || !this.isSameDiscoveredEnvironment(previous, current)) {
497+
changes.push({ kind: EnvironmentChangeKind.add, environment: current });
498+
}
499+
}
500+
501+
this.collection = next;
502+
if (changes.length > 0) {
503+
this._onDidChangeEnvironments.fire(changes);
504+
}
505+
}
506+
507+
private getDiscoveredEnvironmentKey(environment: PythonEnvironment): string {
508+
return normalizePath(environment.sysPrefix);
509+
}
510+
511+
private isSameDiscoveredEnvironment(first: PythonEnvironment, second: PythonEnvironment): boolean {
512+
return (
513+
first.envId.managerId === second.envId.managerId &&
514+
normalizePath(first.environmentPath.fsPath) === normalizePath(second.environmentPath.fsPath) &&
515+
first.version === second.version
516+
);
517+
}
518+
519+
private scheduleActivationDiscoveryRetry(): void {
520+
if (this.discoveryRetryTimer) {
521+
return;
522+
}
523+
524+
const delayMs = this.getDiscoveryRetryDelayMs(this.discoveryRetryAttempt);
525+
if (delayMs === undefined) {
526+
this.stopActivationDiscovery();
527+
return;
528+
}
529+
530+
this.discoveryRetryAttempt += 1;
531+
this.discoveryRetryTimer = setTimeout(() => {
532+
this.discoveryRetryTimer = undefined;
533+
if (this.disposed || !this.activationDiscoveryActive) {
534+
return;
535+
}
536+
this.runActivationDiscoveryPass();
537+
}, delayMs);
538+
}
539+
540+
private getDiscoveryRetryDelayMs(attempt: number): number | undefined {
541+
return DISCOVERY_RETRY_DELAYS_MS[attempt];
542+
}
543+
544+
private stopActivationDiscovery(): void {
545+
if (this.discoveryRetryTimer) {
546+
clearTimeout(this.discoveryRetryTimer);
547+
this.discoveryRetryTimer = undefined;
548+
}
549+
this.activationDiscoveryActive = false;
550+
this.discoveryRetryAttempt = 0;
551+
}
552+
286553
private getScriptUri(scope: CreateEnvironmentScope): Uri | undefined {
287554
const uri = scope instanceof Uri ? scope : Array.isArray(scope) && scope.length === 1 ? scope[0] : undefined;
288555
return uri?.scheme === 'file' ? uri : undefined;
@@ -1390,6 +1657,8 @@ export class InlineScriptEnvManager implements EnvironmentManager, Disposable {
13901657
}
13911658

13921659
dispose(): void {
1660+
this.disposed = true;
1661+
this.stopActivationDiscovery();
13931662
this._onDidChangeEnvironments.dispose();
13941663
this._onDidChangeEnvironment.dispose();
13951664
}
@@ -1413,3 +1682,7 @@ interface PendingScriptUpdate extends ScriptReference {
14131682
readonly needsPersistence: boolean;
14141683
readonly shouldNotify: boolean;
14151684
}
1685+
1686+
type DiscoveredCacheEntryResult =
1687+
| { readonly kind: 'preserve' | 'skip' }
1688+
| { 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)