Skip to content

fix(core): resolve self-root .code-workspace ConfigScope id collision - #116

Open
jianfulin wants to merge 1 commit into
winterdrive:mainfrom
jianfulin:fix/self-root-scope-collision
Open

fix(core): resolve self-root .code-workspace ConfigScope id collision#116
jianfulin wants to merge 1 commit into
winterdrive:mainfrom
jianfulin:fix/self-root-scope-collision

Conversation

@jianfulin

Copy link
Copy Markdown
Contributor

fix(core): self-root .code-workspace causes ConfigScope id collision → duplicated/doubling groups

Problem

When a .code-workspace file declares only its own directory as the sole folder:

{ "folders": [{ "path": "." }] }

(a common "self-root" pattern — e.g. opening MyProject/MyProject.code-workspace where folders is just ["."]) — every activation of the extension causes the persisted group data in that project's .vscode/virtualTab.json to double.

Observed on two independent real projects using this workspace layout: one grew from 41 → 82 top-level-group-entries after a single close/reopen; a second grew similarly. Left unchecked over months of normal use, one file had accumulated to 8× its correct size (7 real groups → 56 duplicated entries). The VS Code tree view then also visually splits into multiple repeated Project: X scope headers, each showing the same duplicated group set.

Root cause

ConfigScopeDiscovery.discover() builds:

  • one workspace scope, id = uri.toString() of the .code-workspace file's parent directory
  • one folder scope per vscode.workspace.workspaceFolders[i], id = folder.uri.toString()

When folders is ["."], the workspace file's parent directory is that single folder, so:

workspaceScope.id === folderScope.id

This breaks the implicit invariant that ConfigScope.id is unique within one discover() result.

Downstream, provider.ts builds groupManagers: Map<string, GroupManager> keyed by scope.id:

for (const scope of this.configScopes) {
    const gm = new GroupManager(this.getConfigStorageRoot(scope));
    this.migrateLegacyWorkspaceConfig(scope, gm);
    this.groupManagers.set(scope.id, gm);   // <-- second set() silently overwrites the first
}

Because discovery order is workspace-scope-first, then folder-scopes, the second .set() (folder manager) silently overwrites the first (workspace manager) — the Map ends up with only one manager, pointing at the project's .vscode/virtualTab.json, but configScopes still has two entries with the same id.

loadGroups() (no-arg / full reload path) then iterates configScopes and does groupManagers.get(scope.id) once per scope:

for (const scope of this.configScopes) {
    const gm = this.groupManagers.get(scope.id); // same key twice → same GroupManager both times
    const { groups: saved } = gm.loadGroups();
    allGroups.push(...saved.map(g => ({ ...g, sourceScopeId: scope.id })));
}

Both iterations resolve to the same GroupManager/file, so the same N persisted groups get read twice into memory (2N). saveGroupsImmediate() buckets by scope.id the same way, so both scope "buckets" collapse into the same target file, and the doubled 2N gets written straight back to disk. Next activation reads 2N, doubles it to 4N, writes it back — compounding indefinitely.

Evidence that pinpoints "read twice" rather than "two legitimately different scopes merged"

For one affected project, after reopening once:

file total entries unique ids notes
project .vscode/virtualTab.json 82 41 all 41 ids appear exactly twice, each pair byte-identical
workspace-scope storage (globalStorage/.../workspace-config/.vscode/virtualTab.json) 41 41 mtime unchanged since first migration

