From fe868b553c50bd34d234df3cce50193b4d8cc264 Mon Sep 17 00:00:00 2001 From: Stella Huang Date: Sat, 22 Aug 2026 19:09:03 -0700 Subject: [PATCH] fix: retry failed manager initialization All six built-in manager initialize() methods memoized initialization in a _initialized deferred and resolved it in finally even on failure, leaving the guard set. The top guard then returned that settled promise forever, so a single transient discovery failure permanently poisoned discovery for the rest of the session with no way to retry. Capture the deferred locally, clear the guard on a thrown exception only if this run still owns it so a later call retries, and always settle the captured deferred in finally so concurrent waiters unblock. Each manager's throw-vs-swallow behavior is preserved, and a non-throwing tool_not_found stays a completed init. Affected: venv, system, conda, pipenv, poetry, pyenv. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/managers/builtin/sysPythonManager.ts | 10 +- src/managers/builtin/venvManager.ts | 10 +- src/managers/conda/condaEnvManager.ts | 8 +- src/managers/pipenv/pipenvManager.ts | 8 +- src/managers/poetry/poetryManager.ts | 8 +- src/managers/pyenv/pyenvManager.ts | 8 +- .../sysPythonManager.initialize.unit.test.ts | 73 +++++++++++ .../venvManager.initialize.unit.test.ts | 88 ++++++++++++++ .../condaEnvManager.initialize.unit.test.ts | 57 +++++++++ .../pipenvManager.initialize.unit.test.ts | 113 ++++++++++++++++++ .../poetryManager.initialize.unit.test.ts | 90 ++++++++++++++ .../pyenvManager.initialize.unit.test.ts | 83 +++++++++++++ 12 files changed, 544 insertions(+), 12 deletions(-) create mode 100644 src/test/managers/builtin/sysPythonManager.initialize.unit.test.ts create mode 100644 src/test/managers/builtin/venvManager.initialize.unit.test.ts create mode 100644 src/test/managers/pipenv/pipenvManager.initialize.unit.test.ts create mode 100644 src/test/managers/poetry/poetryManager.initialize.unit.test.ts create mode 100644 src/test/managers/pyenv/pyenvManager.initialize.unit.test.ts diff --git a/src/managers/builtin/sysPythonManager.ts b/src/managers/builtin/sysPythonManager.ts index 2b031ba3b..b2075d1e0 100644 --- a/src/managers/builtin/sysPythonManager.ts +++ b/src/managers/builtin/sysPythonManager.ts @@ -71,7 +71,8 @@ export class SysPythonManager implements EnvironmentManager { return this._initialized.promise; } - this._initialized = createDeferred(); + const initialized = createDeferred(); + this._initialized = initialized; try { await this.internalRefresh(false, SysManagerStrings.sysManagerDiscovering); @@ -96,8 +97,13 @@ export class SysPythonManager implements EnvironmentManager { } } } + } catch (ex) { + if (this._initialized === initialized) { + this._initialized = undefined; + } + throw ex; } finally { - this._initialized.resolve(); + initialized.resolve(); } } diff --git a/src/managers/builtin/venvManager.ts b/src/managers/builtin/venvManager.ts index 6dcda4df8..9f49d33e9 100644 --- a/src/managers/builtin/venvManager.ts +++ b/src/managers/builtin/venvManager.ts @@ -90,12 +90,18 @@ export class VenvManager implements EnvironmentManager { return this._initialized.promise; } - this._initialized = createDeferred(); + const initialized = createDeferred(); + this._initialized = initialized; try { await this.internalRefresh(undefined, false, VenvManagerStrings.venvInitialize); + } catch (ex) { + if (this._initialized === initialized) { + this._initialized = undefined; + } + throw ex; } finally { - this._initialized.resolve(); + initialized.resolve(); } } diff --git a/src/managers/conda/condaEnvManager.ts b/src/managers/conda/condaEnvManager.ts index 39495a416..10fa129e6 100644 --- a/src/managers/conda/condaEnvManager.ts +++ b/src/managers/conda/condaEnvManager.ts @@ -95,7 +95,8 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { return this._initialized.promise; } - this._initialized = createDeferred(); + const initialized = createDeferred(); + this._initialized = initialized; const stopWatch = new StopWatch(); let result: 'success' | 'tool_not_found' | 'error' = 'success'; let envCount = 0; @@ -165,6 +166,9 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { result = 'error'; errorType = classifyError(ex); traceError('Conda lazy initialization failed', ex); + if (this._initialized === initialized) { + this._initialized = undefined; + } } finally { sendTelemetryEvent(EventNames.MANAGER_LAZY_INIT, stopWatch.elapsedTime, { managerName: 'conda', @@ -173,7 +177,7 @@ export class CondaEnvManager implements EnvironmentManager, Disposable { toolSource, errorType, }); - this._initialized.resolve(); + initialized.resolve(); } } diff --git a/src/managers/pipenv/pipenvManager.ts b/src/managers/pipenv/pipenvManager.ts index 3a8a23528..010ad3520 100644 --- a/src/managers/pipenv/pipenvManager.ts +++ b/src/managers/pipenv/pipenvManager.ts @@ -81,7 +81,8 @@ export class PipenvManager implements EnvironmentManager, Disposable { if (this._initialized) { return this._initialized.promise; } - this._initialized = createDeferred(); + const initialized = createDeferred(); + this._initialized = initialized; const stopWatch = new StopWatch(); let result: 'success' | 'tool_not_found' | 'error' = 'success'; let envCount = 0; @@ -129,6 +130,9 @@ export class PipenvManager implements EnvironmentManager, Disposable { result = 'error'; errorType = classifyError(ex); traceError('Pipenv lazy initialization failed', ex); + if (this._initialized === initialized) { + this._initialized = undefined; + } } finally { sendTelemetryEvent(EventNames.MANAGER_LAZY_INIT, stopWatch.elapsedTime, { managerName: 'pipenv', @@ -137,7 +141,7 @@ export class PipenvManager implements EnvironmentManager, Disposable { toolSource, errorType, }); - this._initialized.resolve(); + initialized.resolve(); } } diff --git a/src/managers/poetry/poetryManager.ts b/src/managers/poetry/poetryManager.ts index 31a35e103..cd3883532 100644 --- a/src/managers/poetry/poetryManager.ts +++ b/src/managers/poetry/poetryManager.ts @@ -79,7 +79,8 @@ export class PoetryManager implements EnvironmentManager, Disposable { if (this._initialized) { return this._initialized.promise; } - this._initialized = createDeferred(); + const initialized = createDeferred(); + this._initialized = initialized; const stopWatch = new StopWatch(); let result: 'success' | 'tool_not_found' | 'error' = 'success'; let envCount = 0; @@ -127,6 +128,9 @@ export class PoetryManager implements EnvironmentManager, Disposable { result = 'error'; errorType = classifyError(ex); traceError('Poetry lazy initialization failed', ex); + if (this._initialized === initialized) { + this._initialized = undefined; + } } finally { sendTelemetryEvent(EventNames.MANAGER_LAZY_INIT, stopWatch.elapsedTime, { managerName: 'poetry', @@ -135,7 +139,7 @@ export class PoetryManager implements EnvironmentManager, Disposable { toolSource, errorType, }); - this._initialized.resolve(); + initialized.resolve(); } } diff --git a/src/managers/pyenv/pyenvManager.ts b/src/managers/pyenv/pyenvManager.ts index c5dc9828a..6aea6aab9 100644 --- a/src/managers/pyenv/pyenvManager.ts +++ b/src/managers/pyenv/pyenvManager.ts @@ -81,7 +81,8 @@ export class PyEnvManager implements EnvironmentManager, Disposable { if (this._initialized) { return this._initialized.promise; } - this._initialized = createDeferred(); + const initialized = createDeferred(); + this._initialized = initialized; const stopWatch = new StopWatch(); let result: 'success' | 'tool_not_found' | 'error' = 'success'; let envCount = 0; @@ -128,6 +129,9 @@ export class PyEnvManager implements EnvironmentManager, Disposable { result = 'error'; errorType = classifyError(ex); traceError('Pyenv lazy initialization failed', ex); + if (this._initialized === initialized) { + this._initialized = undefined; + } } finally { sendTelemetryEvent(EventNames.MANAGER_LAZY_INIT, stopWatch.elapsedTime, { managerName: 'pyenv', @@ -136,7 +140,7 @@ export class PyEnvManager implements EnvironmentManager, Disposable { toolSource, errorType, }); - this._initialized.resolve(); + initialized.resolve(); } } diff --git a/src/test/managers/builtin/sysPythonManager.initialize.unit.test.ts b/src/test/managers/builtin/sysPythonManager.initialize.unit.test.ts new file mode 100644 index 000000000..9ed23d299 --- /dev/null +++ b/src/test/managers/builtin/sysPythonManager.initialize.unit.test.ts @@ -0,0 +1,73 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { anything, reset, when } from 'ts-mockito'; +import { PythonEnvironmentApi } from '../../../api'; +import * as logging from '../../../common/logging'; +import * as cache from '../../../managers/builtin/cache'; +import { SysPythonManager } from '../../../managers/builtin/sysPythonManager'; +import * as utils from '../../../managers/builtin/utils'; +import * as uvInstaller from '../../../managers/builtin/uvPythonInstaller'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { mockedVSCodeNamespaces } from '../../unittests'; + +suite('SysPythonManager.initialize - retry after failure (throw style)', () => { + let refreshPythonsStub: sinon.SinonStub; + + setup(() => { + when(mockedVSCodeNamespaces.window!.withProgress(anything(), anything())).thenCall( + (_options: any, task: any) => task({ report: sinon.stub() }, { isCancellationRequested: false }), + ); + refreshPythonsStub = sinon.stub(utils, 'refreshPythons'); + sinon.stub(uvInstaller, 'promptInstallPythonViaUv').resolves(undefined); + sinon.stub(cache, 'getSystemEnvForGlobal').resolves(undefined); + sinon.stub(logging, 'traceError'); + sinon.stub(logging, 'traceWarn'); + }); + + teardown(() => { + sinon.restore(); + reset(mockedVSCodeNamespaces.window!); + }); + + function createManager(): SysPythonManager { + const api = { + getPythonProjects: sinon.stub().returns([]), + getPythonProject: sinon.stub().returns(undefined), + } as any as PythonEnvironmentApi; + return new SysPythonManager({} as NativePythonFinder, api, { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + } as any); + } + + test('rethrows on failure but clears state so a later call retries', async () => { + refreshPythonsStub.onFirstCall().rejects(new Error('discovery boom')); + refreshPythonsStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + await assert.rejects(mgr.initialize(), /discovery boom/); + assert.strictEqual(refreshPythonsStub.callCount, 1); + + await assert.doesNotReject(mgr.initialize()); + assert.strictEqual(refreshPythonsStub.callCount, 2, 'a later call must retry after a failure'); + + await mgr.initialize(); + assert.strictEqual(refreshPythonsStub.callCount, 2, 'no re-discovery after a successful init'); + }); + + test('settles concurrent waiters during a failing run (leader rejects, waiter resolves)', async () => { + refreshPythonsStub.rejects(new Error('discovery boom')); + + const mgr = createManager(); + + const leader = mgr.initialize(); + const waiter = mgr.initialize(); + + await assert.rejects(leader, /discovery boom/); + await assert.doesNotReject(waiter); + assert.strictEqual(refreshPythonsStub.callCount, 1, 'concurrent callers share one discovery run'); + }); +}); diff --git a/src/test/managers/builtin/venvManager.initialize.unit.test.ts b/src/test/managers/builtin/venvManager.initialize.unit.test.ts new file mode 100644 index 000000000..5e02a9bca --- /dev/null +++ b/src/test/managers/builtin/venvManager.initialize.unit.test.ts @@ -0,0 +1,88 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { EnvironmentManager, PythonEnvironmentApi } from '../../../api'; +import * as logging from '../../../common/logging'; +import * as windowApis from '../../../common/window.apis'; +import { VenvManager } from '../../../managers/builtin/venvManager'; +import * as venvUtils from '../../../managers/builtin/venvUtils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; + +suite('VenvManager.initialize - retry after failure (throw style)', () => { + let findVirtualEnvironmentsStub: sinon.SinonStub; + + setup(() => { + findVirtualEnvironmentsStub = sinon.stub(venvUtils, 'findVirtualEnvironments'); + sinon.stub(venvUtils, 'getVenvForGlobal').resolves(undefined); + sinon.stub(windowApis, 'withProgress').callsFake(async (_options, task) => { + return await (task as any)({ report: sinon.stub() }, { isCancellationRequested: false } as any); + }); + sinon.stub(logging, 'traceError'); + sinon.stub(logging, 'traceWarn'); + }); + + teardown(() => { + sinon.restore(); + }); + + function createManager(): VenvManager { + const api = { + getEnvironments: sinon.stub().resolves([]), + getPythonProject: sinon.stub().returns(undefined), + getPythonProjects: sinon.stub().returns([]), + refreshEnvironments: sinon.stub().resolves(undefined), + } as any as PythonEnvironmentApi; + const baseManager = { + getEnvironments: sinon.stub().resolves([]), + } as any as EnvironmentManager; + return new VenvManager({} as NativePythonFinder, api, baseManager, { + info: sinon.stub(), + error: sinon.stub(), + warn: sinon.stub(), + } as any); + } + + test('rethrows on failure but clears state so a later call retries and succeeds', async () => { + findVirtualEnvironmentsStub.onFirstCall().rejects(new Error('discovery boom')); + findVirtualEnvironmentsStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + await assert.rejects(mgr.initialize(), /discovery boom/); + assert.strictEqual(findVirtualEnvironmentsStub.callCount, 1); + + await assert.doesNotReject(mgr.initialize()); + assert.strictEqual(findVirtualEnvironmentsStub.callCount, 2, 'a later call must retry after a failure'); + + await mgr.initialize(); + assert.strictEqual(findVirtualEnvironmentsStub.callCount, 2, 'no re-discovery after a successful init'); + }); + + test('settles concurrent waiters during a failing run (leader rejects, waiter resolves)', async () => { + findVirtualEnvironmentsStub.rejects(new Error('discovery boom')); + + const mgr = createManager(); + + const leader = mgr.initialize(); + const waiter = mgr.initialize(); + + await assert.rejects(leader, /discovery boom/); + await assert.doesNotReject(waiter); + assert.strictEqual(findVirtualEnvironmentsStub.callCount, 1, 'concurrent callers share one discovery run'); + + findVirtualEnvironmentsStub.resetBehavior(); + findVirtualEnvironmentsStub.resolves([]); + await assert.doesNotReject(mgr.initialize()); + assert.strictEqual(findVirtualEnvironmentsStub.callCount, 2, 'a fresh call retries after failure'); + }); + + test('does not re-run discovery after a successful initialize()', async () => { + findVirtualEnvironmentsStub.resolves([]); + const mgr = createManager(); + + await mgr.initialize(); + await mgr.initialize(); + + assert.strictEqual(findVirtualEnvironmentsStub.callCount, 1); + }); +}); diff --git a/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts b/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts index bab2831f8..f49942707 100644 --- a/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts +++ b/src/test/managers/conda/condaEnvManager.initialize.unit.test.ts @@ -255,6 +255,63 @@ suite('CondaEnvManager.initialize - lazy registration flow', () => { assert.strictEqual(refreshCondaEnvsStub.callCount, 1); }); + test('error path is retryable: a failed run clears state so a later call retries and succeeds', async () => { + getCondaStub.resolves('/usr/bin/conda'); + constructSourcingStub.resolves({ toString: () => '' } as any); + refreshCondaEnvsStub.onFirstCall().rejects(new Error('boom')); + refreshCondaEnvsStub + .onSecondCall() + .resolves([makeEnv('base', Uri.file('/opt/miniconda3').fsPath, '3.11.0')]); + + const mgr = createManager(); + + await assert.doesNotReject(mgr.initialize(), 'initialize() must never throw to its caller'); + assert.strictEqual(refreshCondaEnvsStub.callCount, 1); + + await mgr.initialize(); + assert.strictEqual(refreshCondaEnvsStub.callCount, 2, 'a later call must retry after a failed run'); + + const lazyInitCalls = sendTelemetryStub.getCalls().filter((c) => c.args[0] === EventNames.MANAGER_LAZY_INIT); + assert.strictEqual(lazyInitCalls.length, 2); + assert.strictEqual(lazyInitCalls[0].args[2].result, 'error'); + assert.strictEqual(lazyInitCalls[1].args[2].result, 'success'); + + await mgr.initialize(); + assert.strictEqual(refreshCondaEnvsStub.callCount, 2, 'no re-discovery after a successful init'); + }); + + test('error path settles concurrent waiters without rejecting, then permits a retry', async () => { + getCondaStub.resolves('/usr/bin/conda'); + constructSourcingStub.resolves({ toString: () => '' } as any); + refreshCondaEnvsStub.onFirstCall().rejects(new Error('boom')); + refreshCondaEnvsStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + const results = await Promise.allSettled([mgr.initialize(), mgr.initialize(), mgr.initialize()]); + assert.ok( + results.every((r) => r.status === 'fulfilled'), + 'all concurrent waiters must settle without rejecting', + ); + assert.strictEqual(refreshCondaEnvsStub.callCount, 1, 'concurrent callers share one discovery run'); + + await mgr.initialize(); + assert.strictEqual(refreshCondaEnvsStub.callCount, 2, 'a fresh call retries after failure'); + }); + + test('tool_not_found is treated as completed init and is not retried', async () => { + getCondaStub.rejects(new Error('Conda not found')); + + const mgr = createManager(); + await mgr.initialize(); + await mgr.initialize(); + + assert.strictEqual(refreshCondaEnvsStub.callCount, 1, 'tool_not_found must not cause repeated discovery'); + const lazyInitCalls = sendTelemetryStub.getCalls().filter((c) => c.args[0] === EventNames.MANAGER_LAZY_INIT); + assert.strictEqual(lazyInitCalls.length, 1); + assert.strictEqual(lazyInitCalls[0].args[2].result, 'tool_not_found'); + }); + test('no PET refresh is triggered before initialize(): construction alone does no work', () => { // Simply constructing the manager must not call into discovery. createManager(); diff --git a/src/test/managers/pipenv/pipenvManager.initialize.unit.test.ts b/src/test/managers/pipenv/pipenvManager.initialize.unit.test.ts new file mode 100644 index 000000000..76575726f --- /dev/null +++ b/src/test/managers/pipenv/pipenvManager.initialize.unit.test.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { reset, when } from 'ts-mockito'; +import { PythonEnvironment, PythonEnvironmentApi } from '../../../api'; +import * as logging from '../../../common/logging'; +import * as telemetrySender from '../../../common/telemetry/sender'; +import { createDeferred } from '../../../common/utils/deferred'; +import * as windowApis from '../../../common/window.apis'; +import { PythonProjectManager } from '../../../internal.api'; +import { PipenvManager } from '../../../managers/pipenv/pipenvManager'; +import * as pipenvUtils from '../../../managers/pipenv/pipenvUtils'; +import * as commonUtils from '../../../managers/common/utils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { mockedVSCodeNamespaces } from '../../unittests'; + +suite('PipenvManager.initialize - retry after failure (swallow style)', () => { + let getPipenvStub: sinon.SinonStub; + let refreshPipenvStub: sinon.SinonStub; + + setup(() => { + when(mockedVSCodeNamespaces.workspace!.getConfiguration('python')).thenReturn({ + get: () => undefined, + } as any); + + getPipenvStub = sinon.stub(pipenvUtils, 'getPipenv').resolves('/usr/bin/pipenv'); + refreshPipenvStub = sinon.stub(pipenvUtils, 'refreshPipenv'); + sinon.stub(pipenvUtils, 'getPipenvForGlobal').resolves(undefined); + sinon.stub(pipenvUtils, 'clearPipenvCache').resolves(); + 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, 'traceError'); + sinon.stub(logging, 'traceInfo'); + }); + + teardown(() => { + sinon.restore(); + reset(mockedVSCodeNamespaces.workspace!); + }); + + function createManager(): PipenvManager { + const api = { + getPythonProjects: sinon.stub().returns([]), + getPythonProject: sinon.stub().returns(undefined), + } as any as PythonEnvironmentApi; + return new PipenvManager({} as NativePythonFinder, api, {} as PythonProjectManager); + } + + test('swallowed failure clears state so a later call retries and succeeds', async () => { + refreshPipenvStub.onFirstCall().rejects(new Error('boom')); + refreshPipenvStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + await assert.doesNotReject(mgr.initialize(), 'initialize() must never throw to its caller'); + assert.strictEqual(refreshPipenvStub.callCount, 1); + + await mgr.initialize(); + assert.strictEqual(refreshPipenvStub.callCount, 2, 'a later call must retry after a failed run'); + + await mgr.initialize(); + assert.strictEqual(refreshPipenvStub.callCount, 2, 'no re-discovery after a successful init'); + }); + + test('swallowed failure settles concurrent waiters, then permits a retry', async () => { + refreshPipenvStub.onFirstCall().rejects(new Error('boom')); + refreshPipenvStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + const results = await Promise.allSettled([mgr.initialize(), mgr.initialize(), mgr.initialize()]); + assert.ok(results.every((r) => r.status === 'fulfilled'), 'all concurrent waiters must settle'); + assert.strictEqual(refreshPipenvStub.callCount, 1, 'concurrent callers share one discovery run'); + + await mgr.initialize(); + assert.strictEqual(refreshPipenvStub.callCount, 2, 'a fresh call retries after failure'); + }); + + test('tool_not_found is treated as completed init and is not retried', async () => { + getPipenvStub.resolves(undefined); + refreshPipenvStub.resolves([]); + + const mgr = createManager(); + await mgr.initialize(); + await mgr.initialize(); + + assert.strictEqual(refreshPipenvStub.callCount, 1, 'tool_not_found must not cause repeated discovery'); + }); + + test('a failing run does not clobber a deferred installed by a concurrent clearCache()+init', async () => { + const leaderGate = createDeferred(); + refreshPipenvStub.onFirstCall().returns(leaderGate.promise); + refreshPipenvStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + const leader = mgr.initialize(); + await new Promise((resolve) => setImmediate(resolve)); + + await mgr.clearCache!(); + await mgr.initialize(); + assert.strictEqual(refreshPipenvStub.callCount, 2, 'the reinit runs its own discovery'); + + leaderGate.reject(new Error('late boom')); + await assert.doesNotReject(leader, 'swallow style: the leader never throws to its caller'); + + await mgr.initialize(); + assert.strictEqual(refreshPipenvStub.callCount, 2, 'a failing run must not clobber a newer deferred'); + }); +}); diff --git a/src/test/managers/poetry/poetryManager.initialize.unit.test.ts b/src/test/managers/poetry/poetryManager.initialize.unit.test.ts new file mode 100644 index 000000000..94cac2b7e --- /dev/null +++ b/src/test/managers/poetry/poetryManager.initialize.unit.test.ts @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { reset, when } from 'ts-mockito'; +import { PythonEnvironmentApi } from '../../../api'; +import * as logging from '../../../common/logging'; +import * as telemetrySender from '../../../common/telemetry/sender'; +import * as windowApis from '../../../common/window.apis'; +import { PythonProjectManager } from '../../../internal.api'; +import { PoetryManager } from '../../../managers/poetry/poetryManager'; +import * as poetryUtils from '../../../managers/poetry/poetryUtils'; +import * as commonUtils from '../../../managers/common/utils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { mockedVSCodeNamespaces } from '../../unittests'; + +suite('PoetryManager.initialize - retry after failure (swallow style)', () => { + let getPoetryStub: sinon.SinonStub; + let refreshPoetryStub: sinon.SinonStub; + + setup(() => { + when(mockedVSCodeNamespaces.workspace!.getConfiguration('python')).thenReturn({ + get: () => undefined, + } as any); + + getPoetryStub = sinon.stub(poetryUtils, 'getPoetry').resolves('/usr/bin/poetry'); + refreshPoetryStub = sinon.stub(poetryUtils, 'refreshPoetry'); + sinon.stub(poetryUtils, 'getPoetryForGlobal').resolves(undefined); + 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, 'traceError'); + sinon.stub(logging, 'traceInfo'); + }); + + teardown(() => { + sinon.restore(); + reset(mockedVSCodeNamespaces.workspace!); + }); + + function createManager(): PoetryManager { + const api = { + getPythonProjects: sinon.stub().returns([]), + getPythonProject: sinon.stub().returns(undefined), + } as any as PythonEnvironmentApi; + return new PoetryManager({} as NativePythonFinder, api, {} as PythonProjectManager); + } + + test('swallowed failure clears state so a later call retries and succeeds', async () => { + refreshPoetryStub.onFirstCall().rejects(new Error('boom')); + refreshPoetryStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + await assert.doesNotReject(mgr.initialize(), 'initialize() must never throw to its caller'); + assert.strictEqual(refreshPoetryStub.callCount, 1); + + await mgr.initialize(); + assert.strictEqual(refreshPoetryStub.callCount, 2, 'a later call must retry after a failed run'); + + await mgr.initialize(); + assert.strictEqual(refreshPoetryStub.callCount, 2, 'no re-discovery after a successful init'); + }); + + test('swallowed failure settles concurrent waiters, then permits a retry', async () => { + refreshPoetryStub.onFirstCall().rejects(new Error('boom')); + refreshPoetryStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + const results = await Promise.allSettled([mgr.initialize(), mgr.initialize(), mgr.initialize()]); + assert.ok(results.every((r) => r.status === 'fulfilled'), 'all concurrent waiters must settle'); + assert.strictEqual(refreshPoetryStub.callCount, 1, 'concurrent callers share one discovery run'); + + await mgr.initialize(); + assert.strictEqual(refreshPoetryStub.callCount, 2, 'a fresh call retries after failure'); + }); + + test('tool_not_found is treated as completed init and is not retried', async () => { + getPoetryStub.resolves(undefined); + refreshPoetryStub.resolves([]); + + const mgr = createManager(); + await mgr.initialize(); + await mgr.initialize(); + + assert.strictEqual(refreshPoetryStub.callCount, 1, 'tool_not_found must not cause repeated discovery'); + }); +}); diff --git a/src/test/managers/pyenv/pyenvManager.initialize.unit.test.ts b/src/test/managers/pyenv/pyenvManager.initialize.unit.test.ts new file mode 100644 index 000000000..4cfa8de64 --- /dev/null +++ b/src/test/managers/pyenv/pyenvManager.initialize.unit.test.ts @@ -0,0 +1,83 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import assert from 'assert'; +import * as sinon from 'sinon'; +import { PythonEnvironmentApi } from '../../../api'; +import * as logging from '../../../common/logging'; +import * as telemetrySender from '../../../common/telemetry/sender'; +import * as windowApis from '../../../common/window.apis'; +import { PythonProjectManager } from '../../../internal.api'; +import * as commonUtils from '../../../managers/common/utils'; +import { NativePythonFinder } from '../../../managers/common/nativePythonFinder'; +import { PyEnvManager } from '../../../managers/pyenv/pyenvManager'; +import * as pyenvUtils from '../../../managers/pyenv/pyenvUtils'; + +suite('PyEnvManager.initialize - retry after failure (swallow style)', () => { + let getPyenvStub: sinon.SinonStub; + let refreshPyenvStub: sinon.SinonStub; + + setup(() => { + getPyenvStub = sinon.stub(pyenvUtils, 'getPyenv').resolves('/usr/bin/pyenv'); + refreshPyenvStub = sinon.stub(pyenvUtils, 'refreshPyenv'); + sinon.stub(pyenvUtils, 'getPyenvForGlobal').resolves(undefined); + 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, 'traceError'); + sinon.stub(logging, 'traceInfo'); + }); + + teardown(() => { + sinon.restore(); + }); + + function createManager(): PyEnvManager { + const api = { + getPythonProjects: sinon.stub().returns([]), + getPythonProject: sinon.stub().returns(undefined), + } as any as PythonEnvironmentApi; + return new PyEnvManager({} as NativePythonFinder, api, {} as PythonProjectManager); + } + + test('swallowed failure clears state so a later call retries and succeeds', async () => { + refreshPyenvStub.onFirstCall().rejects(new Error('boom')); + refreshPyenvStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + await assert.doesNotReject(mgr.initialize(), 'initialize() must never throw to its caller'); + assert.strictEqual(refreshPyenvStub.callCount, 1); + + await mgr.initialize(); + assert.strictEqual(refreshPyenvStub.callCount, 2, 'a later call must retry after a failed run'); + + await mgr.initialize(); + assert.strictEqual(refreshPyenvStub.callCount, 2, 'no re-discovery after a successful init'); + }); + + test('swallowed failure settles concurrent waiters, then permits a retry', async () => { + refreshPyenvStub.onFirstCall().rejects(new Error('boom')); + refreshPyenvStub.onSecondCall().resolves([]); + + const mgr = createManager(); + + const results = await Promise.allSettled([mgr.initialize(), mgr.initialize(), mgr.initialize()]); + assert.ok(results.every((r) => r.status === 'fulfilled'), 'all concurrent waiters must settle'); + assert.strictEqual(refreshPyenvStub.callCount, 1, 'concurrent callers share one discovery run'); + + await mgr.initialize(); + assert.strictEqual(refreshPyenvStub.callCount, 2, 'a fresh call retries after failure'); + }); + + test('tool_not_found is treated as completed init and is not retried', async () => { + getPyenvStub.resolves(undefined); + refreshPyenvStub.resolves([]); + + const mgr = createManager(); + await mgr.initialize(); + await mgr.initialize(); + + assert.strictEqual(refreshPyenvStub.callCount, 1, 'tool_not_found must not cause repeated discovery'); + }); +});