Skip to content
Closed
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
34 changes: 29 additions & 5 deletions src/managers/conda/condaEnvManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,12 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
title: CondaStrings.condaDiscovering,
},
async () => {
this.collection =
(await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? [];
const refreshed = await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this);
if (refreshed === undefined) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

📍 src/managers/conda/condaEnvManager.ts:131
The prior maintainability concern is only partially addressed: recovery is shared, but three callers still independently interpret undefined. Centralize discovery-result interpretation so future call sites cannot accidentally collapse failure into an authoritative empty result while retaining their distinct success-event policies.

[verified]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

📍 src/managers/conda/condaEnvManager.ts:127
The two prior centralization warnings are only partially addressed: initialization, refresh, and background initialization still interpret undefined independently. Centralize discovery-result interpretation while parameterizing each caller's distinct successful-result event policy.

[verified]

await this.loadEnvMapPreservingCollection();
return;
}
this.collection = refreshed;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

The initialization, refresh, and background initialization paths still independently classify undefined. Centralize discovery-result classification while preserving each caller's distinct success-event policy. [verified]

await this.loadEnvMap();

this._onDidChangeEnvironments.fire(
Expand Down Expand Up @@ -314,8 +318,13 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
},
async () => {
this.log.info('Refreshing Conda Environments');
const refreshed = await refreshCondaEnvs(true, this.nativeFinder, this.api, this.log, this);
if (refreshed === undefined) {
await this.loadEnvMapPreservingCollection();
return;
}
const discard = this.collection.map((c) => c);
this.collection = (await refreshCondaEnvs(true, this.nativeFinder, this.api, this.log, this)) ?? [];
this.collection = refreshed;

await this.loadEnvMap();

Expand All @@ -342,8 +351,12 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
resolve: (p) => resolveCondaPath(p, this.nativeFinder, this.api, this.log, this),
startBackgroundInit: () =>
withProgress({ location: ProgressLocation.Window, title: CondaStrings.condaDiscovering }, async () => {
this.collection =
(await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this)) ?? [];
const refreshed = await refreshCondaEnvs(false, this.nativeFinder, this.api, this.log, this);
if (refreshed === undefined) {
await this.loadEnvMapPreservingCollection();
return;
}
this.collection = refreshed;
await this.loadEnvMap();
this._onDidChangeEnvironments.fire(
this.collection.map((e) => ({

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue · Please address or respond

📍 src/managers/conda/condaEnvManager.ts:362
[verified] The prior successful-background/late-fast-path race remains: this assignment can publish the authoritative background collection before a later fast-path result is reconciled, leaving that result without an add event. Route the late result through a common reconciliation path and add a gated successful-background regression test.

[verified]

Expand Down Expand Up @@ -486,6 +499,17 @@ export class CondaEnvManager implements EnvironmentManager, Disposable {
await clearCondaCache();
}

private async loadEnvMapPreservingCollection(): Promise<void> {
const known = new Set(this.collection);
await this.loadEnvMap();
const added = this.collection.filter((env) => !known.has(env));
if (added.length > 0) {
this._onDidChangeEnvironments.fire(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning · Non-blocking recommendation

The pre-await known snapshot can become stale if another resolution appends and announces an environment while loadEnvMap() is awaiting, causing this helper to emit a duplicate add. Have loadEnvMap() report appended environments, or recheck announcement state before firing. [verified]

added.map((environment) => ({ kind: EnvironmentChangeKind.add, environment })),
);
}
}

private async loadEnvMap() {
this.globalEnv = undefined;
this.fsPathToEnv.clear();
Expand Down
9 changes: 7 additions & 2 deletions src/managers/conda/condaUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,13 +835,18 @@ export async function resolveCondaPath(
}
}

/**
* Discovers conda environments via the native finder. Returns `undefined` when discovery fails
* (the native finder threw/rejected) so callers can keep a known-good collection, or an array
* (including `[]`) on success — where `[]` authoritatively means "no conda environments".
*/
export async function refreshCondaEnvs(
hardRefresh: boolean,
nativeFinder: NativePythonFinder,
api: PythonEnvironmentApi,
log: LogOutputChannel,
manager: EnvironmentManager,
): Promise<PythonEnvironment[]> {
): Promise<PythonEnvironment[] | undefined> {
log.info(`Refreshing conda environments (hardRefresh=${hardRefresh})`);

let data: (NativeEnvInfo | NativeEnvManagerInfo)[];
Expand All @@ -850,7 +855,7 @@ export async function refreshCondaEnvs(
} catch (error) {
traceError('Failed to refresh native finder for conda environments', error);
log.error(`Failed to refresh native finder: ${error instanceof Error ? error.message : String(error)}`);
return [];
return undefined;
}

// Ensure data is a valid array before proceeding
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import assert from 'assert';
import * as fs from 'fs';
import * as path from 'path';
import * as sinon from 'sinon';
import { Uri } from 'vscode';
import { EnvironmentChangeKind, PythonEnvironmentApi, PythonProject } from '../../../api';
import * as logging from '../../../common/logging';
import * as telemetrySender from '../../../common/telemetry/sender';
import * as windowApis from '../../../common/window.apis';
import * as commonUtils from '../../../managers/common/utils';
import { NativePythonFinder } from '../../../managers/common/nativePythonFinder';
import { CondaEnvManager } from '../../../managers/conda/condaEnvManager';
import * as condaSourcingUtils from '../../../managers/conda/condaSourcingUtils';
import * as condaUtils from '../../../managers/conda/condaUtils';
import { makeMockCondaEnvironment as makeEnv } from '../../mocks/pythonEnvironment';

suite('CondaEnvManager - result preservation on discovery failure', () => {
let getCondaStub: sinon.SinonStub;
let refreshCondaEnvsStub: sinon.SinonStub;
let getCondaForGlobalStub: sinon.SinonStub;
let getCondaForWorkspaceStub: sinon.SinonStub;
let resolveCondaPathStub: sinon.SinonStub;

setup(() => {
getCondaStub = sinon.stub(condaUtils, 'getConda').resolves('/usr/bin/conda');
sinon.stub(condaUtils, 'getCondaPathSetting').returns(undefined);
refreshCondaEnvsStub = sinon.stub(condaUtils, 'refreshCondaEnvs').resolves([]);
getCondaForGlobalStub = sinon.stub(condaUtils, 'getCondaForGlobal').resolves(undefined);
getCondaForWorkspaceStub = sinon.stub(condaUtils, 'getCondaForWorkspace').resolves(undefined);
resolveCondaPathStub = sinon.stub(condaUtils, 'resolveCondaPath').resolves(undefined);
sinon.stub(condaSourcingUtils, 'constructCondaSourcingStatus').resolves({ toString: () => '' } as any);
sinon.stub(commonUtils, 'notifyMissingManagerIfDefault').resolves();
sinon.stub(telemetrySender, 'sendTelemetryEvent');
sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => {
return await (task as any)({ report: sinon.stub() }, { isCancellationRequested: false } as any);
});
sinon.stub(logging, 'traceInfo');
sinon.stub(logging, 'traceError');
sinon.stub(logging, 'traceVerbose');
});

teardown(() => {
sinon.restore();
});

function createManager(): CondaEnvManager {
const api = {
getPythonProjects: sinon.stub().returns([]),
getPythonProject: sinon.stub().returns(undefined),
} as any as PythonEnvironmentApi;
return new CondaEnvManager(
{} as NativePythonFinder,
api,
{ info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any,
);
}

function collectEvents(mgr: CondaEnvManager): any[] {
const events: any[] = [];
mgr.onDidChangeEnvironments((e) => events.push(...e));
return events;
}

const base = () => makeEnv('base', Uri.file('/opt/miniconda3').fsPath, '3.12.0');
const envB = () => makeEnv('envB', Uri.file('/opt/miniconda3/envs/envB').fsPath, '3.11.0');

test('refresh preserves prior environments and emits no changes when discovery fails and nothing persisted resolves', async () => {
const known = [base(), envB()];
refreshCondaEnvsStub.resolves(known);

const mgr = createManager();
await mgr.initialize();
assert.strictEqual((await mgr.getEnvironments('all')).length, 2, 'precondition: two envs discovered');

const events = collectEvents(mgr);
refreshCondaEnvsStub.resolves(undefined);
await mgr.refresh(undefined);

assert.strictEqual(events.length, 0, 'a failed refresh must not emit any environment changes');
const after = await mgr.getEnvironments('all');
assert.deepStrictEqual(
after.map((e) => e.name).sort(),
['base', 'envB'],
'the known-good collection must survive a failed refresh',
);
});

test('refresh empties the collection and emits removals on a successful empty result', async () => {
const known = [base(), envB()];
refreshCondaEnvsStub.resolves(known);

const mgr = createManager();
await mgr.initialize();

const events = collectEvents(mgr);
refreshCondaEnvsStub.resolves([]);
await mgr.refresh(undefined);

const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove).map((e) => e.environment.name);
const added = events.filter((e) => e.kind === EnvironmentChangeKind.add);
assert.deepStrictEqual(removed.sort(), ['base', 'envB'], 'stale environments must be removed on empty success');
assert.strictEqual(added.length, 0, 'no environments should be added for an empty result');
assert.strictEqual((await mgr.getEnvironments('all')).length, 0, 'collection must be emptied');
});

test('refresh replaces the collection and emits removals + adds on a successful non-empty result', async () => {
refreshCondaEnvsStub.resolves([base()]);

const mgr = createManager();
await mgr.initialize();

const events = collectEvents(mgr);
const envC = makeEnv('envC', Uri.file('/opt/miniconda3/envs/envC').fsPath, '3.10.0');
refreshCondaEnvsStub.resolves([envC]);
await mgr.refresh(undefined);

const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove).map((e) => e.environment.name);
const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name);
assert.deepStrictEqual(removed, ['base'], 'old environment removed');
assert.deepStrictEqual(added, ['envC'], 'new environment added');
assert.deepStrictEqual((await mgr.getEnvironments('all')).map((e) => e.name), ['envC']);
});

test('initialize leaves the collection empty and emits no changes when discovery fails and nothing persisted resolves', async () => {
refreshCondaEnvsStub.resolves(undefined);

const mgr = createManager();
const events = collectEvents(mgr);
await mgr.initialize();

assert.strictEqual(getCondaStub.called, true, 'initialize still attempts discovery');
assert.strictEqual(events.length, 0, 'a failed initial discovery must not emit environment changes');
assert.strictEqual((await mgr.getEnvironments('all')).length, 0, 'no environments should be registered');
});

test('refresh failure preserves the collection and restores a persisted global selection, emitting only its addition', async () => {
const known = [base()];
refreshCondaEnvsStub.resolves(known);

const mgr = createManager();
await mgr.initialize();
assert.strictEqual((await mgr.getEnvironments('all')).length, 1, 'precondition: one env discovered');

const events = collectEvents(mgr);

const persistedGlobalPath = Uri.file('/opt/miniconda3/envs/persisted').fsPath;
const persistedEnv = makeEnv('persisted', persistedGlobalPath, '3.9.0');
getCondaForGlobalStub.resolves(persistedGlobalPath);
resolveCondaPathStub.resolves(persistedEnv);
refreshCondaEnvsStub.resolves(undefined);

await mgr.refresh(undefined);

const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove);
const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name);
assert.strictEqual(removed.length, 0, 'no removals on failed discovery');
assert.deepStrictEqual(added, ['persisted'], 'only the restored persisted env is emitted as an addition');

const all = (await mgr.getEnvironments('all')).map((e) => e.name).sort();
assert.deepStrictEqual(all, ['base', 'persisted'], 'old collection preserved and persisted env appended');
assert.strictEqual(await mgr.get(undefined), persistedEnv, 'persisted global selection is retained');
});

test('initialize failure restores a persisted global selection and retains it across get calls, emitting only its addition', async () => {
refreshCondaEnvsStub.resolves(undefined);

const persistedGlobalPath = Uri.file('/opt/miniconda3').fsPath;
const persistedEnv = makeEnv('base', persistedGlobalPath, '3.12.0');
getCondaForGlobalStub.resolves(persistedGlobalPath);
resolveCondaPathStub.resolves(persistedEnv);

const mgr = createManager();
const events = collectEvents(mgr);
await mgr.initialize();

assert.strictEqual((mgr as any)._initialized?.completed, true, 'initialization settles');

const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove);
const added = events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name);
assert.strictEqual(removed.length, 0, 'no removals on failed initial discovery');
assert.deepStrictEqual(added, ['base'], 'only the restored persisted env is emitted');

assert.strictEqual(await mgr.get(undefined), persistedEnv, 'first get returns the persisted selection');
assert.strictEqual(await mgr.get(undefined), persistedEnv, 'subsequent get returns the persisted selection');
assert.deepStrictEqual((await mgr.getEnvironments('all')).map((e) => e.name), ['base']);
});

test('fast/background get: refresh failure restores a persisted workspace selection and retains it across calls', async () => {
const workspaceUri = Uri.file(path.resolve('ws-conda'));
const project = { uri: workspaceUri } as PythonProject;
const api = {
getPythonProjects: sinon.stub().returns([project]),
getPythonProject: sinon.stub().returns(project),
} as any as PythonEnvironmentApi;
const mgr = new CondaEnvManager(
{} as NativePythonFinder,
api,
{ info: sinon.stub(), error: sinon.stub(), warn: sinon.stub() } as any,
);

const persistedPath = Uri.file(path.resolve('ws-conda', '.conda')).fsPath;
const persistedEnv = makeEnv('wsenv', persistedPath, '3.10.0');
getCondaForWorkspaceStub.resolves(persistedPath);
resolveCondaPathStub.resolves(persistedEnv);
refreshCondaEnvsStub.resolves(undefined);
sinon.stub(fs.promises, 'access').resolves();

const events = collectEvents(mgr);

const first = await mgr.get(workspaceUri);
assert.strictEqual(first, persistedEnv, 'fast path returns the persisted env on first get');

await (mgr as any)._initialized?.promise;
await new Promise((resolve) => setImmediate(resolve));

const second = await mgr.get(workspaceUri);
assert.strictEqual(second, persistedEnv, 'persisted selection retained after settled init');

const removed = events.filter((e) => e.kind === EnvironmentChangeKind.remove);
assert.strictEqual(removed.length, 0, 'no removals on failed background discovery');
const wsAdds = events.filter((e) => e.kind === EnvironmentChangeKind.add && e.environment.name === 'wsenv');
assert.strictEqual(wsAdds.length, 1, `expected exactly one add for the persisted env, got ${wsAdds.length}`);

const wsInCollection = (await mgr.getEnvironments('all')).filter((e) => e.name === 'wsenv');
assert.strictEqual(wsInCollection.length, 1, 'exactly one collection entry for the persisted env');
});
});
67 changes: 67 additions & 0 deletions src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import assert from 'assert';
import * as sinon from 'sinon';
import { LogOutputChannel } from 'vscode';
import { EnvironmentManager, PythonEnvironmentApi } from '../../../api';
import * as logging from '../../../common/logging';
import * as persistentState from '../../../common/persistentState';
import { NativePythonFinder } from '../../../managers/common/nativePythonFinder';
import { refreshCondaEnvs } from '../../../managers/conda/condaUtils';

