Skip to content
Merged
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
93 changes: 93 additions & 0 deletions docs/startup-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@

# Startup Flow for Python Environments Extension


user opens VS Code
python environments extension begins activation

SYNC (`activate` in extension.ts):
1. create core objects: ProjectManager, EnvironmentManagers, ManagerReady
2. `setPythonApi()` — API object created, deferred resolved (API is now available to consumers)
3. create views (EnvManagerView, ProjectView), status bar, terminal manager
4. register all commands
5. activate() returns — extension is "active" from VS Code's perspective

📊 TELEMETRY: EXTENSION.ACTIVATION_DURATION { duration }

ASYNC (setImmediate callback, still in extension.ts):
1. spawn PET process (`createNativePythonFinder`)
1. sets up a JSON-RPC connection to it over stdin/stdout
2. register all built-in managers in parallel (Promise.all):
- for each manager (system, conda, pyenv, pipenv, poetry):
1. check if tool exists (e.g. `getConda(nativeFinder)` asks PET for the conda binary)
2. if tool not found → log, return early (manager not registered)
3. if tool found → create manager, call `api.registerEnvironmentManager(manager)`
- this adds it to the `EnvironmentManagers` map
- fires `onDidChangeEnvironmentManager` → `ManagerReady` deferred resolves for this manager
3. all registrations complete (Promise.all resolves)

--- gate point: `applyInitialEnvironmentSelection` ---
📊 TELEMETRY: ENV_SELECTION.STARTED { duration (activation→here), registeredManagerCount, registeredManagerIds, workspaceFolderCount }

1. for each workspace folder + global scope (no workspace case), run `resolvePriorityChainCore` to find manager:
- P1: pythonProjects[] setting → specific manager for this project
- P2: user-configured defaultEnvManager setting
- P3: user-configured python.defaultInterpreterPath → nativeFinder.resolve(path)
- P4: auto-discovery → try venv manager (local .venv), fall back to system python
- for workspace scope: ask venv manager if there's a local env (.venv/venv in the folder)
- if found → use venv manager with that env
- if not found → fall back to system python manager
- for global scope: use system python manager directly

2. get the environment from the winning priority level:

--- fork point: `result.environment ?? await result.manager.get(folder.uri)` ---
left side truthy = envPreResolved | left side undefined = managerDiscovery

envPreResolved — P3 won (interpreter → manager):
`resolvePriorityChainCore` calls `tryResolveInterpreterPath()`:
1. `nativeFinder.resolve(path)` — single PET call, resolves just this one binary
2. find which manager owns the resolved env (by managerId)
3. return { manager, environment } — BOTH are known
→ result.environment is set → the `??` short-circuits
→ no `manager.get()` called, no `initialize()`, no full discovery

managerDiscovery — P1, P2, or P4 won (manager → interpreter):
`resolvePriorityChainCore` returns { manager, environment: undefined }
→ result.environment is undefined → falls through to `await result.manager.get(scope)`
`manager.get(scope)` (e.g. `CondaEnvManager.get()`):
4. `initialize()` — lazy, once-only per manager (guarded by deferred)
a. `nativeFinder.refresh(hardRefresh=false)`:
→ `handleSoftRefresh()` checks in-memory cache (Map) for key 'all' (bc one big scan, shared cache, all managers benefit)
- on reload: cache is empty (Map was destroyed) → cache miss
- falls through to `handleHardRefresh()`
→ `handleHardRefresh()`:
- adds request to WorkerPool queue (concurrency 1, so serialized)
- when its turn comes, calls `doRefresh()`:
1. `configure()` — JSON-RPC to PET with search paths, conda/poetry/pipenv paths, cache dir
2. `refresh` — JSON-RPC to PET, PET scans filesystem
- PET may use its own on-disk cache (cacheDirectory) to speed this up
- PET streams back results as 'environment' and 'manager' notifications
- envs missing version/prefix get an inline resolve() call
3. returns NativeInfo[] (all envs of all types)
- result stored in in-memory cache under key 'all'
→ subsequent managers calling nativeFinder.refresh(false) get cache hit → instant
b. filter results to this manager's env type (e.g. conda filters to kind=conda)
c. convert NativeEnvInfo → PythonEnvironment objects → populate collection
d. `loadEnvMap()` — reads persisted env path from workspace state
→ matches path against freshly discovered collection via `findEnvironmentByPath()`
→ populates `fsPathToEnv` map
5. look up scope in `fsPathToEnv` → return the matched env

📊 TELEMETRY: ENV_SELECTION.RESULT (per scope) { duration (priority chain + manager.get), scope, prioritySource, managerId, path, hasPersistedSelection }

3. env is cached in memory (no settings.json write)
4. Python extension / status bar can now get the selected env via `api.getEnvironment(scope)`

📊 TELEMETRY: EXTENSION.MANAGER_REGISTRATION_DURATION { duration (activation→here), result, failureStage?, errorType? }

