fix(mcp): canonicalize the daemon rendezvous root on Windows

A project spelled d:\work\codegraph or D:\work\codegraph is one directory, but the daemon socket/pipe name and the registry record name hashed a raw path.resolve, which preserves whichever casing the caller passed. A proxy that computed the other casing probed a socket nobody bound, spawned a redundant daemon, and that daemon exited on the lock the running one held, so the session silently served the graph in-process: no shared watcher, no auto-sync, one extra copy of the index per open window.

Both names now come from one shared canonicalProjectRoot (native realpath, lowercased on win32), so every spelling meets the same daemon and codegraph list / stop --all see one record per project.
This commit is contained in:
养心堂主
2026-09-28 14:15:57 +08:00
parent c13bd0890f
commit 1811788018
5 changed files with 143 additions and 4 deletions
+2
View File
@@ -167,6 +167,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
#### MCP / indexing
- **A Windows project whose path is spelled with a different drive-letter case no longer falls back to serving the graph on its own.** `d:\work\codegraph` and `D:\work\codegraph` are the same folder, but they named two different background helpers, so a session that did not spell the path exactly like the already-running one could not join it: it started a helper of its own, that one exited immediately because the first still held the project, and the session quietly served the graph in-process — no shared file watching or auto-sync, and one extra copy of the index per open window. Both spellings now name the same helper, and `codegraph list` / `codegraph stop --all` see one entry per project instead of two. If an older background helper is still running when you upgrade, the first session may fall back until it idles out (a few minutes); `codegraph stop` ends it immediately.
- File watching no longer drops the full re-scan a removed directory asks for when that sync fails, so the deleted files leave the index instead of lingering. (#1964)
- Daemon startup and cleanup now preserve live legacy PID-only locks while still reclaiming dead or identity-disproved records, preventing two writers from serving the same project.
- Incremental sync now keeps edge rebinding crash-safe: replacing a resolved edge with its recovery reference commits atomically, so an interruption cannot permanently remove the relationship.
+90
View File
@@ -0,0 +1,90 @@
/**
* One project must map to ONE daemon rendezvous key, however its root is
* spelled — the regression behind "the MCP server starts but never shares the
* daemon".
*
* The daemon's named pipe (Windows) / tmpdir socket (POSIX) is
* `…codegraph-<sha256(canonical root)>.slice(0,16)`, and the lockfile is shared
* while the SOCKET NAME is derived independently by each process. So the moment
* two processes hash the same directory differently they stop meeting: the
* proxy's probe finds nothing, it spawns a redundant daemon, and that daemon
* exits on the lock the first one holds
* (`Another daemon (pid N) already holds the lock; exiting.`) — the session then
* serves in-process, without the shared watcher or auto-sync.
*
* That is reachable on Windows because the root arrives two ways: a cwd-derived
* root is the on-disk casing (`D:\work\codegraph`), while a client-supplied
* `rootUri`/`workspaceFolders` path arrives as `file:///d%3A/…` → `d:\…`, and
* `path.resolve` preserves whichever it got (NTFS is case-insensitive, so both
* name one directory). Pinned here at the level that actually broke: the key,
* not the filename.
*
* The real-filesystem assertions are Windows-gated — on POSIX the two spellings
* are genuinely different directories, so only the Windows case has a
* case-insensitive filesystem to converge on.
*/
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { canonicalProjectRoot } from '../src/directory';
import { getDaemonSocketPath } from '../src/mcp/daemon-paths';
const tmpDirs: string[] = [];
function makeDir(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rendezvous-'));
tmpDirs.push(dir);
return dir;
}
afterEach(() => {
while (tmpDirs.length) {
try {
fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
} catch {
/* best-effort */
}
}
});
/** The same drive letter, forced to one case. No-op on a path without a drive. */
function withDriveCase(p: string, upper: boolean): string {
return p.replace(/^[a-z]:/i, (drive) => (upper ? drive.toUpperCase() : drive.toLowerCase()));
}
describe('daemon rendezvous key', () => {
it('collapses redundant path segments to one socket name', () => {
const root = makeDir();
const indirect = path.join(root, 'a', '..', 'b', '.');
const direct = path.join(root, 'b');
// `.`, `..` and a trailing separator all name the same directory, so both
// spellings must resolve to the same socket — otherwise `--path` with a
// stray separator is enough to miss a running daemon.
expect(getDaemonSocketPath(indirect)).toBe(getDaemonSocketPath(direct));
});
it.runIf(process.platform === 'win32')(
'maps both drive-letter cases of one directory onto one pipe',
() => {
const root = makeDir();
const upper = withDriveCase(root, true);
const lower = withDriveCase(root, false);
expect(canonicalProjectRoot(upper)).toBe(canonicalProjectRoot(lower));
expect(getDaemonSocketPath(upper)).toBe(getDaemonSocketPath(lower));
// …and the converged key is the on-disk casing, lowercased, so it does not
// depend on which of the two the caller happened to hold.
expect(canonicalProjectRoot(upper)).toBe(fs.realpathSync.native(root).toLowerCase());
},
);
it.runIf(process.platform === 'win32')(
'converges case variants even when the root cannot be realpath’d',
() => {
// The `.codegraph/` root normally exists, so this is the fallback arm —
// but a divergence there would fail identically, so pin it.
const missing = path.join(os.tmpdir(), 'cg-rendezvous-absent', 'nested');
expect(canonicalProjectRoot(missing)).toBe(canonicalProjectRoot(missing.toUpperCase()));
},
);
});
+34
View File
@@ -209,6 +209,40 @@ export function statInode(p: string): string | null {
}
}
/**
* Canonicalize a project root for IDENTITY: hashing it into a rendezvous key
* (the daemon's named pipe / tmpdir socket, a registry record). Not for display
* — the caller-visible root keeps its own spelling.
*
* The contract is that two spellings of ONE directory yield ONE string, because
* a client that derives a different key silently fails to meet the daemon that
* is already running: it probes a socket nobody bound, spawns a redundant
* daemon, and that daemon dies on the lock the first one holds, so the session
* degrades to a single-process engine. `path.resolve` alone does NOT satisfy
* that contract — NTFS is case-insensitive, so `d:\work\codegraph` and
* `D:\work\codegraph` name one directory but two strings, and non-native
* `fs.realpathSync` keeps whichever casing the caller passed (only `.native`
* asks the filesystem for the on-disk name — the same reason
* {@link isSameIndexRoot} uses it). Both spellings really do occur: a
* cwd-derived root is the on-disk case, while a client-supplied
* `rootUri`/`workspaceFolders` path arrives as `file:///d%3A/…`.
*
* The Windows lowercase is belt-and-braces on top of the native realpath — NTFS
* can't hold two directories differing only by case, and it also absorbs a
* `\\?\`-prefixed native result diverging from a plain one.
*/
export function canonicalProjectRoot(projectRoot: string): string {
const resolved = path.resolve(projectRoot);
let canonical = resolved;
try {
canonical = fs.realpathSync.native(resolved);
} catch {
// ENOENT/EACCES/ELOOP — the root is normally there (`.codegraph/` lives in
// it), so this is a fallback rather than a path we expect to take.
}
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
}
/**
* Whether two resolved index roots are one index spelled two ways — a symlinked
* checkout, or a case-variant on a case-insensitive mount (macOS, NTFS, WSL
+9 -3
View File
@@ -32,14 +32,20 @@ import * as crypto from 'crypto';
import * as net from 'net';
import * as os from 'os';
import * as path from 'path';
import { getCodeGraphDir } from '../directory';
import { canonicalProjectRoot, getCodeGraphDir } from '../directory';
/** Soft upper bound for in-project socket paths. */
const POSIX_SOCKET_PATH_LIMIT = 100;
/** Short stable identifier for a project root — used in tmpdir/pipe names. */
/**
* Short stable identifier for a project root — used in tmpdir/pipe names.
*
* Hashed over {@link canonicalProjectRoot}, never a raw `path.resolve`: the key
* is a rendezvous, so every spelling of one directory must land on one name or
* a proxy probes a pipe the running daemon never bound (see that function).
*/
function projectHash(projectRoot: string): string {
return crypto.createHash('sha256').update(path.resolve(projectRoot)).digest('hex').slice(0, 16);
return crypto.createHash('sha256').update(canonicalProjectRoot(projectRoot)).digest('hex').slice(0, 16);
}
/**
+8 -1
View File
@@ -22,6 +22,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as crypto from 'crypto';
import { canonicalProjectRoot } from '../directory';
import {
getDaemonPidPath,
getDaemonSocketCandidates,
@@ -50,8 +51,14 @@ export function getRegistryDir(): string {
return path.join(os.homedir(), '.codegraph', 'daemons');
}
/**
* One record per project, so it is keyed the same way the daemon socket is:
* over {@link canonicalProjectRoot}, not a raw `path.resolve` — otherwise the
* same project spelled with another drive-letter case files two records, and
* `list` over-lists while `stop --all` misses one.
*/
function recordPath(root: string): string {
const hash = crypto.createHash('sha256').update(path.resolve(root)).digest('hex').slice(0, 16);
const hash = crypto.createHash('sha256').update(canonicalProjectRoot(root)).digest('hex').slice(0, 16);
return path.join(getRegistryDir(), `${hash}.json`);
}