Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions __tests__/mcp-project-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, expect, it, vi } from 'vitest';
import CodeGraph from '../src/index';
import { ToolHandler, MAX_CACHED_PROJECTS, __setLoadCodeGraphForTests } from '../src/mcp/tools';

const roots: string[] = [];
let handler: ToolHandler | null = null;

afterEach(async () => {
await handler?.closeAll();
handler = null;
__setLoadCodeGraphForTests(null);
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});

it('trims idle projects while another project call remains active', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-project-cache-'));
roots.push(root);
__setLoadCodeGraphForTests(CodeGraph);
handler = new ToolHandler(null);
const cache = handler as unknown as {
projectCache: Map<string, CodeGraph>;
getCodeGraph(projectPath: string): CodeGraph;
trimProjects(): void;
};
const first = path.join(root, 'project-0');
fs.mkdirSync(first);
CodeGraph.initSync(first).close();
const oldest = cache.getCodeGraph(first);
let finish!: (result: { content: [{ type: 'text'; text: string }] }) => void;
const pending = new Promise<{ content: [{ type: 'text'; text: string }] }>(resolve => { finish = resolve; });
vi.spyOn(cache as unknown as { dispatchTool(): typeof pending }, 'dispatchTool')
.mockReturnValue(pending);
const alias = path.join(root, 'active-alias');
if (process.platform !== 'win32') fs.symlinkSync(first, alias, 'dir');
const activeCall = handler.executeReadTool('codegraph_search', {
projectPath: process.platform === 'win32' ? first : alias,
});
for (let i = 1; i <= MAX_CACHED_PROJECTS; i++) {
const project = path.join(root, `project-${i}`);
fs.mkdirSync(project);
CodeGraph.initSync(project).close();
cache.getCodeGraph(project);
}
const close = vi.spyOn(oldest!, 'close');
cache.trimProjects();
expect(cache.projectCache.size).toBe(MAX_CACHED_PROJECTS);
expect(close).not.toHaveBeenCalled();

const extra = path.join(root, 'project-extra');
fs.mkdirSync(extra);
CodeGraph.initSync(extra).close();
cache.getCodeGraph(extra);
cache.trimProjects();
expect(cache.projectCache.size).toBe(MAX_CACHED_PROJECTS);
expect(close).not.toHaveBeenCalled();

finish({ content: [{ type: 'text', text: 'done' }] });
await activeCall;
const last = path.join(root, 'project-last');
fs.mkdirSync(last);
CodeGraph.initSync(last).close();
cache.getCodeGraph(last);
cache.trimProjects();
expect(close).toHaveBeenCalledOnce();
});