POST-INIT:
1. register terminal package watcher
2. register settings change listener (`registerInterpreterSettingsChangeListener`) — re-runs priority chain if settings change
3. initialize terminal manager
4. send telemetry (manager selection, project structure, discovery summary)
48 changes: 48 additions & 0 deletions src/common/telemetry/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ export enum EventNames {
* - errorType: string (classified error category, on failure only)
*/
PET_INIT_DURATION = 'PET.INIT_DURATION',
/**
* Telemetry event fired when applyInitialEnvironmentSelection begins.
* Signals that all managers are registered and env selection is starting.
* Properties:
* - registeredManagerCount: number (how many env managers registered)
* - workspaceFolderCount: number (how many workspace folders to process)
*/
ENV_SELECTION_STARTED = 'ENV_SELECTION.STARTED',
/**
* Telemetry event fired per scope when the priority chain resolves.
* Properties:
* - scope: string ('workspace' or 'global')
* - prioritySource: string (which priority won: 'pythonProjects', 'defaultEnvManager', 'defaultInterpreterPath', 'autoDiscovery')
* - managerId: string (the winning manager's id)
* - resolutionPath: string ('envPreResolved' = env already resolved, 'managerDiscovery' = needed full discovery)
* - hasPersistedSelection: boolean (whether a persisted env path existed in workspace state)
*/
ENV_SELECTION_RESULT = 'ENV_SELECTION.RESULT',
}

// Map all events to their properties
Expand Down Expand Up @@ -322,4 +340,34 @@ export interface IEventNamePropertyMapping {
result: 'success' | 'error' | 'timeout';
errorType?: string;
};

/* __GDPR__
"env_selection.started": {
"registeredManagerCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
"registeredManagerIds": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"workspaceFolderCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
}
*/
[EventNames.ENV_SELECTION_STARTED]: {
registeredManagerCount: number;
registeredManagerIds: string;
workspaceFolderCount: number;
};

/* __GDPR__
"env_selection.result": {
"scope": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"prioritySource": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"managerId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"resolutionPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
"hasPersistedSelection": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
}
*/
[EventNames.ENV_SELECTION_RESULT]: {
scope: string;
prioritySource: string;
managerId: string;
resolutionPath: string;
hasPersistedSelection: boolean;
};
}
2 changes: 1 addition & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
]);

failureStage = 'envSelection';
await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api);
await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api, start.elapsedTime);

// Register manager-agnostic terminal watcher for package-modifying commands
failureStage = 'terminalWatcher';
Expand Down
41 changes: 40 additions & 1 deletion src/features/interpreterSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { commands, ConfigurationChangeEvent, Disposable, l10n, Uri } from 'vscod
import { PythonEnvironment, PythonEnvironmentApi } from '../api';
import { SYSTEM_MANAGER_ID, VENV_MANAGER_ID } from '../common/constants';
import { traceError, traceInfo, traceVerbose, traceWarn } from '../common/logging';
import { StopWatch } from '../common/stopWatch';
import { EventNames } from '../common/telemetry/constants';
import { sendTelemetryEvent } from '../common/telemetry/sender';
import { resolveVariables } from '../common/utils/internalVariables';
import { showWarningMessage } from '../common/window.apis';
import {
Expand Down Expand Up @@ -140,7 +143,12 @@ async function resolvePriorityChainCore(
};
errors.push(error);
} else {
const resolved = await tryResolveInterpreterPath(nativeFinder, api, expandedInterpreterPath, envManagers);
const resolved = await tryResolveInterpreterPath(
nativeFinder,
api,
expandedInterpreterPath,
envManagers,
);
if (resolved) {
traceVerbose(`${logPrefix} Priority 3: Using defaultInterpreterPath: ${userInterpreterPath}`);
return { result: resolved, errors };
Expand Down Expand Up @@ -275,16 +283,25 @@ export async function applyInitialEnvironmentSelection(
projectManager: PythonProjectManager,
nativeFinder: NativePythonFinder,
api: PythonEnvironmentApi,
activationToReadyDurationMs?: number,
): Promise<void> {
const folders = getWorkspaceFolders() ?? [];
traceInfo(
`[interpreterSelection] Applying initial environment selection for ${folders.length} workspace folder(s)`,
);

// Checkpoint 1: env selection starting — managers are registered
sendTelemetryEvent(EventNames.ENV_SELECTION_STARTED, activationToReadyDurationMs, {
registeredManagerCount: envManagers.managers.length,
registeredManagerIds: envManagers.managers.map((m) => m.id).join(','),
workspaceFolderCount: folders.length,
});

const allErrors: SettingResolutionError[] = [];

for (const folder of folders) {
try {
const scopeStopWatch = new StopWatch();
const { result, errors } = await resolvePriorityChainCore(
folder.uri,
envManagers,
Expand All @@ -294,9 +311,20 @@ export async function applyInitialEnvironmentSelection(
);
allErrors.push(...errors);

// Checkpoint 2: priority chain resolved — which path?
const isPathA = result.environment !== undefined;

// Get the specific environment if not already resolved
const env = result.environment ?? (await result.manager.get(folder.uri));

sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, scopeStopWatch.elapsedTime, {
scope: 'workspace',
prioritySource: result.source,
managerId: result.manager.id,
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
hasPersistedSelection: env !== undefined,
});

// Cache only — NO settings.json write (shouldPersistSettings = false)
await envManagers.setEnvironment(folder.uri, env, false);

Expand All @@ -311,12 +339,23 @@ export async function applyInitialEnvironmentSelection(
// Also apply initial selection for global scope (no workspace folder)
// This ensures defaultInterpreterPath is respected even without a workspace
try {
const globalStopWatch = new StopWatch();
const { result, errors } = await resolvePriorityChainCore(undefined, envManagers, undefined, nativeFinder, api);
allErrors.push(...errors);

const isPathA = result.environment !== undefined;

// Get the specific environment if not already resolved
const env = result.environment ?? (await result.manager.get(undefined));

sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, globalStopWatch.elapsedTime, {
scope: 'global',
prioritySource: result.source,
managerId: result.manager.id,
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
hasPersistedSelection: env !== undefined,
});

// Cache only — NO settings.json write (shouldPersistSettings = false)
await envManagers.setEnvironments('global', env, false);

Expand Down
Loading