fix: preserve out-of-scope environments during venv scoped refresh - #23
fix: preserve out-of-scope environments during venv scoped refresh#23StellaHuang95 wants to merge 9 commits into
Conversation
|
🔒 Automated review in progress — @StellaHuang95 is auto-reviewing this PR. |
6cc58b0 to
cb9c163
Compare
cb9c163 to
91eb2a0
Compare
91eb2a0 to
e20845e
Compare
e20845e to
8f4877d
Compare
8f4877d to
ef5755e
Compare
ef5755e to
895fd3c
Compare
4d21876 to
b62e7c0
Compare
…ed refresh Skip scoped reconciliation when the requested scope is uninspectable instead of widening authority to the owning project root, and derive scoped add events from loadEnvMap's own appends with post-await revalidation so concurrent create/remove are not misattributed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Prevent scoped project-map loading from appending out-of-scope persisted or global environments by threading an in-scope predicate into loadEnvMap and loadGlobalEnv, and derive scoped add events from collection object identity so a same-id remove-and-recreate during map loading cannot emit a duplicate add. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… before discovery Reconcile in-scope environments by normalized path plus object identity so an unchanged object emits no event, a same-path replacement (even with an equal envId.id) emits remove+add, and every stale in-scope object leaves the collection with a remove. Resolve the authoritative scope root via findParentIfFile before discovery inside the serialized refresh chain, pass the resolved directory to both discovery and reconciliation, and stay fail-closed (skip discovery and mutation) when resolution throws. Full refresh unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…overy Restrict the scoped refresh scope-inspection catch to swallow only ENOENT (ambiguous missing scope) and rethrow EACCES/EPERM/unexpected I/O errors so refresh() rejects and surfaces the failure while the chain still recovers. Add a narrow direct-mutation generation counter bumped only by successful user/direct collection mutations (create add/replace, remove, set-global append). Capture it before discovery and discard the stale discovery result (no collection or event mutation) when a direct mutation changed the collection while discovery was in flight, for both scoped and full refreshes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
A direct set(undefined, env) that resolves a previously undiscovered global environment appends it to the collection via loadGlobalEnv/addEnvironment (raiseEvent=false) and bumps the direct-mutation generation, which correctly discards any in-flight refresh as stale. But the append fired no onDidChangeEnvironments add event, so subscribers never learned about the new collection member and the discarded refresh could not publish it either. resetGlobalEnv now publishes exactly one add event for the environment loadGlobalEnv appended (the same object) and bumps the generation once. The refresh-owned loadEnvMap path still calls loadGlobalEnv directly and computes its own events, so it is unaffected and no add is double-emitted. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The full (unscoped) refresh replaced the collection and then computed its add events from the entire shared collection after awaiting loadEnvMap. A direct create (or set-global add) during that await pushes its own env and fires its own add, so the refresh re-emitted the same env as a duplicate add. Assign a copy of the discovered results to the collection (so the discovery snapshot stays pristine), capture loadEnvMap's appended return, and compute add events only from those operation-local sources revalidated against the current collection. This mirrors the scoped path and drops concurrently created/removed envs from the refresh's own event batch without changing the common-case behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…plicate remove events Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
| } | ||
| const discovered = | ||
| (await findVirtualEnvironments( | ||
| hardRefresh, |
There was a problem hiding this comment.
Issue · Please address or respond
The prior deleted-file issue remains unresolved: every ENOENT returns before consulting known project ownership, so a deleted file under a recognized project silently retains stale environments. Resolve confirmed file scopes through their owning project directory without widening ambiguous missing-directory scopes.
[verified]
| } | ||
| const previousCollection = this.collection; | ||
| this.collection = [...discovered]; | ||
| const appended = await this.loadEnvMap(); |
There was a problem hiding this comment.
Issue · Please address or respond
The manager-wide generation check still discards valid scoped discovery after any direct mutation, including one outside the requested scope. Use scope/path-aware conflict detection or merge against the latest collection so unrelated environments do not cancel authoritative in-scope work.
[verified]
| if (!this.collection.includes(env) && !this.wasDirectlyRemovedSince(env, generation)) { | ||
| changes.push({ environment: env, kind: EnvironmentChangeKind.remove }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Issue · Please address or respond
Removal attribution remains keyed by object identity, and the tests now codify separate removals for distinct objects representing the same ID and normalized path. Reconcile removals by logical environment identity and operation order so remove/recreate sequences cannot end with a stale trailing removal for the surviving replacement.
[verified]
Problem
VenvManagersupports URI-scoped refreshes (refresh(scope)), triggered for example after creating an environment in a specific workspace folder (envCommandscallsm.refresh(options?.rootUri)) or viaapi.refreshEnvironments(uri). A scoped discovery is authoritative only for the target scope, but the manager treated its results as authoritative for the entire collection.Reproduction (multi-root workspace)
AandB, each containing its own.venv, plus a global env frompython.venvFolders/~/.virtualenvs.A(create/select an env inA, orapi.refreshEnvironments(Uri.file(A))).B's environment and the global env vanish from the list andonDidChangeEnvironmentsfires spuriousremoveevents for them — even though onlyAwas refreshed.Root cause
internalRefreshranfindVirtualEnvironments(..., [scope])— which searches only the scope plus configuredvenvFolders, skipping global discovery — and then replacedthis.collectionwith just those scoped results, firingremovefor every previously-known environment regardless of whether it was in the scope.Fix
The scoped branch now merges into the collection instead of replacing it (full refresh and all other managers are unchanged):
mergeScopedEnvironmentsresolves it to a directory via the existingfindParentIfFilehelper (falling back to the raw scope if the path can't be inspected).isPathInside(scope, candidate)helper (path.resolve+path.relative, inclusive of the scope, sibling-prefix safe so.../app⊄.../app-2).venvFolders) untouched — same objects, no events.venvFoldersenv that scoped discovery also returns isn't duplicated).removefor the previous in-scope envs andaddfor the newly present ones. Retained siblings/globals never surface.loadEnvMap()and the full remove-all / add-all behavior of an unscoped refresh are kept as-is.Tests
New
venvManager.scopedRefresh.unit.test.ts(hermetic, multi-root; topologyappvsapp-2, global outside root):remove/addfire),venvFoldersduplicate outside the scope from discovery is ignored (existing global object retained, not churned),appvsapp-2),pathUtils.unit.test.tsadds anisPathInsidesuite (equal/child/nested/parent/sibling-prefix/unrelated/relative + Windows drive and case-folding cases).npm run lint,npm run compile-tests, andnpm run unittest(1709 passing) are green.Fixes the scoped-refresh regression for multi-root workspaces.