mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 13:53:01 +08:00
* 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.
(cherry picked from commit 1811788018)
* fix(mcp): share one in-process project however its root is cased (#2278)
The engine's project lifecycle and `codegraph stop`'s registry fallback keyed
a project by its realpath, which keeps the caller's casing, so two spellings
of one root opened two graphs and two watchers in one process. Both now use
the same identity key as the daemon socket.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 0a3239e603d5796b9f2cf636b4c09635239e32dd)
* docs(changelog): file the Windows rendezvous fix under Unreleased
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 养心堂主 <zhouzhk@qq.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -171,6 +171,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In Python, an import split over several lines in parentheses, `from x import (A, B)` written one name per line, is now read in full. Before, its names were invisible to resolution, so Django REST Framework's `serializers.CharField()` and netbox's model mixins linked to the wrong class or to nothing. Thanks @JosefAschauer.
|
||||
- Indexing a large C or C++ project no longer runs out of memory partway through "Resolving refs". Headers whose include guard is written as `#define X_H 1`, as in OpenSceneGraph and osgEarth, or that are included under a build flag codegraph cannot know, used to be re-read on every include path, which grows exponentially with the depth of the include tree. Each is now read once per include state, and a hard limit stops the check on any include tree that is still too large. Thanks @danusha2345, and @coolhitmanleon for the report. (#2127)
|
||||
- In tag-based CFML, calls written in tags are now linked: in `<cfset>`, `<cfif>`/`<cfelseif>`, `<cfreturn>`, `<cfloop condition>` and `#…#` expressions. Before, only `<cfscript>` and `<cfquery>` code was read, so callers and impact found almost nothing in tag-based components. Functions wrapped in tags like `<cfprocessingdirective>` or `<cfsilent>` are now indexed too, and a `<cfinterface>` is indexed as an interface that `implements` links to. Re-index CFML projects after upgrading. Thanks @HarryMuc for the report. (#2091)
|
||||
- On Windows, a project opened with a different drive-letter or folder-name case, like `d:\work\app` and `D:\Work\App`, now joins the CodeGraph background server that is already running for it. Before, each spelling started a server of its own that exited at once, so the session quietly served the graph by itself with no shared file watching or auto-sync, and `codegraph list` showed the project twice. Thanks @greatjackzhou. (#2278)
|
||||
|
||||
## [1.6.1] - 2026-09-29
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 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';
|
||||
import { acquireProject } from '../src/mcp/project-lifecycle';
|
||||
import type CodeGraph from '../src/index';
|
||||
|
||||
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()));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** True when the temp filesystem ignores case (default macOS APFS, NTFS). */
|
||||
function caseInsensitiveTmp(): boolean {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-CaseProbe-'));
|
||||
try {
|
||||
return fs.existsSync(dir.replace('cg-CaseProbe-', 'cg-caseprobe-'));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('in-process project sharing (#2278)', () => {
|
||||
it.runIf(caseInsensitiveTmp())('opens one graph for two casings of one root', async () => {
|
||||
const root = path.join(makeDir(), 'Repo');
|
||||
fs.mkdirSync(root);
|
||||
let opened = 0;
|
||||
const open = () => {
|
||||
opened++;
|
||||
return { close() {}, isIndexing: () => false } as unknown as CodeGraph;
|
||||
};
|
||||
const a = acquireProject(root, open, {});
|
||||
const b = acquireProject(path.join(path.dirname(root), 'REPO'), open, {});
|
||||
try {
|
||||
expect(opened).toBe(1);
|
||||
expect(b.cg).toBe(a.cg);
|
||||
} finally {
|
||||
await a.release();
|
||||
await b.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -303,6 +303,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -242,7 +249,7 @@ export async function stopDaemonAt(root: string, options: { preserveUnverified?:
|
||||
}
|
||||
if (pid == null) {
|
||||
const rec = listDaemons({ prune: false }).find(
|
||||
(r) => path.resolve(r.root) === path.resolve(root)
|
||||
(r) => canonicalProjectRoot(r.root) === canonicalProjectRoot(root)
|
||||
);
|
||||
pid = rec?.pid ?? null;
|
||||
if (rec) identity = rec;
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
import { realpathSync } from 'fs';
|
||||
import type { Socket } from 'net';
|
||||
import type CodeGraph from '../index';
|
||||
import { isInitialized } from '../directory';
|
||||
import { canonicalProjectRoot, isInitialized } from '../directory';
|
||||
import { LockUnavailableError, watchDisabledReason } from '../sync';
|
||||
import { getDaemonSocketCandidates } from './daemon-paths';
|
||||
import { connectWithHello } from './proxy';
|
||||
import { markWriterReady, readWriterLock, releaseWriterLock, tryAcquireWriterLock } from './writer-lock';
|
||||
|
||||
interface Project {
|
||||
/** Identity key: one entry however the root is spelled (#2278). */
|
||||
key: string;
|
||||
cg: CodeGraph;
|
||||
refs: number;
|
||||
owner: boolean;
|
||||
@@ -35,13 +37,14 @@ export function acquireProject(
|
||||
options: Parameters<CodeGraph['watch']>[0],
|
||||
): ProjectLease {
|
||||
root = realpathSync(root);
|
||||
let project = projects.get(root);
|
||||
const key = canonicalProjectRoot(root);
|
||||
let project = projects.get(key);
|
||||
if (!project) {
|
||||
const cg = open();
|
||||
project = { cg, refs: 0, owner: false, caughtUp: false, retirement: null, gate: null, socket: null, options,
|
||||
project = { key, cg, refs: 0, owner: false, caughtUp: false, retirement: null, gate: null, socket: null, options,
|
||||
timer: setInterval(() => { void ready(root, project!); }, 1000) };
|
||||
project.timer.unref();
|
||||
projects.set(root, project);
|
||||
projects.set(key, project);
|
||||
}
|
||||
const entry = project;
|
||||
entry.refs++;
|
||||
@@ -80,7 +83,7 @@ function retire(root: string, project: Project): Promise<void> {
|
||||
setTimeout(finish, 25);
|
||||
return;
|
||||
}
|
||||
projects.delete(root);
|
||||
projects.delete(project.key);
|
||||
project.cg.close();
|
||||
if (project.owner) releaseWriterLock(root);
|
||||
resolve();
|
||||
|
||||
Reference in New Issue
Block a user