it('waits for a pinned read before finishing closeAll even with an empty cache', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cache-close-'));
roots.push(root);
CodeGraph.initSync(root).close();
handler = new ToolHandler(null);
let finish!: (result: { content: [{ type: 'text'; text: string }] }) => void;
const pending = new Promise<{ content: [{ type: 'text'; text: string }] }>(resolve => { finish = resolve; });
vi.spyOn(handler as unknown as { dispatchTool(): typeof pending }, 'dispatchTool').mockReturnValue(pending);
const activeCall = handler.executeReadTool('codegraph_search', { projectPath: root });
let closed = false;
const closing = handler.closeAll().then(() => { closed = true; });
await Promise.resolve();
expect(closed).toBe(false);
finish({ content: [{ type: 'text', text: 'done' }] });
await activeCall;
await closing;
expect(closed).toBe(true);
});
33 changes: 28 additions & 5 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1937,6 +1937,7 @@ export class ToolHandler {
// agent instead of "no project loaded" — today only the Windows/WSL
// shared-index error (#995). Engine-maintained; cleared by a successful open.
private defaultOpenFailure: WslSharedIndexError | null = null;
private activeProjectRoots = new Map<string, number>();
// Per-start-path cache of the git worktree/index mismatch (issue #155). The
// mismatch is a fixed property of (where the request came from → which
// .codegraph/ it resolves to), so the up-to-two `git rev-parse` spawns run
Expand Down Expand Up @@ -2376,19 +2377,34 @@ export class ToolHandler {
await this.awaitCatchUpGate(gate);
}

private pinProject(projectPath: unknown): string | null {
const resolved = typeof projectPath === 'string' ? findNearestCodeGraphRoot(projectPath) : null;
if (!resolved) return null;
const root = canonicalPath(resolved);
this.activeProjectRoots.set(root, (this.activeProjectRoots.get(root) ?? 0) + 1);
return root;
}

private unpinProject(root: string | null): void {
if (!root) return;
const count = this.activeProjectRoots.get(root)!;
if (count === 1) this.activeProjectRoots.delete(root);
else this.activeProjectRoots.set(root, count - 1);
}

/**
* Never evict a graph while a tool call or its timed-out reconcile uses it.
* Evicts over the LRU bound, on close, and once idle past the timeout
* (#2087). The cache is in last-use order, so idle entries lead it.
*/
private trimProjects(): void {
if (this.activeCalls > 0) return;
if (this.activeCalls > 0 && (this.closing || this.activeProjectRoots.size === 0)) return;
const idleMs = resolveProjectIdleTimeoutMs();
const now = Date.now();
for (const [root, cg] of this.projectCache) {
const idle = idleMs > 0 && now - (this.projectUsedAt.get(root) ?? now) >= idleMs;
if (!this.closing && this.projectCache.size <= MAX_CACHED_PROJECTS && !idle) break;
if (this.projectGates.has(cg)) continue;
if (this.projectGates.has(cg) || [...this.activeProjectRoots.keys()].some(active => isSameIndexRoot(root, active))) continue;
this.projectCache.delete(root);
this.projectUsedAt.delete(root);
if (this.projectLifecycle) {
Expand All @@ -2399,7 +2415,7 @@ export class ToolHandler {
});
} else cg.close();
}
if (this.closing && this.projectCache.size === 0 && this.pendingCloses === 0) {
if (this.closing && this.projectCache.size === 0 && this.activeCalls === 0 && this.activeProjectRoots.size === 0 && this.pendingCloses === 0) {
for (const resolve of this.closeWaiters.splice(0)) resolve();
}
this.scheduleIdleRelease(idleMs);
Expand All @@ -2413,7 +2429,7 @@ export class ToolHandler {
private scheduleIdleRelease(idleMs: number): void {
if (this.idleReleaseTimer || this.closing || idleMs <= 0) return;
for (const [root, cg] of this.projectCache) {
if (this.projectGates.has(cg)) continue;
if (this.projectGates.has(cg) || [...this.activeProjectRoots.keys()].some(active => isSameIndexRoot(root, active))) continue;
const due = (this.projectUsedAt.get(root) ?? Date.now()) + idleMs - Date.now();
this.idleReleaseTimer = setTimeout(() => {
this.idleReleaseTimer = null;
Expand Down Expand Up @@ -2460,7 +2476,7 @@ export class ToolHandler {
this.idleReleaseTimer = null;
this.nestedRepoCache.clear();
this.trimProjects();
if (this.projectCache.size === 0 && this.activeCalls === 0 && this.pendingCloses === 0) return Promise.resolve();
if (this.projectCache.size === 0 && this.activeCalls === 0 && this.activeProjectRoots.size === 0 && this.pendingCloses === 0) return Promise.resolve();
return new Promise((resolve) => this.closeWaiters.push(resolve));
}

Expand Down Expand Up @@ -2761,6 +2777,7 @@ export class ToolHandler {
): Promise<ToolResult> {
if (this.closing) return this.textResult('This MCP session is closing; retry with a connected session.');
this.activeCalls++;
let pinnedRoot: string | null = null;
try {
// Block the first tool call on the engine's post-open reconcile so we
// never serve rows for files deleted/edited while no MCP server was
Expand All @@ -2787,6 +2804,7 @@ export class ToolHandler {
if (typeof pathCheck === 'object' && pathCheck !== undefined) {
return pathCheck;
}
pinnedRoot = this.pinProject(pathCheck);
// An explicit project gets the same first-call guarantee as the default
// (#1835): its post-open catch-up sync finishes (time-boxed) before we
// serve it. Resolved on the main thread so the watcher lives here even
Expand Down Expand Up @@ -2905,6 +2923,7 @@ export class ToolHandler {
'continue without codegraph for this task.'
);
} finally {
this.unpinProject(pinnedRoot);
this.activeCalls--;
this.trimProjects();
}
Expand Down Expand Up @@ -2975,6 +2994,7 @@ export class ToolHandler {
* path validation already ran in {@link execute} before routing here.
*/
async executeReadTool(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
const pinnedRoot = this.pinProject(args.projectPath);
try {
return await this.dispatchTool(toolName, args);
} catch (err) {
Expand All @@ -2992,6 +3012,9 @@ export class ToolHandler {
'This is an internal codegraph error — retry the call once; if it persists, ' +
'continue without codegraph for this task.'
);
} finally {
this.unpinProject(pinnedRoot);
this.trimProjects();
}
}

Expand Down