Skip to content

fix: preserve out-of-scope environments during venv scoped refresh - #23

Open
StellaHuang95 wants to merge 9 commits into
mainfrom
preserve-scoped-refresh-results
Open

fix: preserve out-of-scope environments during venv scoped refresh#23
StellaHuang95 wants to merge 9 commits into
mainfrom
preserve-scoped-refresh-results

Conversation

@StellaHuang95

Copy link
Copy Markdown
Owner

Problem

VenvManager supports URI-scoped refreshes (refresh(scope)), triggered for example after creating an environment in a specific workspace folder (envCommands calls m.refresh(options?.rootUri)) or via api.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)

  1. Open a multi-root workspace with folders A and B, each containing its own .venv, plus a global env from python.venvFolders / ~/.virtualenvs.
  2. All three environments are discovered and listed.
  3. Trigger a scoped refresh of folder A (create/select an env in A, or api.refreshEnvironments(Uri.file(A))).
  4. Before this fix: folder B's environment and the global env vanish from the list and onDidChangeEnvironments fires spurious remove events for them — even though only A was refreshed.

Root cause

internalRefresh ran findVirtualEnvironments(..., [scope]) — which searches only the scope plus configured venvFolders, skipping global discovery — and then replaced this.collection with just those scoped results, firing remove for 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):

  • Scope directory. A scope may be a file, so mergeScopedEnvironments resolves it to a directory via the existing findParentIfFile helper (falling back to the raw scope if the path can't be inspected).
  • Partition. The current collection is split into the envs inside the scope and those outside it using a new isPathInside(scope, candidate) helper (path.resolve + path.relative, inclusive of the scope, sibling-prefix safe so .../app.../app-2).
  • Retain every env outside the scope (siblings, globals, venvFolders) untouched — same objects, no events.
  • Replace the in-scope region with the freshly discovered envs, dropping any discovered result whose normalized path duplicates a retained env (so a global venvFolders env that scoped discovery also returns isn't duplicated).
  • Events cover only in-scope changes: remove for the previous in-scope envs and add for 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; topology app vs app-2, global outside root):

  • sibling + global retention while the in-scope env is rediscovered (only in-scope remove/add fire),
  • target-only stale removal, new in-scope add,
  • a venvFolders duplicate outside the scope from discovery is ignored (existing global object retained, not churned),
  • sibling-prefix safety (app vs app-2),
  • unchanged full-refresh behavior, scope pass-through to discovery, and file-scope → directory resolution (plus the inspect-failure fallback).

pathUtils.unit.test.ts adds an isPathInside suite (equal/child/nested/parent/sibling-prefix/unrelated/relative + Windows drive and case-folding cases).

npm run lint, npm run compile-tests, and npm run unittest (1709 passing) are green.

Fixes the scoped-refresh regression for multi-root workspaces.

@StellaHuang95 StellaHuang95 added the bug Something isn't working label Aug 23, 2026
@StellaHuang95

StellaHuang95 commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

🔒 Automated review in progress — @StellaHuang95 is auto-reviewing this PR.

Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/test/managers/builtin/venvManager.scopedRefresh.unit.test.ts
Comment thread src/test/managers/builtin/venvManager.scopedRefresh.unit.test.ts
@StellaHuang95 StellaHuang95 added the review-auto:changes-requested Automated review: posted blocking findings to address. label Aug 23, 2026
@StellaHuang95
StellaHuang95 force-pushed the preserve-scoped-refresh-results branch from 6cc58b0 to cb9c163 Compare August 23, 2026 05:03
Comment thread src/managers/builtin/venvManager.ts Outdated
@StellaHuang95
StellaHuang95 force-pushed the preserve-scoped-refresh-results branch from cb9c163 to 91eb2a0 Compare August 23, 2026 05:37
Comment thread src/managers/builtin/venvManager.ts Outdated
@StellaHuang95
StellaHuang95 force-pushed the preserve-scoped-refresh-results branch from 91eb2a0 to e20845e Compare August 23, 2026 06:24
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/test/managers/builtin/venvManager.scopedRefresh.unit.test.ts Outdated
Comment thread src/test/managers/builtin/venvManager.scopedRefresh.unit.test.ts Outdated
@StellaHuang95
StellaHuang95 force-pushed the preserve-scoped-refresh-results branch from e20845e to 8f4877d Compare August 23, 2026 07:20
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/test/managers/builtin/venvManager.scopedRefresh.unit.test.ts
@StellaHuang95
StellaHuang95 force-pushed the preserve-scoped-refresh-results branch from 8f4877d to ef5755e Compare August 23, 2026 07:48
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts
@StellaHuang95
StellaHuang95 force-pushed the preserve-scoped-refresh-results branch from ef5755e to 895fd3c Compare August 23, 2026 08:27
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts
@StellaHuang95
StellaHuang95 force-pushed the preserve-scoped-refresh-results branch 2 times, most recently from 4d21876 to b62e7c0 Compare August 23, 2026 09:42
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts Outdated
…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>
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts Outdated
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>
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts
… 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>
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts
…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>
Comment thread src/managers/builtin/venvManager.ts Outdated
Comment thread src/managers/builtin/venvManager.ts Outdated
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>
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/managers/builtin/venvManager.ts Outdated
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>
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/managers/builtin/venvManager.ts
…plicate remove events

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/managers/builtin/venvManager.ts
Comment thread src/managers/builtin/venvManager.ts
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
}
const discovered =
(await findVirtualEnvironments(
hardRefresh,

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

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();

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

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 });
}
}

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

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]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working review-auto:changes-requested Automated review: posted blocking findings to address.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant