Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A PHP call written without a receiver, such as `redirect($url)`, `view('books.show')`, `auth()` or `basename($path)`, is a function call, and no longer links to a same-named method, field or class elsewhere in the project. These wrong links showed up in callers, impact and `codegraph_explore` answers wherever a Laravel helper or PHP built-in shared its name with a project member. Re-index PHP projects after upgrading.
- The Claude Code prompt hook no longer runs on the messages Claude Code uses to hand a subagent's report back to the main session. Before, such a long report could keep the hook busy past Claude Code's 30-second hook timeout and inject context unrelated to what you asked. Thanks @danusha2345, and @tippmar-nr for the report. (#2184)
- Newer MCP clients such as Antigravity 2.5 connect again: the server now answers their `server/discover` probe right away with "method not found", so they go straight on to the regular handshake instead of waiting on a reply that could take seconds or never come. Thanks @danusha2345, and @samem26 for the report. (#2084)
- A CodeGraph session that queried another project through `projectPath` no longer keeps that project locked for as long as it runs: after 10 minutes without a query it lets the project go, so the project's own session and `codegraph index` can take over again (tune with `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS`, `0` keeps it open). Thanks @danusha2345, and @bompus for the report. (#2087)

## [1.6.1] - 2026-09-29

Expand Down
55 changes: 55 additions & 0 deletions __tests__/mcp-projectpath-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,61 @@ describe('MCP explicit projectPath lifecycle (#1835)', { timeout: 30_000 }, () =
}, 60_000);


// A daemon that answered one projectPath query for another project must not
// keep that project's writer lock until it exits: the project's own daemon
// and `codegraph index` there would stay locked out (#2087).
it('releases an idle explicit project and its writer lock, and retakes it on the next call', async () => {
const prev = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS;
process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = '200';
const lock = path.join(serviceB, '.codegraph/writer.pid');
try {
expect(await search(serviceB, 'betaOriginal')).toContain('betaOriginal');
expect(JSON.parse(fs.readFileSync(lock, 'utf8')).pid).toBe(process.pid);
expect(opened[0]!.isWatching()).toBe(true);

expect(await waitFor(async () => !fs.existsSync(lock), 10000)).toBe(true);
expect(() => opened[0]!.getStats()).toThrow();

// Still synchronized while in use: the next call reopens and catches up.
fs.writeFileSync(path.join(serviceB, 'src/sample.ts'), 'export function afterIdleRelease() {}\n');
expect(await search(serviceB, 'afterIdleRelease')).toContain('afterIdleRelease');
expect(opened).toHaveLength(2);
expect(opened[1]!.isWatching()).toBe(true);
expect(JSON.parse(fs.readFileSync(lock, 'utf8')).pid).toBe(process.pid);
} finally {
if (prev === undefined) delete process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS;
else process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = prev;
}
});

it('defers an idle release until an active catch-up finishes (#2087)', async () => {
let release!: () => void;
const held = new Promise<void>((resolve) => { release = resolve; });
onOpen = (cg) => {
const sync = cg.sync.bind(cg);
vi.spyOn(cg, 'sync').mockImplementation(async (...args) => { await held; return sync(...args); });
};
const prevIdle = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS;
process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = '50';
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '10';
const lock = path.join(serviceB, '.codegraph/writer.pid');
try {
fs.writeFileSync(path.join(serviceB, 'src/sample.ts'), 'export function idleCatchUp() {}\n');
await search(serviceB, 'betaOriginal');
await new Promise((r) => setTimeout(r, 300));
expect(() => opened[0]!.getStats()).not.toThrow();
expect(fs.existsSync(lock)).toBe(true);
release();
expect(await waitFor(async () => !fs.existsSync(lock), 10000)).toBe(true);
expect(names(serviceB)).toEqual(['idleCatchUp']);
expect(() => opened[0]!.getStats()).toThrow();
} finally {
release();
if (prevIdle === undefined) delete process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS;
else process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS = prevIdle;
}
});

it('drains a tool operation before closing its cached graph', async () => {
await search(serviceA, 'alphaOriginal');
const handler = engine.getToolHandler();
Expand Down
58 changes: 56 additions & 2 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1867,6 +1867,23 @@ function canonicalPath(p: string): string {
*/
export const MAX_CACHED_PROJECTS = 8;

/**
* How long an explicit-`projectPath` project may go unused before the handler
* releases it (#2087). Releasing frees its SQLite handle, its watcher and the
* writer lock the engine may hold on that project — which otherwise stays held
* for the whole life of this daemon, locking the project's own daemon and
* `codegraph index` out. The next call reopens it and catches up.
* `CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS` overrides it; `0` never releases.
*/
const DEFAULT_PROJECT_IDLE_TIMEOUT_MS = 600_000;
function resolveProjectIdleTimeoutMs(): number {
const raw = process.env.CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS;
if (raw === undefined || raw === '') return DEFAULT_PROJECT_IDLE_TIMEOUT_MS;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return DEFAULT_PROJECT_IDLE_TIMEOUT_MS;
return Math.floor(n);
}

/**
* Engine-side lifecycle for a project the ToolHandler opened for an explicit
* `projectPath` (#1835). `activate` gives it the same treatment the default
Expand All @@ -1892,6 +1909,10 @@ export class ToolHandler {
// CANONICAL (realpath) index root. Map insertion order doubles as LRU order:
// a hit re-inserts, and `MAX_CACHED_PROJECTS` bounds the size (#1835).
private projectCache: Map<string, CodeGraph> = new Map();
// When each cached root was last handed to a call, and the one timer that
// releases the oldest once it has been idle long enough (#2087).
private projectUsedAt: Map<string, number> = new Map();
private idleReleaseTimer: NodeJS.Timeout | null = null;
// Engine hook that watches + catches up an explicit project (null for the
// CLI and worker-thread handlers, which never own a watcher).
private projectLifecycle: ProjectLifecycle | null = null;
Expand Down Expand Up @@ -2260,6 +2281,7 @@ export class ToolHandler {
// Refresh LRU position.
this.projectCache.delete(canonicalRoot);
this.projectCache.set(canonicalRoot, cached);
this.projectUsedAt.set(canonicalRoot, Date.now());
return this.freshen(cached);
}

Expand All @@ -2269,13 +2291,15 @@ export class ToolHandler {
if (isSameIndexRoot(root, resolvedRoot)) {
this.projectCache.delete(root);
this.projectCache.set(root, open);
this.projectUsedAt.set(root, Date.now());
return this.freshen(open);
}
}

const open = () => loadCodeGraph().openSync(canonicalRoot);
const cg = this.projectLifecycle?.open(canonicalRoot, open) ?? open();
this.projectCache.set(canonicalRoot, cg);
this.projectUsedAt.set(canonicalRoot, Date.now());
this.trimProjects();
return cg;
}
Expand All @@ -2295,13 +2319,21 @@ export class ToolHandler {
await this.awaitCatchUpGate(gate);
}

/** Never evict a graph while a tool call or its timed-out reconcile uses it. */
/**
* 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;
const idleMs = resolveProjectIdleTimeoutMs();
const now = Date.now();
for (const [root, cg] of this.projectCache) {
if (!this.closing && this.projectCache.size <= MAX_CACHED_PROJECTS) break;
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;
this.projectCache.delete(root);
this.projectUsedAt.delete(root);
if (this.projectLifecycle) {
this.pendingCloses++;
void Promise.resolve(this.projectLifecycle.release(cg)).finally(() => {
Expand All @@ -2313,6 +2345,26 @@ export class ToolHandler {
if (this.closing && this.projectCache.size === 0 && this.pendingCloses === 0) {
for (const resolve of this.closeWaiters.splice(0)) resolve();
}
this.scheduleIdleRelease(idleMs);
}

/**
* Arm one unref'd timer for the oldest project a trim could release. A
* project whose catch-up is still running is trimmed when that settles; the
* 1s floor keeps a project a trim must skip from re-arming in a tight loop.
*/
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;
const due = (this.projectUsedAt.get(root) ?? Date.now()) + idleMs - Date.now();
this.idleReleaseTimer = setTimeout(() => {
this.idleReleaseTimer = null;
this.trimProjects();
}, Math.min(Math.max(due, 1000), 0x7fffffff)); // setTimeout's 32-bit cap
this.idleReleaseTimer.unref();
return;
}
}

/**
Expand Down Expand Up @@ -2347,6 +2399,8 @@ export class ToolHandler {
closeAll(): Promise<void> {
this.closing = true;
this.worktreeMismatchCache.clear();
if (this.idleReleaseTimer) clearTimeout(this.idleReleaseTimer);
this.idleReleaseTimer = null;
this.trimProjects();
if (this.projectCache.size === 0 && this.activeCalls === 0 && this.pendingCloses === 0) return Promise.resolve();
return new Promise((resolve) => this.closeWaiters.push(resolve));
Expand Down