Skip to content

Commit 54c55e8

Browse files
authored
Merge branch 'main' into fix/defaultinterpreterpath-workspacefolder-global
2 parents a2532b9 + c226b66 commit 54c55e8

File tree

15 files changed

+834
-35
lines changed

15 files changed

+834
-35
lines changed

build/azure-pipeline.npm.yml

Lines changed: 112 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ parameters:
2020
- preview
2121

2222
- name: publishPackage
23-
displayName: 🚀 Publish to npm
23+
displayName: 🚀 Publish npm
24+
type: boolean
25+
default: false
26+
27+
- name: publishToConsumptionFeed
28+
displayName: 📡 Publish to msft_consumption feed
2429
type: boolean
2530
default: false
2631

@@ -56,6 +61,18 @@ variables:
5661
value: next
5762
${{ else }}:
5863
value: latest
64+
- name: AzureArtifactsFeedUrl
65+
value: 'https://pkgs.dev.azure.com/azure-public/vside/_packaging/python-environments/npm/registry/'
66+
# Same URL without the https:// prefix (used in .npmrc auth lines)
67+
- name: AzureArtifactsFeedUrlNoProtocol
68+
value: 'pkgs.dev.azure.com/azure-public/vside/_packaging/python-environments/npm/registry/'
69+
# Managed Identity service connection for Azure Artifacts auth (shared with Pylance)
70+
- name: AzureServiceConnection
71+
value: 'PylanceSecureVsIdePublishWithManagedIdentity'
72+
- name: ConsumptionFeedUrl
73+
value: 'https://pkgs.dev.azure.com/azure-public/vside/_packaging/msft_consumption/npm/registry/'
74+
- name: ConsumptionFeedUrlNoProtocol
75+
value: 'pkgs.dev.azure.com/azure-public/vside/_packaging/msft_consumption/npm/registry/'
5976

6077
extends:
6178
template: azure-pipelines/MicroBuild.1ES.Official.yml@MicroBuildTemplate
@@ -97,30 +114,111 @@ extends:
97114
targetFolder: $(Build.ArtifactStagingDirectory)
98115

99116
- stage: Publish
100-
displayName: Publish to npm
117+
displayName: Publish to Azure Artifacts
101118
dependsOn: Build
102119
condition: and(succeeded(), eq('${{ parameters.publishPackage }}', 'true'))
103120
jobs:
104121
- job: PublishPackage
105122
displayName: Publish $(PackageName)
106-
steps:
107-
- task: DownloadPipelineArtifact@2
108-
displayName: Download build artifact
109-
inputs:
123+
templateContext:
124+
type: releaseJob
125+
isProduction: true
126+
inputs:
127+
- input: pipelineArtifact
110128
artifactName: npm-package
111-
targetPath: $(Build.ArtifactStagingDirectory)/npm-package
129+
targetPath: $(Pipeline.Workspace)/npm-package
130+
steps:
131+
- checkout: none
112132

113133
- task: NodeTool@0
114134
inputs:
115135
versionSpec: '22.21.1'
116136
displayName: Select Node version
117137

118-
- bash: echo '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}' > .npmrc
119-
workingDirectory: $(Build.SourcesDirectory)/pythonEnvironmentsApi
120-
displayName: Configure npm auth
138+
# Acquire a short-lived AAD token via Managed Identity (no stored secrets)
139+
# SEE https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/1es-security-configuration/configuration-guides/pat-burndown-guidance
140+
- task: AzureCLI@2
141+
displayName: Acquire AAD token via Managed Identity
142+
inputs:
143+
azureSubscription: '$(AzureServiceConnection)'
144+
scriptType: 'pscore'
145+
scriptLocation: 'inlineScript'
146+
inlineScript: |
147+
$token = az account get-access-token --query accessToken --resource 499b84ac-1321-427f-aa17-267ca6975798 -o tsv
148+
Write-Host "##vso[task.setvariable variable=AzdoToken;issecret=true]$token"
149+
150+
- powershell: |
151+
@"
152+
registry=$(AzureArtifactsFeedUrl)
153+
always-auth=true
154+
"@ | Out-File -FilePath .npmrc
155+
156+
@"
157+
; begin auth token
158+
//$(AzureArtifactsFeedUrlNoProtocol):username=VssSessionToken
159+
//$(AzureArtifactsFeedUrlNoProtocol):_authToken=$env:AZDO_TOKEN
160+
//$(AzureArtifactsFeedUrlNoProtocol):email=not-used@example.com
161+
; end auth token
162+
"@ | Out-File -FilePath $HOME/.npmrc
163+
env:
164+
AZDO_TOKEN: $(AzdoToken)
165+
displayName: Create .npmrc files
166+
167+
- powershell: |
168+
$tgz = Get-ChildItem "$(Pipeline.Workspace)/npm-package/*.tgz" | Select-Object -First 1
169+
if (-not $tgz) {
170+
Write-Error "No .tgz file found in $(Pipeline.Workspace)/npm-package/"
171+
exit 1
172+
}
173+
Write-Host "Publishing: $($tgz.FullName)"
174+
if ("$(npmTag)" -eq "next") {
175+
npm publish $tgz.FullName --registry $(AzureArtifactsFeedUrl) --tag next --ignore-scripts
176+
} else {
177+
npm publish $tgz.FullName --registry $(AzureArtifactsFeedUrl) --ignore-scripts
178+
}
179+
displayName: npm publish (${{ parameters.quality }})
180+
181+
- stage: PublishConsumption
182+
displayName: Publish package to msft_consumption feed
183+
dependsOn: Publish
184+
condition: and(not(failed()), eq('${{ parameters.publishToConsumptionFeed }}', 'true'))
185+
jobs:
186+
- job: PullToConsumption
187+
displayName: Pull $(PackageName) to msft_consumption
188+
steps:
189+
- checkout: none
190+
191+
- task: NodeTool@0
192+
inputs:
193+
versionSpec: '22.21.1'
194+
displayName: Select Node version
121195

122-
- bash: npm publish $(Build.ArtifactStagingDirectory)/npm-package/*.tgz --tag $(npmTag) --access public --ignore-scripts
123-
displayName: Publish to npm (${{ parameters.quality }})
124-
workingDirectory: $(Build.SourcesDirectory)/pythonEnvironmentsApi
196+
- task: AzureCLI@2
197+
displayName: Acquire AAD token via Managed Identity
198+
inputs:
199+
azureSubscription: '$(AzureServiceConnection)'
200+
scriptType: 'pscore'
201+
scriptLocation: 'inlineScript'
202+
inlineScript: |
203+
$token = az account get-access-token --query accessToken --resource 499b84ac-1321-427f-aa17-267ca6975798 -o tsv
204+
Write-Host "##vso[task.setvariable variable=AzdoToken;issecret=true]$token"
205+
206+
- powershell: |
207+
@"
208+
registry=$(ConsumptionFeedUrl)
209+
always-auth=true
210+
"@ | Out-File -FilePath .npmrc
211+
212+
@"
213+
; begin auth token
214+
//$(ConsumptionFeedUrlNoProtocol):username=VssSessionToken
215+
//$(ConsumptionFeedUrlNoProtocol):_authToken=$env:AZDO_TOKEN
216+
//$(ConsumptionFeedUrlNoProtocol):email=not-used@example.com
217+
; end auth token
218+
"@ | Out-File -FilePath $HOME/.npmrc
125219
env:
126-
NODE_AUTH_TOKEN: $(NpmAuthToken)
220+
AZDO_TOKEN: $(AzdoToken)
221+
displayName: Create .npmrc files
222+
223+
- script: npm i -g $(PackageName)@$(npmTag) --registry $(ConsumptionFeedUrl)
224+
displayName: Pull to msft_consumption

build/azure-pipeline.stable.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ extends:
112112
project: 'Monaco'
113113
definition: 593
114114
buildVersionToDownload: 'latestFromBranch'
115-
branchName: 'refs/heads/release/2026.2'
115+
branchName: 'refs/heads/release/2026.4'
116116
targetPath: '$(Build.SourcesDirectory)/python-env-tools/bin'
117117
artifactName: 'bin-$(buildTarget)'
118118
itemPattern: |

src/common/telemetry/constants.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,30 @@ export enum EventNames {
5050
*/
5151
ENVIRONMENT_DISCOVERY = 'ENVIRONMENT_DISCOVERY',
5252
MANAGER_READY_TIMEOUT = 'MANAGER_READY.TIMEOUT',
53+
/**
54+
* Telemetry event for individual manager registration failure.
55+
* Fires once per manager that fails during registration (inside safeRegister).
56+
* Properties:
57+
* - managerName: string (e.g. 'system', 'conda', 'pyenv', 'pipenv', 'poetry', 'shellStartupVars')
58+
* - errorType: string (classified error category from classifyError)
59+
*/
60+
MANAGER_REGISTRATION_FAILED = 'MANAGER_REGISTRATION.FAILED',
61+
/**
62+
* Telemetry event fired when the setup block appears to be hung.
63+
* A watchdog timer fires after a deadline; if the setup completes normally,
64+
* the timer is cancelled and this event never fires.
65+
* Properties:
66+
* - failureStage: string (which phase was in progress when the watchdog fired)
67+
*/
68+
SETUP_HANG_DETECTED = 'SETUP.HANG_DETECTED',
69+
/**
70+
* Telemetry event for when a manager skips registration because its tool was not found.
71+
* This is an expected outcome (not an error) and is distinct from MANAGER_REGISTRATION_FAILED.
72+
* Properties:
73+
* - managerName: string (e.g. 'conda', 'pyenv', 'pipenv', 'poetry')
74+
* - reason: string ('tool_not_found')
75+
*/
76+
MANAGER_REGISTRATION_SKIPPED = 'MANAGER_REGISTRATION.SKIPPED',
5377
}
5478

5579
// Map all events to their properties
@@ -62,10 +86,17 @@ export interface IEventNamePropertyMapping {
6286
[EventNames.EXTENSION_ACTIVATION_DURATION]: never | undefined;
6387
/* __GDPR__
6488
"extension.manager_registration_duration": {
65-
"duration" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" }
89+
"duration" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "eleanorjboyd" },
90+
"result" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
91+
"failureStage" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
92+
"errorType" : { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }
6693
}
6794
*/
68-
[EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION]: never | undefined;
95+
[EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION]: {
96+
result: 'success' | 'error';
97+
failureStage?: string;
98+
errorType?: string;
99+
};
69100

70101
/* __GDPR__
71102
"environment_manager.registered": {
@@ -239,4 +270,36 @@ export interface IEventNamePropertyMapping {
239270
managerId: string;
240271
managerKind: 'environment' | 'package';
241272
};
273+
274+
/* __GDPR__
275+
"manager_registration.failed": {
276+
"managerName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
277+
"errorType": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }
278+
}
279+
*/
280+
[EventNames.MANAGER_REGISTRATION_FAILED]: {
281+
managerName: string;
282+
errorType: string;
283+
};
284+
285+
/* __GDPR__
286+
"setup.hang_detected": {
287+
"failureStage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
288+
"<duration>": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "owner": "StellaHuang95" }
289+
}
290+
*/
291+
[EventNames.SETUP_HANG_DETECTED]: {
292+
failureStage: string;
293+
};
294+
295+
/* __GDPR__
296+
"manager_registration.skipped": {
297+
"managerName": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" },
298+
"reason": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "owner": "StellaHuang95" }
299+
}
300+
*/
301+
[EventNames.MANAGER_REGISTRATION_SKIPPED]: {
302+
managerName: string;
303+
reason: 'tool_not_found';
304+
};
242305
}

src/common/utils/asyncUtils.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { traceError } from '../logging';
2+
import { EventNames } from '../telemetry/constants';
3+
import { classifyError } from '../telemetry/errorClassifier';
4+
import { sendTelemetryEvent } from '../telemetry/sender';
25

36
export async function timeout(milliseconds: number): Promise<void> {
47
return new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
@@ -13,5 +16,9 @@ export async function safeRegister(name: string, task: Promise<void>): Promise<v
1316
await task;
1417
} catch (error) {
1518
traceError(`Failed to register ${name} features:`, error);
19+
sendTelemetryEvent(EventNames.MANAGER_REGISTRATION_FAILED, undefined, {
20+
managerName: name,
21+
errorType: classifyError(error),
22+
});
1623
}
1724
}

src/extension.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { clearPersistentState, setPersistentState } from './common/persistentSta
1717
import { newProjectSelection } from './common/pickers/managers';
1818
import { StopWatch } from './common/stopWatch';
1919
import { EventNames } from './common/telemetry/constants';
20+
import { classifyError } from './common/telemetry/errorClassifier';
2021
import {
2122
logDiscoverySummary,
2223
sendEnvironmentToolUsageTelemetry,
@@ -522,13 +523,34 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
522523
* Below are all the contributed features using the APIs.
523524
*/
524525
setImmediate(async () => {
526+
let failureStage = 'nativeFinder';
527+
// Watchdog: fires if setup hasn't completed within 120s, indicating a likely hang
528+
const SETUP_HANG_TIMEOUT_MS = 120_000;
529+
let hangWatchdogActive = true;
530+
const clearHangWatchdog = () => {
531+
if (!hangWatchdogActive) {
532+
return;
533+
}
534+
hangWatchdogActive = false;
535+
clearTimeout(hangWatchdog);
536+
};
537+
const hangWatchdog = setTimeout(() => {
538+
if (!hangWatchdogActive) {
539+
return;
540+
}
541+
hangWatchdogActive = false;
542+
traceError(`Setup appears hung during stage: ${failureStage}`);
543+
sendTelemetryEvent(EventNames.SETUP_HANG_DETECTED, start.elapsedTime, { failureStage });
544+
}, SETUP_HANG_TIMEOUT_MS);
545+
context.subscriptions.push({ dispose: clearHangWatchdog });
525546
try {
526547
// This is the finder that is used by all the built in environment managers
527548
const nativeFinder: NativePythonFinder = await createNativePythonFinder(outputChannel, api, context);
528549
context.subscriptions.push(nativeFinder);
529550
const sysMgr = new SysPythonManager(nativeFinder, api, outputChannel);
530551
sysPythonManager.resolve(sysMgr);
531552
// Each manager registers independently — one failure must not block the others.
553+
failureStage = 'managerRegistration';
532554
await Promise.all([
533555
safeRegister(
534556
'system',
@@ -547,17 +569,23 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
547569
safeRegister('shellStartupVars', shellStartupVarsMgr.initialize()),
548570
]);
549571

572+
failureStage = 'envSelection';
550573
await applyInitialEnvironmentSelection(envManagers, projectManager, nativeFinder, api);
551574

552575
// Register manager-agnostic terminal watcher for package-modifying commands
576+
failureStage = 'terminalWatcher';
553577
registerTerminalPackageWatcher(api, terminalActivation, outputChannel, context.subscriptions);
554578

555579
// Register listener for interpreter settings changes for interpreter re-selection
580+
failureStage = 'settingsListener';
556581
context.subscriptions.push(
557582
registerInterpreterSettingsChangeListener(envManagers, projectManager, nativeFinder, api),
558583
);
559584

560-
sendTelemetryEvent(EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION, start.elapsedTime);
585+
sendTelemetryEvent(EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION, start.elapsedTime, {
586+
result: 'success',
587+
});
588+
clearHangWatchdog();
561589
try {
562590
await terminalManager.initialize(api);
563591
sendManagerSelectionTelemetry(projectManager);
@@ -570,11 +598,16 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
570598
traceError('Post-initialization tasks failed:', postInitError);
571599
}
572600
} catch (error) {
601+
clearHangWatchdog();
573602
traceError('Failed to initialize environment managers:', error);
574603
sendTelemetryEvent(
575604
EventNames.EXTENSION_MANAGER_REGISTRATION_DURATION,
576605
start.elapsedTime,
577-
undefined,
606+
{
607+
result: 'error',
608+
failureStage,
609+
errorType: classifyError(error),
610+
},
578611
error instanceof Error ? error : undefined,
579612
);
580613
// Show a user-friendly error message

src/features/interpreterSelection.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,27 @@ async function resolvePriorityChainCore(
106106
// PRIORITY 3: User-configured python.defaultInterpreterPath
107107
const userInterpreterPath = getUserConfiguredSetting<string>('python', 'defaultInterpreterPath', scope);
108108
if (userInterpreterPath) {
109-
if (!scope && userInterpreterPath.includes('${workspaceFolder}')) {
110-
traceVerbose(
111-
`${logPrefix} Skipping workspace-scoped defaultInterpreterPath during global resolution: ${userInterpreterPath}`,
112-
);
109+
const expandedInterpreterPath = resolveVariables(userInterpreterPath, scope);
110+
if (expandedInterpreterPath.includes('${')) {
111+
if (scope) {
112+
// Workspace scope: unresolved variables are a genuine configuration error
113+
traceWarn(
114+
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' contains unresolved variables, falling back to auto-discovery`,
115+
);
116+
const error: SettingResolutionError = {
117+
setting: 'defaultInterpreterPath',
118+
configuredValue: userInterpreterPath,
119+
reason: l10n.t('Path contains unresolved variables'),
120+
};
121+
errors.push(error);
122+
} else {
123+
// Global scope: workspace-specific variables like ${workspaceFolder} can't resolve here.
124+
// This is expected when a workspace-level setting uses workspace variables —
125+
// the per-folder chain handles them correctly. Silently skip to auto-discovery.
126+
traceVerbose(
127+
`${logPrefix} defaultInterpreterPath '${userInterpreterPath}' contains workspace-specific variables, skipping for global scope`,
128+
);
129+
}
113130
} else {
114131
const expandedInterpreterPath = resolveVariables(userInterpreterPath, scope);
115132
if (expandedInterpreterPath.includes('${')) {

0 commit comments

Comments
 (0)