mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-02 09:45:39 +08:00
* fix(mcp): share one connection across two spellings of one root (#1057) A symlinked checkout, or a case-variant of a root on a case-insensitive mount (macOS, NTFS, WSL DrvFs), resolved to two path strings and opened two connections to the same .codegraph/codegraph.db. The connection cache stays keyed by path, so a root recreated at the same path still heals in place (#925). On a miss, an open root that is the same index NOW (both data directories stat to one dev:ino, read as bigints) is reused and the new spelling filed as its alias. Comparing live identities means a deleted root never matches, even if a new directory reuses its inode. Windows has no usable inode, so it compares the case-folded realpath.native instead. statInode moves to directory.ts so the DB layer and the cache share one reading, and closeAll closes an aliased instance once. * fix(mcp): keep root aliases out of the connection cache (#1057) A second spelling of an open root was filed in projectCache under its own key, so one CodeGraph sat under two keys. Anything that drops one key and closes its connection (a bounded cache's eviction, as proposed in #1929) then left the other key holding a closed handle: every query on that spelling, and through the alias scan on the original spelling too, failed with "database is not open" until restart. Aliases now live in their own map (spelling -> projectCache key). projectCache is back to one key per instance, and an alias whose entry is gone is dropped and re-resolved instead of served. * fix(mcp): deduplicate equivalent project paths (#1057) Equivalent project paths could open duplicate cached connections. Compare current filesystem identities using bigint inodes and native Windows paths. Revalidate aliases and pin cached connection owners to symlink targets. Preserve recreation recovery and single-owner cleanup. Co-authored-by: Eric Minish <eric.minish@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Eric Minish <eric.minish@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
8.8 KiB
TypeScript
258 lines
8.8 KiB
TypeScript
/**
|
|
* One index, two spellings (#1057).
|
|
*
|
|
* The MCP server caches its open-DB connections by resolved-root PATH STRING, so
|
|
* two spellings of one physical repo — a symlinked checkout, or a case-variant
|
|
* on a case-insensitive mount (macOS, NTFS, WSL DrvFs `/mnt/c`) — each opened a
|
|
* SEPARATE connection to the same `.codegraph/codegraph.db`. The cache stays
|
|
* path-keyed (a same-path recreate must still heal in place, #925); a miss now
|
|
* checks whether an open root is the same index as the new spelling, compared
|
|
* as it is on disk NOW, without caching an alias that could later be retargeted.
|
|
*
|
|
* The symlink case is the deterministic, filesystem-agnostic stand-in for the
|
|
* case-insensitive-mount scenario: both give two path strings for one inode.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { getCodeGraphDir, isSameIndexRoot, statInode } from '../src/directory';
|
|
import CodeGraph from '../src/index';
|
|
import { ToolHandler, __setLoadCodeGraphForTests } from '../src/mcp/tools';
|
|
|
|
const posixOnly = it.runIf(process.platform !== 'win32');
|
|
const windowsOnly = it.runIf(process.platform === 'win32');
|
|
|
|
describe('isSameIndexRoot (#1057)', () => {
|
|
let tmp: string;
|
|
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rootid-'));
|
|
});
|
|
afterEach(() => {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
function makeProject(name: string): string {
|
|
const proj = path.join(tmp, name);
|
|
fs.mkdirSync(path.join(proj, '.codegraph'), { recursive: true });
|
|
fs.writeFileSync(path.join(proj, '.codegraph', 'codegraph.db'), 'x');
|
|
return proj;
|
|
}
|
|
|
|
it('treats a directory and a symlink that points at it as one index', () => {
|
|
const real = makeProject('proj');
|
|
const link = path.join(tmp, 'projLink');
|
|
fs.symlinkSync(real, link, 'junction');
|
|
|
|
expect(path.resolve(real)).not.toBe(path.resolve(link));
|
|
expect(isSameIndexRoot(link, real)).toBe(true);
|
|
});
|
|
|
|
it('keeps distinct projects apart', () => {
|
|
expect(isSameIndexRoot(makeProject('a'), makeProject('b'))).toBe(false);
|
|
});
|
|
|
|
it('never matches a root that is gone', () => {
|
|
const real = makeProject('proj');
|
|
const gone = path.join(tmp, 'does-not-exist');
|
|
expect(isSameIndexRoot(gone, real)).toBe(false);
|
|
expect(isSameIndexRoot(real, gone)).toBe(false);
|
|
});
|
|
|
|
posixOnly('never matches a deleted root, even when its inode is handed to a new one', () => {
|
|
// Inode reuse after delete is filesystem-dependent, so force the worst case
|
|
// by comparing against what the old root WAS: once it is gone it no longer
|
|
// stats, so no later directory can claim its identity.
|
|
const old = makeProject('old');
|
|
const oldId = statInode(getCodeGraphDir(old));
|
|
fs.rmSync(old, { recursive: true, force: true });
|
|
const fresh = makeProject('fresh');
|
|
|
|
expect(oldId).not.toBeNull();
|
|
expect(isSameIndexRoot(old, fresh)).toBe(false);
|
|
});
|
|
|
|
posixOnly('reads inodes exactly, as bigints', () => {
|
|
const id = statInode(makeProject('proj'));
|
|
expect(id).toMatch(/^\d+:\d+$/);
|
|
const s = fs.statSync(path.join(tmp, 'proj'), { bigint: true });
|
|
expect(id).toBe(`${s.dev}:${s.ino}`);
|
|
});
|
|
|
|
windowsOnly('has no inode on Windows, and compares on-disk-cased real paths instead', () => {
|
|
const real = makeProject('Proj');
|
|
expect(statInode(real)).toBeNull();
|
|
expect(isSameIndexRoot(real, real.toLowerCase())).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('ToolHandler connection cache (#1057)', () => {
|
|
let tmp: string;
|
|
let handler: ToolHandler;
|
|
const graphs: CodeGraph[] = [];
|
|
|
|
beforeEach(() => {
|
|
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rootcache-'));
|
|
__setLoadCodeGraphForTests(CodeGraph);
|
|
handler = new ToolHandler(null);
|
|
});
|
|
afterEach(() => {
|
|
handler.closeAll();
|
|
__setLoadCodeGraphForTests(null);
|
|
for (const cg of graphs.splice(0)) {
|
|
try { cg.close(); } catch { /* already closed */ }
|
|
}
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
async function makeIndexed(name: string): Promise<string> {
|
|
const proj = path.join(tmp, name);
|
|
fs.mkdirSync(path.join(proj, 'src'), { recursive: true });
|
|
fs.writeFileSync(path.join(proj, 'src', 'a.ts'), 'export function a() { return 1; }\n');
|
|
const cg = CodeGraph.initSync(proj);
|
|
await cg.indexAll();
|
|
cg.close();
|
|
return proj;
|
|
}
|
|
|
|
function open(p: string): CodeGraph {
|
|
return (handler as unknown as { getCodeGraph(p: string): CodeGraph }).getCodeGraph(p);
|
|
}
|
|
|
|
it('serves a symlinked spelling from the connection already open', async () => {
|
|
const real = await makeIndexed('proj');
|
|
const link = path.join(tmp, 'projLink');
|
|
fs.symlinkSync(real, link, 'junction');
|
|
|
|
const first = open(real);
|
|
expect(open(link)).toBe(first);
|
|
// And the alias is remembered: the same spelling is served again.
|
|
expect(open(link)).toBe(first);
|
|
});
|
|
|
|
it('revalidates a previously seen alias after it is retargeted', async () => {
|
|
const a = await makeIndexed('a');
|
|
const b = await makeIndexed('b');
|
|
const link = path.join(tmp, 'link');
|
|
fs.symlinkSync(a, link, 'junction');
|
|
const first = open(a);
|
|
const second = open(b);
|
|
expect(open(link)).toBe(first);
|
|
|
|
fs.unlinkSync(link);
|
|
fs.symlinkSync(b, link, 'junction');
|
|
expect(open(link)).toBe(second);
|
|
expect(open(a)).toBe(first);
|
|
});
|
|
|
|
it('keeps the connection owner stable when the first spelling is a symlink', async () => {
|
|
const a = await makeIndexed('a');
|
|
const b = await makeIndexed('b');
|
|
const link = path.join(tmp, 'link');
|
|
fs.symlinkSync(a, link, 'junction');
|
|
const first = open(link);
|
|
const second = open(b);
|
|
expect(open(a)).toBe(first);
|
|
|
|
fs.unlinkSync(link);
|
|
fs.symlinkSync(b, link, 'junction');
|
|
expect(open(link)).toBe(second);
|
|
expect(open(a)).toBe(first);
|
|
});
|
|
|
|
posixOnly('revalidates an alias replaced by a distinct indexed directory', async () => {
|
|
const real = await makeIndexed('proj');
|
|
const link = path.join(tmp, 'link');
|
|
fs.symlinkSync(real, link, 'junction');
|
|
const first = open(real);
|
|
expect(open(link)).toBe(first);
|
|
|
|
fs.unlinkSync(link);
|
|
await makeIndexed('link');
|
|
expect(open(link)).not.toBe(first);
|
|
expect(open(real)).toBe(first);
|
|
});
|
|
|
|
it('shares the default instance with another spelling of its root', async () => {
|
|
const real = await makeIndexed('proj');
|
|
const link = path.join(tmp, 'projLink');
|
|
fs.symlinkSync(real, link, 'junction');
|
|
|
|
const def = CodeGraph.openSync(real);
|
|
graphs.push(def);
|
|
handler = new ToolHandler(def);
|
|
expect(open(link)).toBe(def);
|
|
});
|
|
|
|
it('opens distinct projects on distinct connections', async () => {
|
|
const a = await makeIndexed('a');
|
|
const b = await makeIndexed('b');
|
|
expect(open(a)).not.toBe(open(b));
|
|
});
|
|
|
|
it('closes an aliased connection once', async () => {
|
|
const real = await makeIndexed('proj');
|
|
const link = path.join(tmp, 'projLink');
|
|
fs.symlinkSync(real, link, 'junction');
|
|
|
|
const cg = open(real);
|
|
open(link);
|
|
let closes = 0;
|
|
const close = cg.close.bind(cg);
|
|
cg.close = () => { closes++; close(); };
|
|
handler.closeAll();
|
|
expect(closes).toBe(1);
|
|
});
|
|
|
|
it('never serves a connection that was evicted under another spelling', async () => {
|
|
const real = await makeIndexed('proj');
|
|
const link = path.join(tmp, 'projLink');
|
|
fs.symlinkSync(real, link, 'junction');
|
|
|
|
const first = open(real);
|
|
expect(open(link)).toBe(first);
|
|
|
|
// Evict the entry the way a bounded cache does: drop the key, close the
|
|
// connection. The other spelling must not be left holding a closed handle.
|
|
const cache = (handler as unknown as { projectCache: Map<string, CodeGraph> }).projectCache;
|
|
for (const [key, cg] of cache) {
|
|
if (cg === first) {
|
|
cache.delete(key);
|
|
break;
|
|
}
|
|
}
|
|
first.close();
|
|
|
|
const again = open(link);
|
|
expect(again).not.toBe(first);
|
|
expect(() => again.getStats()).not.toThrow();
|
|
expect(open(real)).toBe(again);
|
|
});
|
|
|
|
posixOnly('still heals a root recreated at the same path in place (#925)', async () => {
|
|
const real = await makeIndexed('proj');
|
|
const first = open(real);
|
|
|
|
fs.rmSync(getCodeGraphDir(real), { recursive: true, force: true });
|
|
const cg = CodeGraph.initSync(real);
|
|
await cg.indexAll();
|
|
cg.close();
|
|
|
|
// Same path, same cached instance — reopened onto the new index, not a
|
|
// second connection beside a leaked one.
|
|
const again = open(real);
|
|
expect(again).toBe(first);
|
|
expect(again.searchNodes('a').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
posixOnly('never hands a new root the connection of a deleted one', async () => {
|
|
const old = await makeIndexed('old');
|
|
const oldCg = open(old);
|
|
fs.rmSync(old, { recursive: true, force: true });
|
|
|
|
const fresh = await makeIndexed('fresh');
|
|
expect(open(fresh)).not.toBe(oldCg);
|
|
});
|
|
});
|