Skip to content

Commit 26e300e

Browse files
authored
Merge branch 'main' into eager-id
2 parents 0d4999e + 1eb7b67 commit 26e300e

7 files changed

Lines changed: 608 additions & 193 deletions

File tree

docs/managing-python-projects.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ You can set default managers that apply to all projects without explicit overrid
211211
}
212212
```
213213

214+
> **Important**: The extension never writes settings to the User (global) scope. All extension-managed settings are written at the Workspace or Workspace Folder level only. This prevents the extension from setting values that persist across unrelated projects and cause unexpected interference (see [#1468](https://github.com/microsoft/vscode-python-environments/issues/1468)). If a user wants a user-level default, they can set it manually in their User `settings.json`.
215+
214216
## Working with Multi-Root Workspaces
215217

216218
Multi-root workspaces contain multiple top-level folders. The extension handles these seamlessly:

src/common/telemetry/constants.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,16 @@ export enum EventNames {
101101
* - hasPersistedSelection: boolean (whether a persisted env path existed in workspace state)
102102
*/
103103
ENV_SELECTION_RESULT = 'ENV_SELECTION.RESULT',
104+
/**
105+
* Telemetry event fired when applyInitialEnvironmentSelection returns.
106+
* Duration measures the blocking time (excludes deferred global scope).
107+
* Properties:
108+
* - globalScopeDeferred: boolean (true = global scope fired in background, false = awaited)
109+
* - workspaceFolderCount: number (total workspace folders)
110+
* - resolvedFolderCount: number (folders that resolved with a non-undefined env)
111+
* - settingErrorCount: number (user-configured settings that could not be applied)
112+
*/
113+
ENV_SELECTION_COMPLETED = 'ENV_SELECTION.COMPLETED',
104114
/**
105115
* Telemetry event fired when a lazily-registered manager completes its first initialization.
106116
* Replaces MANAGER_REGISTRATION_SKIPPED and MANAGER_REGISTRATION_FAILED for managers
@@ -122,6 +132,42 @@ export enum EventNames {
122132
* - duration: number (milliseconds taken for the CLI operation)
123133
*/
124134
PET_JSON_CLI_FALLBACK = 'PET.JSON_CLI_FALLBACK',
135+
/**
136+
* Telemetry event for a PET refresh attempt (the core discovery RPC call).
137+
* Properties:
138+
* - result: 'success' | 'timeout' | 'error'
139+
* - envCount: number (environments returned via notifications)
140+
* - unresolvedCount: number (envs that needed follow-up resolve calls)
141+
* - workspaceDirCount: number (workspace directories sent in configure)
142+
* - searchPathCount: number (extra search paths sent in configure)
143+
* - attempt: number (0 = first try, 1 = retry)
144+
* - errorType: string (classified error category, on failure only)
145+
*/
146+
PET_REFRESH = 'PET.REFRESH',
147+
/**
148+
* Telemetry event for a PET configure RPC call.
149+
* Properties:
150+
* - result: 'success' | 'timeout' | 'error' | 'skipped'
151+
* - workspaceDirCount: number
152+
* - envDirCount: number (environmentDirectories count)
153+
* - retryCount: number (consecutive timeout count from ConfigureRetryState)
154+
*/
155+
PET_CONFIGURE = 'PET.CONFIGURE',
156+
/**
157+
* Telemetry event for PET process restart attempts.
158+
* Properties:
159+
* - attempt: number (1-based restart attempt number)
160+
* - result: 'success' | 'error'
161+
* - errorType: string (classified error category, on failure only)
162+
*/
163+
PET_PROCESS_RESTART = 'PET.PROCESS_RESTART',
164+
/**
165+
* Telemetry event for PET resolve calls (single-env resolution).
166+
* Properties:
167+
* - result: 'success' | 'timeout' | 'error'
168+
* - errorType: string (classified error category, on failure only)
169+
*/
170+
PET_RESOLVE = 'PET.RESOLVE',
125171
}
126172

127173
// Map all events to their properties
@@ -395,6 +441,21 @@ export interface IEventNamePropertyMapping {
395441
hasPersistedSelection: boolean;
396442
};
397443

444+
/* __GDPR__
445+
"env_selection.completed": {
446+
"globalScopeDeferred": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
447+
"workspaceFolderCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
448+
"resolvedFolderCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
449+
"settingErrorCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
450+
}
451+
*/
452+
[EventNames.ENV_SELECTION_COMPLETED]: {
453+
globalScopeDeferred: boolean;
454+
workspaceFolderCount: number;
455+
resolvedFolderCount: number;
456+
settingErrorCount: number;
457+
};
458+
398459
/* __GDPR__
399460
"manager.lazy_init": {
400461
"managerName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
@@ -424,4 +485,68 @@ export interface IEventNamePropertyMapping {
424485
operation: 'refresh' | 'resolve';
425486
result: 'success' | 'error';
426487
};
488+
489+
/* __GDPR__
490+
"pet.refresh": {
491+
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
492+
"envCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
493+
"unresolvedCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
494+
"workspaceDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
495+
"searchPathCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
496+
"attempt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
497+
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
498+
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
499+
}
500+
*/
501+
[EventNames.PET_REFRESH]: {
502+
result: 'success' | 'timeout' | 'error';
503+
envCount?: number;
504+
unresolvedCount?: number;
505+
workspaceDirCount?: number;
506+
searchPathCount?: number;
507+
attempt: number;
508+
errorType?: string;
509+
};
510+
511+
/* __GDPR__
512+
"pet.configure": {
513+
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
514+
"workspaceDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
515+
"envDirCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
516+
"retryCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
517+
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
518+
}
519+
*/
520+
[EventNames.PET_CONFIGURE]: {
521+
result: 'success' | 'timeout' | 'error' | 'skipped';
522+
workspaceDirCount?: number;
523+
envDirCount?: number;
524+
retryCount: number;
525+
};
526+
527+
/* __GDPR__
528+
"pet.process_restart": {
529+
"attempt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" },
530+
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
531+
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
532+
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
533+
}
534+
*/
535+
[EventNames.PET_PROCESS_RESTART]: {
536+
attempt: number;
537+
result: 'success' | 'error';
538+
errorType?: string;
539+
};
540+
541+
/* __GDPR__
542+
"pet.resolve": {
543+
"result": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
544+
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
545+
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "eleanorjboyd" }
546+
}
547+
*/
548+
[EventNames.PET_RESOLVE]: {
549+
result: 'success' | 'timeout' | 'error';
550+
errorType?: string;
551+
};
427552
}

src/features/interpreterSelection.ts

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -290,14 +290,16 @@ export async function applyInitialEnvironmentSelection(
290290
`[interpreterSelection] Applying initial environment selection for ${folders.length} workspace folder(s)`,
291291
);
292292

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

300299
const allErrors: SettingResolutionError[] = [];
300+
let workspaceFolderResolved = false;
301+
let resolvedFolderCount = 0;
302+
const selectionStopWatch = new StopWatch();
301303

302304
for (const folder of folders) {
303305
try {
@@ -311,23 +313,24 @@ export async function applyInitialEnvironmentSelection(
311313
);
312314
allErrors.push(...errors);
313315

314-
// Checkpoint 2: priority chain resolved — which path?
315-
const isPathA = result.environment !== undefined;
316-
317-
// Get the specific environment if not already resolved
318316
const env = result.environment ?? (await result.manager.get(folder.uri));
319317

320318
sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, scopeStopWatch.elapsedTime, {
321319
scope: 'workspace',
322320
prioritySource: result.source,
323321
managerId: result.manager.id,
324-
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
322+
resolutionPath: result.environment ? 'envPreResolved' : 'managerDiscovery',
325323
hasPersistedSelection: env !== undefined,
326324
});
327325

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

329+
if (env) {
330+
workspaceFolderResolved = true;
331+
resolvedFolderCount++;
332+
}
333+
331334
traceInfo(
332335
`[interpreterSelection] ${folder.name}: ${env?.displayName ?? 'none'} (source: ${result.source})`,
333336
);
@@ -336,49 +339,94 @@ export async function applyInitialEnvironmentSelection(
336339
}
337340
}
338341

339-
// Also apply initial selection for global scope (no workspace folder)
340-
// This ensures defaultInterpreterPath is respected even without a workspace
341-
try {
342-
const globalStopWatch = new StopWatch();
343-
const { result, errors } = await resolvePriorityChainCore(undefined, envManagers, undefined, nativeFinder, api);
344-
allErrors.push(...errors);
342+
// Resolve global scope (fallback for files outside workspace folders).
343+
// Deferred to background when a workspace folder already resolved.
344+
const resolveGlobalScope = async (): Promise<SettingResolutionError[]> => {
345+
try {
346+
const globalStopWatch = new StopWatch();
347+
const { result, errors: globalErrors } = await resolvePriorityChainCore(
348+
undefined,
349+
envManagers,
350+
undefined,
351+
nativeFinder,
352+
api,
353+
);
345354

346-
const isPathA = result.environment !== undefined;
355+
const env = result.environment ?? (await result.manager.get(undefined));
347356

348-
// Get the specific environment if not already resolved
349-
const env = result.environment ?? (await result.manager.get(undefined));
357+
sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, globalStopWatch.elapsedTime, {
358+
scope: 'global',
359+
prioritySource: result.source,
360+
managerId: result.manager.id,
361+
resolutionPath: result.environment ? 'envPreResolved' : 'managerDiscovery',
362+
hasPersistedSelection: env !== undefined,
363+
});
350364

351-
sendTelemetryEvent(EventNames.ENV_SELECTION_RESULT, globalStopWatch.elapsedTime, {
352-
scope: 'global',
353-
prioritySource: result.source,
354-
managerId: result.manager.id,
355-
resolutionPath: isPathA ? 'envPreResolved' : 'managerDiscovery',
356-
hasPersistedSelection: env !== undefined,
357-
});
365+
// Cache only — NO settings.json write
366+
await envManagers.setEnvironments('global', env, false);
358367

359-
// Cache only — NO settings.json write (shouldPersistSettings = false)
360-
await envManagers.setEnvironments('global', env, false);
368+
traceInfo(`[interpreterSelection] global: ${env?.displayName ?? 'none'} (source: ${result.source})`);
361369

362-
traceInfo(`[interpreterSelection] global: ${env?.displayName ?? 'none'} (source: ${result.source})`);
363-
} catch (err) {
364-
traceError(`[interpreterSelection] Failed to set global environment: ${err}`);
370+
return globalErrors;
371+
} catch (err) {
372+
traceError(`[interpreterSelection] Failed to set global environment: ${err}`);
373+
return [];
374+
}
375+
};
376+
377+
if (workspaceFolderResolved) {
378+
// Defer global scope so it doesn't block post-selection startup.
379+
traceInfo('[interpreterSelection] Workspace env resolved, deferring global scope to background');
380+
resolveGlobalScope()
381+
.then(async (globalErrors) => {
382+
if (globalErrors.length > 0) {
383+
await notifyUserOfSettingErrors(globalErrors);
384+
}
385+
})
386+
.catch((err) => traceError(`[interpreterSelection] Background global scope resolution failed: ${err}`));
387+
} else {
388+
// No workspace folder resolved — global scope is the primary fallback, must await.
389+
const globalErrors = await resolveGlobalScope();
390+
allErrors.push(...globalErrors);
365391
}
366392

367-
// Notify user if any settings could not be applied
393+
// Notify user if any settings could not be applied (workspace + global when awaited)
368394
if (allErrors.length > 0) {
369395
await notifyUserOfSettingErrors(allErrors);
370396
}
397+
398+
// Duration measures blocking time only (excludes deferred global scope).
399+
sendTelemetryEvent(EventNames.ENV_SELECTION_COMPLETED, selectionStopWatch.elapsedTime, {
400+
globalScopeDeferred: workspaceFolderResolved,
401+
workspaceFolderCount: folders.length,
402+
resolvedFolderCount,
403+
settingErrorCount: allErrors.length,
404+
});
371405
}
372406

373407
/**
374408
* Notify the user when their configured settings could not be applied.
375409
* Shows a warning message with an option to open settings.
410+
* Tracks already-warned settings to avoid duplicate dialogs (e.g., when
411+
* the same user-level misconfiguration is hit by both workspace and
412+
* deferred global scope resolution).
376413
*/
414+
const warnedSettings = new Set<string>();
415+
416+
export function resetSettingWarnings(): void {
417+
warnedSettings.clear();
418+
}
419+
377420
async function notifyUserOfSettingErrors(errors: SettingResolutionError[]): Promise<void> {
378421
// Group errors by setting type to avoid spamming the user
379422
const uniqueSettings = [...new Set(errors.map((e) => e.setting))];
380423

381424
for (const setting of uniqueSettings) {
425+
if (warnedSettings.has(setting)) {
426+
continue;
427+
}
428+
warnedSettings.add(setting);
429+
382430
const settingErrors = errors.filter((e) => e.setting === setting);
383431
const firstError = settingErrors[0];
384432

0 commit comments

Comments
 (0)