Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { registerInlineScriptUx } from './features/inlineScript/setupEnvironment
import {
applyInitialEnvironmentSelection,
registerInterpreterSettingsChangeListener,
registerLateManagerRegistrationListener,
} from './features/interpreterSelection';
import type { PythonProjectManager } from './features/projectManager';
import { PythonProjectManagerImpl } from './features/projectManager';
Expand Down Expand Up @@ -776,6 +777,10 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron

failureStage = 'envSelection';
stageWatch.reset();
// Before startup selection, so managers that register while it runs are still picked up.
context.subscriptions.push(
registerLateManagerRegistrationListener(envManagers, projectManager, nativeFinder, api),
);
await applyInitialEnvironmentSelection(
envManagers,
projectManager,
Expand Down
135 changes: 130 additions & 5 deletions src/features/interpreterSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
onDidChangeConfiguration,
} from '../common/workspace.apis';
import { getUserConfiguredSetting } from '../helpers';
import { MANAGER_READY_TIMEOUT_MS } from './common/managerReady';
import type { EnvironmentManagers } from './envManagers';
import type {
PythonProjectManager,
Expand All @@ -35,6 +36,28 @@ import { NativeEnvInfo, NativePythonFinder } from '../managers/common/nativePyth
*/
const DEFAULT_INTERPRETER_PATH_SETTING = 'python';

/**
* Workspace folders (keyed by URI) whose selection fell back because a manager named by a setting
* (`pythonProjects[]` or `defaultEnvManager`) was not registered yet. Managers contributed by other extensions
* (e.g. Pixi, Hatch) register from their own `activate()`, which can run after startup selection. When one of the
* missing managers registers, only these folders are selected again (see registerLateManagerRegistrationListener).
*/
const fallbackSelections = new Map<string, { missingManagerIds: string[]; fellBackAt: number }>();

/** Serializes selection runs so a re-run triggered by a late registration cannot be overwritten by an older run. */
let selectionQueue: Promise<void> = Promise.resolve();

function enqueueSelection(run: () => Promise<void>): Promise<void> {
const result = selectionQueue.then(run);
selectionQueue = result.catch(() => undefined);
return result;
}

/** Test hook: forget folders recorded as fallen back by earlier selection runs. */
export function resetFallbackSelections(): void {
fallbackSelections.clear();
}

/**
* Result from the priority chain resolution.
*/
Expand Down Expand Up @@ -281,16 +304,43 @@ async function autoDiscoverEnvironment(
* @param changeEvent - The settings change that triggered a re-run. The global scope is resolved again only when
* a setting it depends on changed, so a user's global selection is kept otherwise.
*/
export async function applyInitialEnvironmentSelection(
export function applyInitialEnvironmentSelection(
envManagers: EnvironmentManagers,
projectManager: PythonProjectManager,
nativeFinder: NativePythonFinder,
api: PythonEnvironmentApi,
activationToReadyDurationMs?: number,
globalScopeDeferredRef?: { value: 'deferred' | 'not_deferred' | 'unknown' },
changeEvent?: ConfigurationChangeEvent,
): Promise<void> {
return enqueueSelection(() =>
applyEnvironmentSelectionNow(
envManagers,
projectManager,
nativeFinder,
api,
activationToReadyDurationMs,
globalScopeDeferredRef,
changeEvent,
),
);
}

/**
* @param onlyFolders - When set, only these workspace folders (by URI string) are selected and the global scope is
* left alone. Used to re-select folders that fell back because their configured manager registered late.
*/
async function applyEnvironmentSelectionNow(
envManagers: EnvironmentManagers,
projectManager: PythonProjectManager,
nativeFinder: NativePythonFinder,
api: PythonEnvironmentApi,
activationToReadyDurationMs?: number,
globalScopeDeferredRef?: { value: 'deferred' | 'not_deferred' | 'unknown' },
changeEvent?: ConfigurationChangeEvent,
onlyFolders?: ReadonlySet<string>,
): Promise<void> {
const folders = getWorkspaceFolders() ?? [];
const folders = (getWorkspaceFolders() ?? []).filter((f) => !onlyFolders || onlyFolders.has(f.uri.toString()));
traceInfo(
`[interpreterSelection] Applying initial environment selection for ${folders.length} workspace folder(s)`,
);
Expand Down Expand Up @@ -335,6 +385,15 @@ export async function applyInitialEnvironmentSelection(
explicit: isExplicitSelection(result, env),
});

const missingManagerIds = errors
.filter((e) => e.kind === 'managerNotRegistered')
.map((e) => e.configuredValue);
if (missingManagerIds.length > 0) {
fallbackSelections.set(folder.uri.toString(), { missingManagerIds, fellBackAt: Date.now() });
} else {
fallbackSelections.delete(folder.uri.toString());
}

if (env) {
workspaceFolderResolved = true;
resolvedFolderCount++;
Expand Down Expand Up @@ -388,9 +447,10 @@ export async function applyInitialEnvironmentSelection(
// pythonProjects never applies to the global scope. Resolving it again when only that changed would replace
// a user's global selection with the unchanged result of the other settings.
const globalSettingsChanged =
!changeEvent ||
changeEvent.affectsConfiguration('python-envs.defaultEnvManager') ||
changeEvent.affectsConfiguration('python.defaultInterpreterPath');
!onlyFolders &&
(!changeEvent ||
changeEvent.affectsConfiguration('python-envs.defaultEnvManager') ||
changeEvent.affectsConfiguration('python.defaultInterpreterPath'));

if (!globalSettingsChanged) {
traceVerbose('[interpreterSelection] Settings for the global scope did not change, keeping its environment');
Expand Down Expand Up @@ -720,6 +780,71 @@ async function tryResolveInterpreterPath(
return undefined;
}

/**
* Re-apply environment selection when a manager named by a setting registers after the priority chain skipped it.
*
* Startup selection does not wait for managers contributed by other extensions, because there is no way to know
* which extensions will register or when. Instead of blocking on them, selection falls back immediately and is
* corrected here once the configured manager actually registers:
* - Only the workspace folders that fell back for that manager are selected again; other folders and the global
* scope are left alone.
* - The correction only applies within MANAGER_READY_TIMEOUT_MS of the fallback (the same window the API uses when
* waiting for managers). A manager that shows up later, e.g. an extension installed mid-session, does not switch
* the interpreter under a running session.
* - The re-selection is queued behind any selection that is still running, so it cannot be overwritten by it.
*
* Register this before startup selection runs so that registrations which happen while it is in progress are not
* missed.
*/
export function registerLateManagerRegistrationListener(
envManagers: EnvironmentManagers,
projectManager: PythonProjectManager,
nativeFinder: NativePythonFinder,
api: PythonEnvironmentApi,
): Disposable {
return envManagers.onDidChangeEnvironmentManager((e) => {
if (e.kind !== 'registered') {
return;
}
const managerId = e.manager.id;
// Queued, so a registration that happens during startup selection is handled once that run has recorded
// which folders fell back.
enqueueSelection(async () => {
const now = Date.now();
const folders = new Set<string>();
for (const [folder, fallback] of fallbackSelections) {
if (!fallback.missingManagerIds.includes(managerId)) {
continue;
}
fallbackSelections.delete(folder);
if (now - fallback.fellBackAt <= MANAGER_READY_TIMEOUT_MS) {
folders.add(folder);
} else {
traceInfo(
`[interpreterSelection] Manager '${managerId}' registered too long after ${folder} fell back, keeping its environment`,
);
}
}
if (folders.size === 0) {
return;
}
traceInfo(
`[interpreterSelection] Configured manager '${managerId}' registered late, re-applying selection for ${folders.size} workspace folder(s)`,
);
await applyEnvironmentSelectionNow(
envManagers,
projectManager,
nativeFinder,
api,
undefined,
undefined,
undefined,
folders,
);
}).catch((err) => traceError(`[interpreterSelection] Re-applying selection for '${managerId}' failed: ${err}`));
});
}

/**
* Register a configuration change listener for interpreter-related settings. When relevant settings change (defaultInterpreterPath, defaultEnvManager, pythonProjects),
* re-run the priority chain to apply the new settings immediately.
Expand Down
Loading