Skip to content

fix(mcp): dedupe one repo's two path spellings onto one DB connection (#1057)#1082

Open
inth3shadows wants to merge 1 commit into
colbymchenry:mainfrom
inth3shadows:fix/1057-root-identity-cache
Open

fix(mcp): dedupe one repo's two path spellings onto one DB connection (#1057)#1082
inth3shadows wants to merge 1 commit into
colbymchenry:mainfrom
inth3shadows:fix/1057-root-identity-cache

Conversation

@inth3shadows

@inth3shadows inth3shadows commented Jun 30, 2026

Copy link
Copy Markdown

Problem (#1057)

On WSL, opening a repo under /mnt via two different path spellings — most concretely an upper- vs lowercase variant of the same path — corrupts .codegraph, even with CODEGRAPH_NO_DAEMON set. The same class of bug hits a symlinked checkout on any platform.

Root cause

ToolHandler.getCodeGraph caches each open CodeGraph (a live SQLite connection) in projectCache, and reuses the default instance, keyed on the resolved-root path string. Two spellings of one physical directory resolve to two different strings → two cache entries → two SQLite connections to the same .codegraph/codegraph.db. Concurrent writes across the two connections corrupt the index. (This is the same second-connection hazard already documented at the #238 comment in that method.)

Why not just realpathSync the root

That was my first attempt, and it is insufficient — verified on a real WSL DrvFs mount:

realpathSync('/mnt/c/.../MyProj') -> /mnt/c/.../MyProj
realpathSync('/mnt/c/.../myproj') -> /mnt/c/.../myproj   # casing preserved!

realpathSync resolves symlinks and ./.., but on a case-insensitive case-preserving filesystem it returns the caller's casing, so it cannot dedupe case-variants. Filesystem identity (dev, ino) is identical for every spelling and is the robust key.

Fix

Key projectCache and the default-instance reuse check on (dev, ino) via a new canonicalRootKey() helper. This mirrors the inode-identity pattern this codebase already uses in DatabaseConnection.openedInode (statInode) for replace-on-disk detection, so it's idiomatic rather than novel. Minimal blast radius: findNearestCodeGraphRoot itself is unchanged.

Reproduced (WSL2 / Ubuntu)

Before fix, against the built resolver:

[drvfs] via MyProj: /mnt/c/.../MyProj
[drvfs] via myproj: /mnt/c/.../myproj   -> two roots -> two connections (corruption)

After fix, canonicalRootKey converges:

symlink converges:            true
drvfs case-variant converges: true
distinct projects stay distinct: true

Tests

__tests__/root-identity.test.ts (4 tests). The symlink case is a deterministic, filesystem-agnostic proxy for the case-insensitive-mount scenario (both produce two path strings for one inode), so it runs on case-sensitive CI. Full suite green locally (the only failures were two pre-existing CPU-contention timing flakes — query-pool, mcp-daemon — that pass in isolation).

Scope / follow-up

This fixes the in-process connection cache — the reported CODEGRAPH_NO_DAEMON path.

daemon-registry.ts also hashes the path string rather than (dev, ino), so two spellings can produce two discovery entries under ~/.codegraph/daemons/ for the same daemon. That's a smaller, separate bug than this PR's — the actual daemon-start arbitration (daemon.ts's acquireLockViaExclusiveOpen, an O_CREAT|O_EXCL create on a real file inside the project's own .codegraph/) is already safe against path-spelling variance, since the OS resolves any spelling to the same physical file. So the with-daemon path doesn't get the same connection-level corruption this PR fixes — worst case is a cosmetic duplicate/stale entry in codegraph list / stop --all. Not expanding this PR for it.


Validated on WSL2 (Ubuntu). Happy to adjust naming/placement to your preference.


Edit: Softened the "Scope / follow-up" section above — my original wording called this "the same class of issue" as the corruption bug this PR fixes. On a closer look at daemon.ts's lock arbitration, it isn't: the daemon-start path is already safe by construction, and the only actual gap is the cosmetic duplicate-listing one described above. Didn't want the overstatement to stand uncorrected.

Fixes #1057

@inth3shadows

Copy link
Copy Markdown
Author

Re-verified against main @ 5955d04: #1057 still reproduces and this fix is not superseded.

ToolHandler's open-connection cache is still keyed by the resolved-root path string, not filesystem identity:

  • src/mcp/tools.ts:1096 — default-instance reuse is this.cg.getProjectRoot() === resolvedRoot (string equality)
  • src/mcp/tools.ts:1099-1101projectCache.get(resolvedRoot) / .set(resolvedRoot, cg) ("Cache the open DB connection by RESOLVED ROOT only")

findNearestCodeGraphRoot returns a cased path string, so on a case-insensitive mount (WSL DrvFs /mnt/c, NTFS) two spellings of one repo resolve to two different strings → two projectCache entries → a second SQLite connection to the same .codegraph/codegraph.db — the exact second-connection corruption mechanism called out for #238 right above that code.

This PR's canonicalRootKey (dev:ino) collapses every spelling of one physical root onto a single cache entry, which is what prevents the second connection.

Status: the branch now conflicts with main — the cache region here, src/directory.ts, and CHANGELOG.md have all moved since it was opened — so it needs a rebase before it can merge, but the approach applies unchanged.

…ath (colbymchenry#1057)

Two spellings of one repo — a symlinked checkout, or upper/lowercase variants
of a path on a case-insensitive mount (Windows NTFS, WSL DrvFs /mnt) — resolved
to two different cache keys, so the MCP server opened a second SQLite connection
to the same .codegraph/codegraph.db; concurrent writes then corrupted the index.

Key projectCache (and the default-instance reuse check) on (dev,ino) filesystem
identity, which is identical for every spelling. realpath alone is insufficient:
on a case-insensitive, case-preserving filesystem it returns the caller's casing
and cannot dedupe case-variants. Mirrors the existing inode-identity pattern in
DatabaseConnection.openedInode.

Adds __tests__/root-identity.test.ts (symlink case is a deterministic, FS-agnostic
proxy for the case-insensitive-mount scenario).
@inth3shadows
inth3shadows force-pushed the fix/1057-root-identity-cache branch from 1a9af8a to f0bec08 Compare July 18, 2026 12:00
@inth3shadows

Copy link
Copy Markdown
Author

Rebased onto main @ 5955d04 and force-pushed — now MERGEABLE.

Conflicts were CHANGELOG.md plus re-applying the inode-keying to the current freshen()-wrapped cache block in tools.ts: openSync(resolvedRoot) still opens by the resolved path, while the default-instance check and projectCache get/set are now keyed by canonicalRootKey(resolvedRoot).

Verified on Node 22: tsc --noEmit clean, the root-identity regression suite (4 tests) passes, and mcp-unindexed (8 tests) passes. Also added Fixes #1057 so the issue closes on merge.

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.

v1.1.3 Codex MCP on WSL /mnt can open same repo via upper/lowercase paths, corrupting .codegraph despite CODEGRAPH_NO_DAEMON

1 participant