Skip to content

Commit d7df91e

Browse files
authored
Merge branch 'main' into copilot/fix-environment-test-failure
2 parents 0242919 + bb6e0f4 commit d7df91e

5 files changed

Lines changed: 304 additions & 17 deletions

File tree

src/features/common/activation.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getShellActivationCommand,
55
getShellCommandAsString,
66
getShellDeactivationCommand,
7+
wrapDeactivationCommand,
78
} from '../terminal/shells/common/shellUtils';
89
import { identifyTerminalShell } from './shellDetector';
910

@@ -28,7 +29,7 @@ export function getDeactivationCommand(terminal: Terminal, environment: PythonEn
2829
const shell = identifyTerminalShell(terminal);
2930
const command = getShellDeactivationCommand(shell, environment);
3031
if (command) {
31-
return getShellCommandAsString(shell, command);
32+
return wrapDeactivationCommand(shell, getShellCommandAsString(shell, command));
3233
}
3334
return undefined;
3435
}

src/features/interpreterSelection.ts

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ export interface PriorityChainResult {
4444
export interface SettingResolutionError {
4545
/** The setting that failed */
4646
setting: 'pythonProjects' | 'defaultEnvManager' | 'defaultInterpreterPath';
47+
/** Structured failure kind, used for localization and stale-error re-checks. */
48+
kind: 'managerNotRegistered' | 'pathUnresolvedVariables' | 'pathCannotResolve';
4749
/** The configured value */
4850
configuredValue: string;
49-
/** Reason for failure */
50-
reason: string;
5151
}
5252

5353
/**
@@ -81,8 +81,8 @@ async function resolvePriorityChainCore(
8181
}
8282
const error: SettingResolutionError = {
8383
setting: 'pythonProjects',
84+
kind: 'managerNotRegistered',
8485
configuredValue: projectManagerId,
85-
reason: `Environment manager '${projectManagerId}' is not registered`,
8686
};
8787
errors.push(error);
8888
traceWarn(`${logPrefix} pythonProjects[] manager '${projectManagerId}' not found, trying next priority`);
@@ -99,8 +99,8 @@ async function resolvePriorityChainCore(
9999
}
100100
const error: SettingResolutionError = {
101101
setting: 'defaultEnvManager',
102+
kind: 'managerNotRegistered',
102103
configuredValue: userConfiguredManager,
103-
reason: `Environment manager '${userConfiguredManager}' is not registered`,
104104
};
105105
errors.push(error);
106106
traceWarn(`${logPrefix} defaultEnvManager '${userConfiguredManager}' not found, trying next priority`);
@@ -118,8 +118,8 @@ async function resolvePriorityChainCore(
118118
);
119119
const error: SettingResolutionError = {
120120
setting: 'defaultInterpreterPath',
121+
kind: 'pathUnresolvedVariables',
121122
configuredValue: userInterpreterPath,
122-
reason: l10n.t('Path contains unresolved variables'),
123123
};
124124
errors.push(error);
125125
} else {
@@ -138,8 +138,8 @@ async function resolvePriorityChainCore(
138138
);
139139
const error: SettingResolutionError = {
140140
setting: 'defaultInterpreterPath',
141+
kind: 'pathUnresolvedVariables',
141142
configuredValue: userInterpreterPath,
142-
reason: l10n.t('Path contains unresolved variables'),
143143
};
144144
errors.push(error);
145145
} else {
@@ -155,8 +155,8 @@ async function resolvePriorityChainCore(
155155
}
156156
const error: SettingResolutionError = {
157157
setting: 'defaultInterpreterPath',
158+
kind: 'pathCannotResolve',
158159
configuredValue: userInterpreterPath,
159-
reason: `Could not resolve interpreter path '${userInterpreterPath}'`,
160160
};
161161
errors.push(error);
162162
traceWarn(
@@ -383,8 +383,9 @@ export async function applyInitialEnvironmentSelection(
383383
}
384384
resolveGlobalScope()
385385
.then(async (globalErrors) => {
386-
if (globalErrors.length > 0) {
387-
await notifyUserOfSettingErrors(globalErrors);
386+
const liveGlobalErrors = filterLiveSettingErrors(globalErrors, envManagers);
387+
if (liveGlobalErrors.length > 0) {
388+
await notifyUserOfSettingErrors(liveGlobalErrors);
388389
}
389390
})
390391
.catch((err) => traceError(`[interpreterSelection] Background global scope resolution failed: ${err}`));
@@ -397,9 +398,11 @@ export async function applyInitialEnvironmentSelection(
397398
allErrors.push(...globalErrors);
398399
}
399400

400-
// Notify user if any settings could not be applied (workspace + global when awaited)
401-
if (allErrors.length > 0) {
402-
await notifyUserOfSettingErrors(allErrors);
401+
// Drop errors that became stale during the awaits above; see filterLiveSettingErrors.
402+
const liveErrors = filterLiveSettingErrors(allErrors, envManagers);
403+
404+
if (liveErrors.length > 0) {
405+
await notifyUserOfSettingErrors(liveErrors);
403406
}
404407

405408
// Numeric values must go via the measures argument (properties are dropped).
@@ -409,7 +412,7 @@ export async function applyInitialEnvironmentSelection(
409412
duration: selectionStopWatch.elapsedTime,
410413
workspaceFolderCount: folders.length,
411414
resolvedFolderCount,
412-
settingErrorCount: allErrors.length,
415+
settingErrorCount: liveErrors.length,
413416
},
414417
{
415418
globalScopeDeferred: workspaceFolderResolved,
@@ -430,7 +433,42 @@ export function resetSettingWarnings(): void {
430433
warnedSettings.clear();
431434
}
432435

436+
function localizedReasonFor(error: SettingResolutionError): string {
437+
switch (error.kind) {
438+
case 'managerNotRegistered':
439+
return l10n.t("Environment manager '{0}' is not registered", error.configuredValue);
440+
case 'pathUnresolvedVariables':
441+
return l10n.t('Path contains unresolved variables');
442+
case 'pathCannotResolve':
443+
return l10n.t("Could not resolve interpreter path '{0}'", error.configuredValue);
444+
}
445+
}
446+
447+
/**
448+
* Re-check the live registry to drop `managerNotRegistered` errors that became
449+
* stale while later awaits ran — a third-party manager extension may have
450+
* registered in the meantime, in which case the warning would be a false positive.
451+
*/
452+
function filterLiveSettingErrors(
453+
errors: SettingResolutionError[],
454+
envManagers: EnvironmentManagers,
455+
): SettingResolutionError[] {
456+
return errors.filter((e) => {
457+
if (e.kind === 'managerNotRegistered' && envManagers.getEnvironmentManager(e.configuredValue)) {
458+
traceVerbose(
459+
`[interpreterSelection] Manager '${e.configuredValue}' is now registered; suppressing stale warning`,
460+
);
461+
return false;
462+
}
463+
return true;
464+
});
465+
}
466+
433467
async function notifyUserOfSettingErrors(errors: SettingResolutionError[]): Promise<void> {
468+
if (errors.length === 0) {
469+
return;
470+
}
471+
434472
// Group errors by setting type to avoid spamming the user
435473
const uniqueSettings = [...new Set(errors.map((e) => e.setting))];
436474

@@ -442,6 +480,7 @@ async function notifyUserOfSettingErrors(errors: SettingResolutionError[]): Prom
442480

443481
const settingErrors = errors.filter((e) => e.setting === setting);
444482
const firstError = settingErrors[0];
483+
const reason = localizedReasonFor(firstError);
445484

446485
let message: string;
447486
let settingKey: string;
@@ -451,23 +490,23 @@ async function notifyUserOfSettingErrors(errors: SettingResolutionError[]): Prom
451490
message = l10n.t(
452491
"Python project setting for environment manager '{0}' could not be applied: {1}",
453492
firstError.configuredValue,
454-
firstError.reason,
493+
reason,
455494
);
456495
settingKey = 'python-envs.pythonProjects';
457496
break;
458497
case 'defaultEnvManager':
459498
message = l10n.t(
460499
"Default environment manager '{0}' could not be applied: {1}",
461500
firstError.configuredValue,
462-
firstError.reason,
501+
reason,
463502
);
464503
settingKey = 'python-envs.defaultEnvManager';
465504
break;
466505
case 'defaultInterpreterPath':
467506
message = l10n.t(
468507
"Default interpreter path '{0}' could not be resolved: {1}",
469508
firstError.configuredValue,
470-
firstError.reason,
509+
reason,
471510
);
472511
settingKey = 'python.defaultInterpreterPath';
473512
break;

src/features/terminal/shells/common/shellUtils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,68 @@ export function getShellCommandAsString(shell: string, command: PythonCommandRun
5050
return commandStr;
5151
}
5252

53+
// Shells whose bare `deactivate` command is a shell function/alias defined by venv's
54+
// `activate` script. These can disappear (e.g. on session restore where VIRTUAL_ENV
55+
// persists but the shell function does not), so we must guard the call with an
56+
// existence check before sending it to the terminal — otherwise the shell prints
57+
// `deactivate: command not found`.
58+
const bareDeactivateGuardByShell = new Map<string, (cmd: string) => string>([
59+
// POSIX-family shells: `command -v <name>` is the portable existence check.
60+
[ShellConstants.BASH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
61+
[ShellConstants.SH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
62+
[ShellConstants.ZSH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
63+
[ShellConstants.KSH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
64+
[ShellConstants.GITBASH, (cmd) => `command -v deactivate >/dev/null 2>&1 && ${cmd.trimStart()}`],
65+
// fish uses `functions -q` for function existence.
66+
[ShellConstants.FISH, (cmd) => `functions -q deactivate; and ${cmd.trimStart()}`],
67+
// PowerShell: Get-Command returns silently if not found with -ErrorAction SilentlyContinue.
68+
[ShellConstants.PWSH, (cmd) => `if (Get-Command deactivate -ErrorAction SilentlyContinue) { ${cmd.trimStart()} }`],
69+
]);
70+
71+
/**
72+
* Returns the bare `deactivate` token if and only if `command` represents a single,
73+
* bare invocation of a shell function/alias literally named `deactivate` — meaning
74+
* it is safe and meaningful to gate on the existence of that function in the shell.
75+
*
76+
* Returns `undefined` for anything else (full paths like `path/to/deactivate.bat`,
77+
* multi-token forms like `conda deactivate`, `pyenv shell --unset`, `overlay hide ...`,
78+
* etc.), since those have different failure modes that should not be silently swallowed.
79+
*
80+
* The token is normalized to lowercase so the generated guard is consistent across
81+
* shells (notably PowerShell, which is case-insensitive).
82+
*/
83+
function bareDeactivateInvocation(command: string): string | undefined {
84+
const trimmed = command.trim();
85+
return trimmed.toLowerCase() === 'deactivate' ? 'deactivate' : undefined;
86+
}
87+
88+
/**
89+
* Wraps a deactivation command in a shell-specific existence guard so that sending
90+
* it to a terminal where the `deactivate` shell function no longer exists does not
91+
* print `deactivate: command not found`.
92+
*
93+
* Only applies when the command is a single bare `deactivate` token and the shell
94+
* has a known guard template. All other deactivation forms (cmd's `deactivate.bat`
95+
* path, `conda deactivate`, `pyenv shell --unset`, nu's `overlay hide ...`, etc.)
96+
* are returned unchanged — their failure modes are legitimate and should surface.
97+
*/
98+
export function wrapDeactivationCommand(shell: string, command: string): string {
99+
const bare = bareDeactivateInvocation(command);
100+
if (!bare) {
101+
return command;
102+
}
103+
const guard = bareDeactivateGuardByShell.get(shell);
104+
if (!guard) {
105+
return command;
106+
}
107+
const wrapped = guard(bare);
108+
// Preserve the leading-space history-ignore behavior for shells that honor it.
109+
if (shellsWithLeadingSpaceHistorySupport.has(shell)) {
110+
return ` ${wrapped}`;
111+
}
112+
return wrapped;
113+
}
114+
53115
export function normalizeShellPath(filePath: string, shellType?: string): string {
54116
if (isWindows() && shellType) {
55117
if (shellType.toLowerCase() === ShellConstants.GITBASH || shellType.toLowerCase() === 'git-bash') {

src/test/features/interpreterSelection.unit.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -968,6 +968,97 @@ suite('Interpreter Selection - applyInitialEnvironmentSelection', () => {
968968
const warningMessage = showWarnStub.firstCall.args[0] as string;
969969
assert.ok(warningMessage.includes('/nonexistent/python'), 'Warning message should include the configured path');
970970
});
971+
972+
test('should suppress stale warning when manager registers during await (issue #1517)', async () => {
973+
// Simulate the race: defaultEnvManager is set to 'pypa.hatch:hatch', which is not
974+
// registered at priority-chain evaluation time, but becomes registered before
975+
// notifyUserOfSettingErrors runs. The warning must NOT be shown — neither from the
976+
// awaited workspace path nor from the deferred global `.then(...)` path.
977+
const hatchManagerId = 'pypa.hatch:hatch';
978+
979+
sandbox.stub(workspaceApis, 'getWorkspaceFolders').returns([{ uri: testUri, name: 'test', index: 0 }]);
980+
sandbox.stub(workspaceApis, 'getConfiguration').returns(createMockConfig([]) as WorkspaceConfiguration);
981+
sandbox.stub(helpers, 'getUserConfiguredSetting').callsFake((section: string, key: string) => {
982+
if (section === 'python-envs' && key === 'defaultEnvManager') {
983+
return hatchManagerId;
984+
}
985+
return undefined;
986+
});
987+
988+
// Hatch is not registered when the priority chain runs (returns undefined),
989+
// but by the time notifyUserOfSettingErrors is called, it is available.
990+
const mockHatchManager = {
991+
id: hatchManagerId,
992+
name: 'hatch',
993+
displayName: 'Hatch',
994+
get: sandbox.stub().resolves(undefined),
995+
set: sandbox.stub().resolves(),
996+
} as unknown as sinon.SinonStubbedInstance<InternalEnvironmentManager>;
997+
998+
let hatchRegistered = false;
999+
// Override getEnvironmentManager: returns undefined until hatchRegistered is set, then returns the manager
1000+
mockEnvManagers.getEnvironmentManager.callsFake((scope: unknown) => {
1001+
const id = typeof scope === 'string' ? scope : undefined;
1002+
if (id === hatchManagerId && hatchRegistered) {
1003+
return mockHatchManager;
1004+
}
1005+
if (id === 'ms-python.python:venv') {
1006+
return mockVenvManager;
1007+
}
1008+
if (id === 'ms-python.python:system') {
1009+
return mockSystemManager;
1010+
}
1011+
return undefined;
1012+
});
1013+
1014+
// Simulate Hatch registering during the venv discovery await
1015+
mockVenvManager.get.callsFake(async () => {
1016+
hatchRegistered = true; // Hatch "registers" while venv discovery is awaited
1017+
return mockVenvEnv;
1018+
});
1019+
1020+
// The workspace folder will resolve (venv found), so global scope is deferred to
1021+
// a background `.then(...)`. Use a deferred promise to deterministically wait for
1022+
// that background work to complete before asserting on showWarningMessage —
1023+
// otherwise a regression in the deferred-path filtering would fire AFTER the test
1024+
// returned and silently slip through.
1025+
let resolveGlobalDone!: () => void;
1026+
const globalDone = new Promise<void>((resolve) => {
1027+
resolveGlobalDone = resolve;
1028+
});
1029+
mockEnvManagers.setEnvironments.callsFake(async () => {
1030+
resolveGlobalDone();
1031+
});
1032+
1033+
const showWarnStub = sandbox.stub(windowApis, 'showWarningMessage').resolves(undefined);
1034+
1035+
await applyInitialEnvironmentSelection(
1036+
mockEnvManagers as unknown as EnvironmentManagers,
1037+
mockProjectManager as unknown as PythonProjectManager,
1038+
mockNativeFinder as unknown as NativePythonFinder,
1039+
mockApi as unknown as PythonEnvironmentApi,
1040+
);
1041+
1042+
// Wait for the background global scope to call setEnvironments, then flush
1043+
// microtasks so the `.then(...)` handler that would call notifyUserOfSettingErrors
1044+
// has a chance to run before we assert.
1045+
await globalDone;
1046+
await new Promise<void>((resolve) => process.nextTick(resolve));
1047+
1048+
// Workspace folder should have resolved (venv found) and global scope was attempted
1049+
assert.ok(mockEnvManagers.setEnvironment.called, 'setEnvironment should be called for workspace folder');
1050+
assert.ok(
1051+
mockEnvManagers.setEnvironments.called,
1052+
'setEnvironments should be called for deferred global scope',
1053+
);
1054+
1055+
// Warning must NOT be shown because the error was stale on both paths
1056+
assert.strictEqual(
1057+
showWarnStub.called,
1058+
false,
1059+
'showWarningMessage must NOT be called when manager registered before notification',
1060+
);
1061+
});
9711062
});
9721063

9731064
suite('Interpreter Selection - resolveGlobalEnvironmentByPriority', () => {

0 commit comments

Comments
 (0)