mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-04 08:45:36 +08:00
fix(mcp): release an idle explicit project and its writer lock (#2087)
A daemon that answered a `projectPath` query for another indexed project opened it in-process and, with no owner there, took its writer lock. The project cache only evicted over its LRU bound or on shutdown, so the lock stayed held for the daemon's whole life: the project's own daemon could not become its writer and `codegraph index` there was refused. Release a cached explicit project once it has gone unused for 10 minutes (CODEGRAPH_PROJECT_IDLE_TIMEOUT_MS; 0 never releases), through the same trim path as LRU eviction, so an active call or a running catch-up still defers it. The next call reopens the project and catches it up (#1835). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1f64e15886
commit
092d49d3db
+56
-2
@@ -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
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2269,6 +2291,7 @@ 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);
|
||||
}
|
||||
}
|
||||
@@ -2276,6 +2299,7 @@ export class ToolHandler {
|
||||
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;
|
||||
}
|
||||
@@ -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(() => {
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user