If this were "workspace-config (41) + folder file (41) legitimately merged", the union would show ~40 overlapping ids plus a couple of scope-unique ones (that's roughly what was observed a layer up, before this precise mechanism was isolated). But the exact symptom reproduced live was: every id appearing precisely twice, confirming the same on-disk file was loaded twice in a single loadGroups() pass — not two distinct files merging.

Fix (minimal / hotfix scope)

  1. src/core/ConfigScopeDiscovery.ts — build folder scopes first, then only add the workspace scope if its id doesn't already collide with a folder scope's id (i.e. skip the workspace scope for the self-root alias case, keep the folder scope as sole canonical source):

    static discover(): ConfigScope[] {
        const scopes: ConfigScope[] = [];
    
        for (const folder of vscode.workspace.workspaceFolders ?? []) {
            scopes.push(ConfigScopeDiscovery.createFolderScope(folder));
        }
    
        if (vscode.workspace.workspaceFile) {
            const workspaceScope = ConfigScopeDiscovery.createWorkspaceScope(vscode.workspace.workspaceFile);
            const isSelfRootAlias = scopes.some(scope => scope.id === workspaceScope.id);
            if (!isSelfRootAlias) {
                scopes.unshift(workspaceScope);
            }
        }
    
        return scopes;
    }

    Ordinary multi-root workspaces (where the .code-workspace parent directory differs from every declared folder) are unaffected — the workspace scope is still added, in its original position.

  2. src/provider.ts — defense in depth: a private assertUniqueScopeIds() guard runs right after ConfigScopeDiscovery.discover() in both the constructor and reinitializeScopes(), before groupManagers is populated. If a duplicate id is ever found (e.g. from a future discovery rule that reintroduces a collision), it logs a warning and drops the colliding (later) scope rather than letting Map.set() silently overwrite — fails safe instead of failing silently.

  3. src/test/unit/ConfigScopeDiscovery.test.ts — added 4 cases: self-root alias collapses to one folder scope; resulting scope ids are unique; a mixed multi-root where one folder happens to equal the workspace root still keeps all folder scopes and only skips the colliding workspace scope; and a true multi-root (workspace parent different from every folder) is unaffected.

What this hotfix intentionally does NOT cover

  • No changes to migrateLegacyWorkspaceConfig() — it happens to no longer be invoked for the self-root alias case as a side effect of (1) (no workspace-type scope exists for that case anymore), but the function itself is untouched.
  • No shared/production-level duplicate-group repair utility for data that's already corrupted on disk (existing users with already-inflated virtualTab.json need a one-off manual cleanup, e.g. dedupe-by-id, keep-first-occurrence).
  • No handling of the pre-existing "orphaned" workspace-config storage files left behind from earlier migrations — those are harmless leftovers, not cleaned up automatically.
  • Broader P1 concerns (scoped (scopeId, groupId) runtime identity so two legitimately different scopes can safely share a group id, optimistic-lock retry correctness, generic multi-root duplicate-id UI handling) are out of scope for this hotfix.

Verification performed

  • tsc --noEmit — no errors.
  • Full unit suite: 27 suites / 194 tests passing (211 total once the 4 new cases are included in that count... see actual numbers in CI).
  • Property-based suite: 4 suites / 13 tests passing.
  • Real-world repro: packaged a local test VSIX with only this fix applied, installed over the previously-published version, opened an affected self-root .code-workspace project, and did 3 full close/reopen cycles. Group count stayed stable across all 3 (previously it had doubled after just 1 reopen on the same project/version prior to the fix).

Opening a .code-workspace whose folders array only declares its own
directory (e.g. "folders": [{ "path": "." }]) produces a workspace
scope and a folder scope with the same uri.toString() id. Because
provider.ts keys groupManagers by scope.id, the second Map.set()
silently overwrites the first, but configScopes still lists both
scopes. Every load then reads the same project .vscode/virtualTab.json
twice and writes the doubled result back, compounding on each
activation (confirmed live: 41 -> 82 group entries after one reopen).

ConfigScopeDiscovery.discover() now builds folder scopes first and
skips adding the workspace scope when its id already collides with a
folder scope's id, keeping the folder scope as the sole canonical
source for that project. provider.ts adds a defense-in-depth
assertUniqueScopeIds() guard before groupManagers is populated, so any
future collision fails safe (drops + warns) instead of silently
overwriting. Ordinary multi-root workspaces are unaffected.

Verified with 3 full close/reopen cycles against a real affected
project using a locally packaged test build; group count stayed
stable throughout (previously doubled after a single reopen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant