Skip to content

Commit ac36330

Browse files
Fix base conda environment incorrectly resolved to a named environment on reload (#1412)
Fixes microsoft/vscode-python#25814 ## Problem After selecting the **base** conda environment and reloading VS Code, a different named conda environment is shown as active instead. The wrong environment is typically whichever named env has the highest Python version. ## Root Cause `findEnvironmentByPath()` in `CondaEnvManager` uses a single `Array.find()` with three match strategies evaluated in OR: exact path, parent directory, and grandparent directory. ```typescript // Before (broken) return this.collection.find((e) => { const n = normalizePath(e.environmentPath.fsPath); return ( n === normalized || normalizePath(path.dirname(e.environmentPath.fsPath)) === normalized || normalizePath(path.dirname(path.dirname(e.environmentPath.fsPath))) === normalized ); }); ``` Conda environments have this disk layout: ``` /miniconda3/ ← base environment (prefix) /miniconda3/envs/torch/ ← named env "torch" /miniconda3/envs/data_science/ ← named env "data_science" ``` The grandparent of every named environment (`dirname(dirname(/miniconda3/envs/torch))`) equals the base environment's own path (`/miniconda3`). Since the collection is sorted by Python version descending, any named env with a higher version than base appears **before** base in the array. `Array.find()` returns the first match — so the named env matches via grandparent before base can match via exact path. This only triggers on **reload/restart** (not live selection), because `loadEnvMap()` reads the stored path from persistent state and calls `findEnvironmentByPath()` to resolve it back to an environment object. ## Fix Split the single `find()` into two passes — exact match first, then parent/grandparent fallback: ```typescript // After (fixed) const exact = this.collection.find((e) => normalizePath(e.environmentPath.fsPath) === normalized); if (exact) { return exact; } return this.collection.find((e) => { return ( normalizePath(path.dirname(e.environmentPath.fsPath)) === normalized || normalizePath(path.dirname(path.dirname(e.environmentPath.fsPath))) === normalized ); }); ``` This ensures base's prefix `/miniconda3` always matches itself exactly before any named env can match via grandparent. The parent/grandparent fallback is preserved for cases where a binary path (e.g. `.../bin/python`) needs resolving. ## Testing Added 17 unit tests covering: - Core bug scenario (base with lower version vs named envs with higher versions) - Exact match priority over parent/grandparent matches - Standard exact, parent, and grandparent matching - Empty collection and no-match cases - Windows backslash paths - Deeply nested paths that shouldn't match
1 parent 1ee225c commit ac36330

File tree

2 files changed

+271
-2
lines changed

2 files changed

+271
-2
lines changed

src/managers/conda/condaEnvManager.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,10 +486,17 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
486486

487487
private findEnvironmentByPath(fsPath: string): PythonEnvironment | undefined {
488488
const normalized = normalizePath(fsPath);
489+
490+
// Prefer exact match first to avoid ambiguous parent/grandparent collisions.
491+
// E.g. base env at /miniconda3 must not be confused with a named env at
492+
// /miniconda3/envs/<name> whose grandparent is also /miniconda3.
493+
const exact = this.collection.find((e) => normalizePath(e.environmentPath.fsPath) === normalized);
494+
if (exact) {
495+
return exact;
496+
}
497+
489498
return this.collection.find((e) => {
490-
const n = normalizePath(e.environmentPath.fsPath);
491499
return (
492-
n === normalized ||
493500
normalizePath(path.dirname(e.environmentPath.fsPath)) === normalized ||
494501
normalizePath(path.dirname(path.dirname(e.environmentPath.fsPath))) === normalized
495502
);
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import assert from 'assert';
3+
import * as sinon from 'sinon';
4+
import { Uri } from 'vscode';
5+
import { PythonEnvironment, PythonEnvironmentApi } from '../../../api';
6+
import { isWindows } from '../../../common/utils/platformUtils';
7+
import { PythonEnvironmentImpl } from '../../../internal.api';
8+
import { CondaEnvManager } from '../../../managers/conda/condaEnvManager';
9+
import { NativePythonFinder } from '../.././../managers/common/nativePythonFinder';
10+
11+
/**
12+
* Helper to create a minimal PythonEnvironment stub with required fields.
13+
* Only `name`, `environmentPath`, and `version` matter for findEnvironmentByPath.
14+
*/
15+
function makeEnv(name: string, envPath: string, version: string = '3.12.0'): PythonEnvironment {
16+
return new PythonEnvironmentImpl(
17+
{ id: `${name}-test`, managerId: 'ms-python.python:conda' },
18+
{
19+
name,
20+
displayName: `${name} (${version})`,
21+
displayPath: envPath,
22+
version,
23+
environmentPath: Uri.file(envPath),
24+
sysPrefix: envPath,
25+
execInfo: {
26+
run: { executable: 'python' },
27+
},
28+
},
29+
);
30+
}
31+
32+
/**
33+
* Creates a CondaEnvManager with a given collection, bypassing initialization.
34+
*/
35+
function createManagerWithCollection(collection: PythonEnvironment[]): CondaEnvManager {
36+
const manager = new CondaEnvManager(
37+
{} as NativePythonFinder,
38+
{} as PythonEnvironmentApi,
39+
{ info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any,
40+
);
41+
(manager as any).collection = collection;
42+
return manager;
43+
}
44+
45+
/**
46+
* Calls the private findEnvironmentByPath method on the manager.
47+
*/
48+
function findByPath(manager: CondaEnvManager, fsPath: string): PythonEnvironment | undefined {
49+
return (manager as any).findEnvironmentByPath(fsPath);
50+
}
51+
52+
suite('CondaEnvManager - findEnvironmentByPath', () => {
53+
teardown(() => {
54+
sinon.restore();
55+
});
56+
57+
// --- Core bug fix: base vs named env collision ---
58+
59+
test('Exact match on base prefix returns base, not a named env with higher version', () => {
60+
// This is the core bug scenario from issue #25814.
61+
// Named envs under /miniconda3/envs/<name> have grandparent /miniconda3,
62+
// same as base's own path. With version-sorted collection, a named env
63+
// with a higher Python version would appear first and incorrectly match.
64+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
65+
const namedHigher = makeEnv('torch', '/home/user/miniconda3/envs/torch', '3.13.0');
66+
67+
// Collection sorted by version descending (torch first, higher version)
68+
const manager = createManagerWithCollection([namedHigher, base]);
69+
70+
const result = findByPath(manager, '/home/user/miniconda3');
71+
assert.strictEqual(result, base, 'Should return base env via exact match, not torch via grandparent');
72+
});
73+
74+
test('Exact match on base prefix works with many named envs of varying versions', () => {
75+
const base = makeEnv('base', '/home/user/miniconda3', '3.11.0');
76+
const envA = makeEnv('alpha', '/home/user/miniconda3/envs/alpha', '3.13.0');
77+
const envB = makeEnv('beta', '/home/user/miniconda3/envs/beta', '3.12.0');
78+
const envC = makeEnv('gamma', '/home/user/miniconda3/envs/gamma', '3.10.0');
79+
80+
// Sorted by version descending: alpha(3.13), beta(3.12), base(3.11), gamma(3.10)
81+
const manager = createManagerWithCollection([envA, envB, base, envC]);
82+
83+
const result = findByPath(manager, '/home/user/miniconda3');
84+
assert.strictEqual(result, base, 'Should return base even when multiple named envs have higher versions');
85+
});
86+
87+
// --- Standard exact match cases ---
88+
89+
test('Exact match returns the correct named environment', () => {
90+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
91+
const myenv = makeEnv('myenv', '/home/user/miniconda3/envs/myenv', '3.11.0');
92+
93+
const manager = createManagerWithCollection([base, myenv]);
94+
95+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
96+
assert.strictEqual(result, myenv);
97+
});
98+
99+
test('Exact match returns correct env when path is a prefix env outside envs dir', () => {
100+
const prefixEnv = makeEnv('project', '/home/user/projects/myproject/.conda', '3.12.0');
101+
const manager = createManagerWithCollection([prefixEnv]);
102+
103+
const result = findByPath(manager, '/home/user/projects/myproject/.conda');
104+
assert.strictEqual(result, prefixEnv);
105+
});
106+
107+
// --- Parent directory match (one level up) ---
108+
109+
test('Parent dir match resolves executable path to env (bin/ inside env)', () => {
110+
// When given a path like /miniconda3/envs/myenv/bin, dirname of the env
111+
// is /miniconda3/envs/myenv and the path is /miniconda3/envs/myenv/bin,
112+
// so parent match: dirname(envPath) matches the lookup path won't work here.
113+
// Actually parent match means: dirname(environmentPath) === lookupPath.
114+
// For bin match, we'd pass /miniconda3/envs/myenv/bin and
115+
// dirname(/miniconda3/envs/myenv) = /miniconda3/envs ≠ /miniconda3/envs/myenv/bin
116+
// So this case uses grandparent. Let me test a real parent scenario:
117+
// If we have env at /miniconda3/envs/myenv/python (subdir) and look up /miniconda3/envs/myenv
118+
const env = makeEnv('myenv', '/home/user/miniconda3/envs/myenv/python', '3.12.0');
119+
const manager = createManagerWithCollection([env]);
120+
121+
// dirname(/miniconda3/envs/myenv/python) = /miniconda3/envs/myenv
122+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
123+
assert.strictEqual(result, env, 'Should match via parent directory');
124+
});
125+
126+
// --- Grandparent directory match (two levels up) ---
127+
128+
test('Grandparent dir match resolves executable path to env (bin/python inside env)', () => {
129+
// environmentPath = /miniconda3/envs/myenv/bin/python
130+
// dirname(dirname(path)) = /miniconda3/envs/myenv
131+
//
132+
// This is the typical case where environmentPath points to the Python binary
133+
// and we look up the environment prefix.
134+
const env = makeEnv('myenv', '/home/user/miniconda3/envs/myenv/bin/python', '3.12.0');
135+
const manager = createManagerWithCollection([env]);
136+
137+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
138+
assert.strictEqual(result, env, 'Should match via grandparent directory');
139+
});
140+
141+
// --- No match ---
142+
143+
test('Returns undefined when no environment matches', () => {
144+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
145+
const manager = createManagerWithCollection([base]);
146+
147+
const result = findByPath(manager, '/opt/other/path');
148+
assert.strictEqual(result, undefined);
149+
});
150+
151+
test('Returns undefined for empty collection', () => {
152+
const manager = createManagerWithCollection([]);
153+
const result = findByPath(manager, '/home/user/miniconda3');
154+
assert.strictEqual(result, undefined);
155+
});
156+
157+
// --- Priority: exact over parent, parent over grandparent ---
158+
159+
test('Exact match takes priority over parent match of a different env', () => {
160+
// envA is at /a/b/c and envB is at /a/b/c/sub
161+
// Looking up /a/b/c should return envA (exact), not envB (parent)
162+
const envA = makeEnv('envA', '/a/b/c', '3.12.0');
163+
const envB = makeEnv('envB', '/a/b/c/sub', '3.12.0');
164+
const manager = createManagerWithCollection([envB, envA]); // envB first in iteration
165+
166+
const result = findByPath(manager, '/a/b/c');
167+
assert.strictEqual(result, envA, 'Exact match should win over parent match');
168+
});
169+
170+
test('Exact match takes priority over grandparent match of a different env', () => {
171+
const envA = makeEnv('envA', '/a/b', '3.12.0');
172+
const envB = makeEnv('envB', '/a/b/c/d', '3.13.0');
173+
const manager = createManagerWithCollection([envB, envA]); // envB first (higher version)
174+
175+
// dirname(dirname(/a/b/c/d)) = /a/b which also matches envA exactly
176+
const result = findByPath(manager, '/a/b');
177+
assert.strictEqual(result, envA, 'Exact match should win over grandparent match');
178+
});
179+
180+
// --- Windows-style paths ---
181+
// Uri.file() lowercases drive letters on non-Windows, causing path mismatches
182+
// with normalizePath which only lowercases on Windows. Skip on Linux/macOS.
183+
184+
(isWindows() ? test : test.skip)('Works with Windows-style backslash paths', () => {
185+
const base = makeEnv('base', 'C:\\Users\\user\\miniconda3', '3.12.0');
186+
const named = makeEnv('torch', 'C:\\Users\\user\\miniconda3\\envs\\torch', '3.13.0');
187+
188+
const manager = createManagerWithCollection([named, base]);
189+
190+
const result = findByPath(manager, 'C:\\Users\\user\\miniconda3');
191+
assert.strictEqual(result, base, 'Should return base on Windows paths');
192+
});
193+
194+
(isWindows() ? test : test.skip)('Windows: exact match on named env path', () => {
195+
const base = makeEnv('base', 'C:\\Users\\user\\miniconda3', '3.12.0');
196+
const named = makeEnv('myenv', 'C:\\Users\\user\\miniconda3\\envs\\myenv', '3.11.0');
197+
198+
const manager = createManagerWithCollection([base, named]);
199+
200+
const result = findByPath(manager, 'C:\\Users\\user\\miniconda3\\envs\\myenv');
201+
assert.strictEqual(result, named);
202+
});
203+
204+
// --- Edge: base is the only env ---
205+
206+
test('Base as only env is found via exact match', () => {
207+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
208+
const manager = createManagerWithCollection([base]);
209+
210+
const result = findByPath(manager, '/home/user/miniconda3');
211+
assert.strictEqual(result, base);
212+
});
213+
214+
// --- Edge: multiple envs with same version (alphabetical sort) ---
215+
216+
test('Works when base and named env have the same Python version', () => {
217+
const base = makeEnv('base', '/home/user/miniconda3', '3.12.0');
218+
const named = makeEnv('aaa', '/home/user/miniconda3/envs/aaa', '3.12.0');
219+
220+
// Same version, 'aaa' sorts before 'base' alphabetically
221+
const manager = createManagerWithCollection([named, base]);
222+
223+
const result = findByPath(manager, '/home/user/miniconda3');
224+
assert.strictEqual(result, base, 'Should return base even when named env sorts first alphabetically');
225+
});
226+
227+
// --- Edge: prefix env inside workspace (not under envs/) ---
228+
229+
test('Prefix env inside workspace does not collide with base', () => {
230+
const base = makeEnv('base', '/home/user/miniconda3', '3.13.0');
231+
const prefixEnv = makeEnv('.conda', '/home/user/project/.conda', '3.12.0');
232+
233+
const manager = createManagerWithCollection([base, prefixEnv]);
234+
235+
const result = findByPath(manager, '/home/user/project/.conda');
236+
assert.strictEqual(result, prefixEnv);
237+
});
238+
239+
// --- Edge: deeply nested path that doesn't match anything ---
240+
241+
test('Path that only matches at 3+ levels up does not match', () => {
242+
// environmentPath = /a/b/c/d/e, looking up /a/b
243+
// dirname = /a/b/c/d, grandparent = /a/b/c — neither matches /a/b
244+
const env = makeEnv('deep', '/a/b/c/d/e', '3.12.0');
245+
const manager = createManagerWithCollection([env]);
246+
247+
const result = findByPath(manager, '/a/b');
248+
assert.strictEqual(result, undefined, 'Should not match beyond grandparent');
249+
});
250+
251+
// --- Edge: trailing separator normalization ---
252+
253+
test('Fallback still works when no exact match exists', () => {
254+
// An env whose environmentPath is a binary path, not a prefix
255+
const env = makeEnv('myenv', '/home/user/miniconda3/envs/myenv/bin/python3', '3.12.0');
256+
const manager = createManagerWithCollection([env]);
257+
258+
// Looking up the prefix — should find it via grandparent
259+
const result = findByPath(manager, '/home/user/miniconda3/envs/myenv');
260+
assert.strictEqual(result, env);
261+
});
262+
});

0 commit comments

Comments
 (0)