diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 39495a416..1031fb44e 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -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) { + await this.loadEnvMapPreservingCollection(); + return; + } + this.collection = refreshed; await this.loadEnvMap(); this._onDidChangeEnvironments.fire( @@ -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(); @@ -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) => ({ @@ -486,6 +499,17 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { await clearCondaCache(); } + private async loadEnvMapPreservingCollection(): Promise { + 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( + added.map((environment) => ({ kind: EnvironmentChangeKind.add, environment })), + ); + } + } + private async loadEnvMap() { this.globalEnv = undefined; this.fsPathToEnv.clear(); diff --git a/src/managers/conda/condaUtils.ts b/src/managers/conda/condaUtils.ts index 06d3ec54f..e32cbff00 100644 --- a/src/managers/conda/condaUtils.ts +++ b/src/managers/conda/condaUtils.ts @@ -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 { +): Promise { log.info(`Refreshing conda environments (hardRefresh=${hardRefresh})`); let data: (NativeEnvInfo | NativeEnvManagerInfo)[]; @@ -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 diff --git a/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts b/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts new file mode 100644 index 000000000..2ad1e387a --- /dev/null +++ b/src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts @@ -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'); + }); +}); diff --git a/src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts b/src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts new file mode 100644 index 000000000..ab919c0c4 --- /dev/null +++ b/src/test/managers/conda/condaUtils.refreshCondaEnvs.unit.test.ts @@ -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'); + }); +});