fix: preserve conda environments on transient discovery failure (and fix fast-path/concurrency races) - #12
fix: preserve conda environments on transient discovery failure (and fix fast-path/concurrency races)#12StellaHuang95 wants to merge 3 commits into
Conversation
refreshCondaEnvs now returns PythonEnvironment[] | undefined so callers can distinguish a transient discovery failure (native finder rejection or non-array output) from an authoritative successful-empty result. CondaEnvManager guards the initialize, refresh, and get background-init paths so a failure preserves the known-good collection and emits no environment changes, while a successful empty result still removes stale environments and emits removals. Preserves existing _initialized retry semantics and adds no user notifications. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Address two review findings on the conda result-preservation fix: - Narrow the failure contract to a thrown/rejected native-finder refresh only. Revert the defensive non-array guard in refreshCondaEnvs back to returning [] (NativePythonFinder already normalizes malformed worker output to [] upstream); only a rejection/exception returns undefined. - Fix a blocking persisted-selection regression: on failed discovery the manager now preserves the known-good collection AND still runs loadEnvMap() to restore persisted global/workspace selections, emitting add events only for environments newly appended (snapshot-before / diff-after by object identity) and never emitting removals. Applied via a new preserveCollectionOnFailedDiscovery() helper in initialize, explicit refresh, and background startBackgroundInit. Tests: drop the malformed-output-as-failure cases; add regression tests proving persisted global (initialize + explicit refresh) and workspace (fast/background get) selections are restored on failed discovery, retained across get calls, and emit only genuine additions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
🔒 Automated review in progress — @StellaHuang95 is auto-reviewing this PR. |
| ...new Set( | ||
| events.filter((e) => e.kind === EnvironmentChangeKind.add).map((e) => e.environment.name), | ||
| ), | ||
| ]; |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
📍 src/test/managers/conda/condaEnvManager.resultPreservation.unit.test.ts:255
The Set deduplicates addition names, so this test passes even if the background path emits duplicate add events. Assert the raw addition count and ordered names instead.
[verified]
| traceVerbose( | ||
| 'Conda discovery failed during initialization; preserving collection and restoring persisted selections.', | ||
| ); | ||
| await this.preserveCollectionOnFailedDiscovery(); |
There was a problem hiding this comment.
Warning · Non-blocking recommendation
📍 src/managers/conda/condaEnvManager.ts:131
The same failure-result interpretation is repeated at all three discovery entry points, leaving the preservation invariant vulnerable to drift. Centralize applying a discovery result in a private helper, or otherwise keep the branches mechanically linked.
[verified]
…iled discovery Second correction round for the conda result-preservation fix; closes two concurrency races found in review: - get()'s fast path now silently registers its resolved environment into the collection/map by normalized-path identity, so a subsequent background discovery failure whose own re-resolution also fails cannot lose it. - loadEnvMap() returns exactly the environments it appended and re-checks identity before each push. Failed-discovery recovery emits adds only for that list, so a concurrent create/resolve append is neither duplicated in the collection nor emitted twice. Adds two deterministic, signal-gated regression tests and relaxes the timing-dependent fast/background get test to be order-independent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| // concurrent background discovery failure (whose own re-resolution may also fail) | ||
| // cannot lose it. Registration is silent: the discovery paths announce the collection, | ||
| // and failure recovery reuses this entry via normalized-path identity. | ||
| this.registerFastPathEnv(scope, fastResult.env); |
There was a problem hiding this comment.
Issue · Please address or respond
📍 src/managers/conda/condaEnvManager.ts:394
The Skeptic reproduced a race where successful background discovery finishes before the fast path, after which this silently inserts an environment into the already-announced authoritative collection. Reconcile this completion order by emitting an add when necessary or routing the late result through a common post-discovery application path, and add a gated regression test.
[verified]
| // and never emit removals, but still restore persisted global/workspace | ||
| // selections (they resolve independently of discovery). A successful (possibly | ||
| // empty) array is applied normally. | ||
| if (refreshed === undefined) { |
There was a problem hiding this comment.
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]
Context
CondaEnvManagerkeeps an in-memorycollectionof discovered conda environments and emitsonDidChangeEnvironmentsadd/remove events as that collection changes. Discovery is delegated torefreshCondaEnvsincondaUtils.ts, which drives the native finder (PET). Persisted global/workspace selections are restored separately byloadEnvMap(), which resolves the persisted paths and appends them to the collection (it never removes).Previously,
refreshCondaEnvsreturnedPythonEnvironment[]and used[]to represent two different outcomes:Because both looked identical (
[]), the manager could not tell them apart.Transient-failure reproduction
collectionis populated (envs visible in the UI, exposed to Python/Jupyter API consumers).nativeFinder.refresh()rejects (PET spawn/timeout/IO error).refreshCondaEnvscaught the error internally and returned[].CondaEnvManager.refresh()diddiscard = collection; collection = await refreshCondaEnvs(...), then firedremovefor every env indiscardandaddfor none.removeevents were emitted after a purely transient error. The next successful refresh re-added them, causing visible flicker and churn for downstream API consumers — and any persisted selection appeared to vanish.Root cause
The
[]sentinel conflated "authoritative empty" with "discovery failed", so every collection-assignment site inCondaEnvManagertreated a transient failure as "conda now has zero environments".Failure-vs-empty contract
refreshCondaEnvsnow returnsPromise<PythonEnvironment[] | undefined>:undefined-> discovery failed. The native finder threw/rejected. Callers must preserve any previously known-good collection and must not emit removals.[]) -> authoritative success. An empty array means conda genuinely has no environments (or conda is not installed); stale environments should be removed normally.The native finder already normalizes malformed worker output to
[]before conda sees it, so the defensive non-array guard inrefreshCondaEnvskeeps its preexisting behavior and returns[](a successful-empty result), not a failure. This PR does not change shared finder behavior and does not reclassify malformed production output — the authoritative distinction is purely rejection/exception vs successful array (including[]).Fix
condaUtils.tsrefreshCondaEnvs: return type ->PythonEnvironment[] | undefined; only the native-finder rejection/exceptioncatchreturnsundefined. The non-array guard and the legitimate "conda not installed / not found" paths keep returning[].condaEnvManager.tsguards all three discovery sites (initialize, explicitrefresh, and backgroundstartBackgroundInitinsideget). OnrefreshCondaEnvs() === undefined:this.collectionis preserved (never replaced with[]);removeevents are emitted;loadEnvMap()is still run so persisted global/workspace selections are restored/updated even though discovery failed.A new private helper
preserveCollectionOnFailedDiscovery()runsloadEnvMap()and emitsaddevents only for the environmentsloadEnvMap()reports it appended — never removals.Existing
_initializedretry semantics are untouched (a separate PR owns initialization retries). A failure now returnsundefinedwithout throwing, sotryFastPathGet's reject-triggered retry path infastPath.tsis unchanged.No new user notifications — only existing trace/log channels (
log.warn/traceVerbose).Why legitimate deletion remains intact
A genuine "no more conda environments" result is a successful
[], which is!== undefined, sorefresh()falls through to the normaldiscard/collectionswap and firesremoveevents for every stale environment. Uninstalling conda or deleting all conda envs still clears the collection and emits removals exactly as before — only transient failures (rejections) are now preserved.Persisted-selection restoration on failure
Two reviews flagged a blocking regression: early-returning on failure without calling
loadEnvMap()would leave a persisted global/workspace selection unrestored. The fix keepsloadEnvMap()on the failure path so a persisted selection that resolves independently is still restored, mapped, and made available viaget(). Only genuinely newly-appended environments are emitted asaddevents; the failed-discovery path never emits removals.Concurrency races (follow-up review)
A final review reproduced two races between the fast path, the failed-discovery recovery, and concurrent create/resolve. Both are fixed surgically:
get()'s fast path could resolve a persisted environment and return it without registering it incollection/fsPathToEnv. If background discovery then failed andloadEnvMap()'s own re-resolution also failed, initialization completed with the environment lost, and the nextget()returnedundefined. Fix:get()now calls a new privateregisterFastPathEnv(scope, env)immediately after a successful fast path, registering the environment into the collection + workspace map by normalized-path identity (reusing any existing entry). Registration is silent (no event) by design: the discovery success path replacesthis.collectionwholesale and re-emits the entire collection, so emitting on registration would double-emit on success. Background failure recovery now finds and reuses the registered environment viafindEnvironmentByPathwithout requiring a second successful resolve.preserveCollectionOnFailedDiscovery()previously inferred additions by snapshotting the whole collection around the awaitedloadEnvMap(), so a concurrentcreate()/resolve()that appended (and already emitted) its own environment during the await was emitted a second time by recovery. Fix:loadEnvMap()now returns exactly the environments it itself appended, and re-checksfindEnvironmentByPathimmediately before each push (after the awaitedresolveCondaPath, at both the global and workspace resolve sites) so a concurrent insert cannot duplicate the collection. Recovery emits adds for only that returned list.Tests
Focused unit tests (
condaUtils.refreshCondaEnvs.unit.test.ts,condaEnvManager.resultPreservation.unit.test.ts):undefined; successful empty discovery ->[](defined array).undefined) with no change events when nothing persisted resolves;[]empties the collection and emits the expectedremoveevents;get(undefined)calls;getwhose background discovery fails restores a persisted workspace selection, returns it on the firstget, retains it after initialization settles, and never emits a duplicate/removal;refreshCondaEnvsproves recovery reuses the registered env (resolveCondaPathcalled exactly once) and repeatedget()returns the same environment with no duplicate collection entry or event;loadEnvMap(gatedresolveCondaPath) runs concurrently with aresolve()that appends + emits the same env — asserting exactly one collection entry and exactly one rawaddevent (recovery emits nothing extra).Validation:
npm run lintclean,npm run compile-testsclean, targeted conda + fast-path suites pass. Full unit suite: 1701 passing; the only failure is a pre-existing, unrelated WindowsEPERMfile-rename flake ininlineScriptCacheLayout(touches no conda code; CI runs on Linux).Limitations
NativePythonFinderbehavior; malformed worker output is still normalized to[]upstream (preexisting) and treated as a successful-empty result.initializefrom a genuine empty result (result='success', envCount=0). This was already true before this change; the newundefinedsignal makes a follow-up possible.Fixes the transient-failure environment wipe described above.
Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com