suite('condaUtils.refreshCondaEnvs - failure vs. successful contract', () => {
let nativeFinder: { refresh: sinon.SinonStub };
let api: PythonEnvironmentApi;
let log: LogOutputChannel;
let manager: EnvironmentManager;

setup(() => {
nativeFinder = { refresh: sinon.stub() };
api = {} as PythonEnvironmentApi;
log = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub() } as unknown as LogOutputChannel;
manager = {} as EnvironmentManager;

sinon.stub(logging, 'traceError');
sinon.stub(logging, 'traceWarn');
sinon.stub(logging, 'traceInfo');
sinon.stub(logging, 'traceVerbose');

sinon.stub(persistentState, 'getWorkspacePersistentState').resolves({
get: sinon.stub().resolves(undefined),
set: sinon.stub().resolves(),
clear: sinon.stub().resolves(),
} as any);
});

teardown(() => {
sinon.restore();
});

test('returns undefined when the native finder rejects (discovery failure)', async () => {
nativeFinder.refresh.rejects(new Error('native finder boom'));

const result = await refreshCondaEnvs(
true,
nativeFinder as unknown as NativePythonFinder,
api,
log,
manager,
);

assert.strictEqual(result, undefined, 'a rejected refresh must be reported as failure (undefined)');
});

test('returns an empty array (not undefined) on a successful discovery with no conda envs', async () => {
nativeFinder.refresh.resolves([]);

const result = await refreshCondaEnvs(
false,
nativeFinder as unknown as NativePythonFinder,
api,
log,
manager,
);

assert.ok(Array.isArray(result), 'a successful empty discovery must return an array, not undefined');
assert.strictEqual(result!.length, 0, 'a successful empty discovery must return an empty array');
});
});
Loading