feat(mcp): off-load read-tool dispatch to a worker pool to fix concurrent-call timeouts (#1002)
The shared daemon served every session on one event loop with synchronous node:sqlite. codegraph_explore is CPU-bound work stitched together by microtask awaits, so N concurrent explores keep the microtask queue continuously full and starve the macrotask phases — timers AND socket I/O. The transport freezes: no response can flush until the whole batch drains, so with ~10 subagents on a large repo clients routinely time out (reported via X by @symbolic2020). Move the heavy read-tool dispatch onto a worker-thread pool. Each worker holds its own WAL read connection (verified: a worker reader sees the main writer's committed catch-up/watcher writes); the single watcher/writer, the catch-up gate, codegraph_status, and the staleness/worktree notices stay on the main thread. Concurrent reads now run in true parallel up to core count and the main loop stays free for the MCP transport, so responses flush incrementally instead of all-at-once after the batch drains. Enabled for the shared daemon only; direct (single-stdio-client) mode is unchanged. - crash recovery: respawn + retry-once, with a circuit breaker that falls back to in-process dispatch if workers can't run on this platform - graceful backstop: an overloaded pool returns success-shaped "busy, retry" guidance, never isError (so it can't teach the agent to abandon codegraph) - pending-aware growth + capped concurrent cold-starts avoid a startup thundering herd (N simultaneous module-loads + DB opens could stall the loop) - config: CODEGRAPH_QUERY_POOL_SIZE (default clamp(cores-1, 1, 16); 0 disables → in-process), CODEGRAPH_QUERY_BUSY_TIMEOUT_MS (default 45s) 10 concurrent explores on vscode (10.5k files): 31s → ~9s, staggered flush, 0 timeouts, byte-identical output; scales with cores (≈3.3× on 8, 1.8× on 2). Full suite passes plus 10 new query-pool tests (fake-worker injection so the scheduling logic is covered without spawning threads). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4077ed19b7
commit
dfe13b03c8
+48
-1
@@ -10,10 +10,12 @@
|
||||
* inotify watch set — that's the entire point of issue #411.
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import type CodeGraph from '../index';
|
||||
import { findNearestCodeGraphRoot } from '../directory';
|
||||
import { watchDisabledReason } from '../sync';
|
||||
import { ToolHandler } from './tools';
|
||||
import { QueryPool, resolvePoolSize } from './query-pool';
|
||||
|
||||
// Lazy-load the heavy CodeGraph chain (sqlite + query/graph/context layers) OFF
|
||||
// the MCP startup path. It's only needed once a tool actually opens a project —
|
||||
@@ -31,6 +33,15 @@ export interface MCPEngineOptions {
|
||||
* cheap. Honors {@link watchDisabledReason} regardless.
|
||||
*/
|
||||
watch?: boolean;
|
||||
/**
|
||||
* Whether to off-load read-tool dispatch to a worker-thread pool. Only the
|
||||
* SHARED daemon wants this — it serves many concurrent clients on one event
|
||||
* loop, so without a pool concurrent explores serialize and starve the MCP
|
||||
* transport. Direct mode (one stdio client, no concurrency) leaves it off so a
|
||||
* single call never pays a worker round-trip. `CODEGRAPH_QUERY_POOL_SIZE=0`
|
||||
* disables it even in daemon mode.
|
||||
*/
|
||||
queryPool?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,12 +62,39 @@ export class MCPEngine {
|
||||
private watcherStarted = false;
|
||||
private opts: Required<MCPEngineOptions>;
|
||||
private closed = false;
|
||||
// Off-loop read-tool pool (daemon mode only). Created lazily once the default
|
||||
// project is open — workers each hold their own WAL read connection.
|
||||
private queryPool: QueryPool | null = null;
|
||||
|
||||
constructor(opts: MCPEngineOptions = {}) {
|
||||
this.opts = { watch: opts.watch ?? true };
|
||||
this.opts = { watch: opts.watch ?? true, queryPool: opts.queryPool ?? false };
|
||||
this.toolHandler = new ToolHandler(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the worker-thread query pool once a default project is open (daemon
|
||||
* mode only; honors `CODEGRAPH_QUERY_POOL_SIZE`). Idempotent and best-effort:
|
||||
* if workers can't spawn on this platform the ToolHandler keeps serving reads
|
||||
* in-process, so the pool can only help, never break, tool calls.
|
||||
*/
|
||||
private maybeStartPool(root: string): void {
|
||||
if (!this.opts.queryPool || this.queryPool || this.closed) return;
|
||||
const size = resolvePoolSize(process.env.CODEGRAPH_QUERY_POOL_SIZE, os.cpus().length);
|
||||
if (size <= 0) {
|
||||
process.stderr.write('[CodeGraph MCP] Query pool disabled (CODEGRAPH_QUERY_POOL_SIZE=0); serving reads in-process.\n');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.queryPool = new QueryPool({ root, size });
|
||||
this.toolHandler.setQueryPool(this.queryPool);
|
||||
process.stderr.write(`[CodeGraph MCP] Query pool: up to ${size} worker thread(s) for concurrent reads.\n`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[CodeGraph MCP] Query pool unavailable (${msg}); serving reads in-process.\n`);
|
||||
this.queryPool = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for {@link MCPServer} compatibility: pre-seed an explicit
|
||||
* project path (from the `--path` CLI flag) without yet opening it. This
|
||||
@@ -133,6 +171,7 @@ export class MCPEngine {
|
||||
this.toolHandler.setDefaultCodeGraph(this.cg);
|
||||
this.startWatching();
|
||||
this.catchUpSync();
|
||||
this.maybeStartPool(resolvedRoot);
|
||||
} catch {
|
||||
// Still failing — caller will try again on the next tool call.
|
||||
}
|
||||
@@ -145,6 +184,13 @@ export class MCPEngine {
|
||||
stop(): void {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
// Detach + terminate the worker pool first so no tool call routes to a
|
||||
// worker mid-teardown; outstanding pool calls resolve with graceful guidance.
|
||||
this.toolHandler.setQueryPool(null);
|
||||
if (this.queryPool) {
|
||||
void this.queryPool.destroy();
|
||||
this.queryPool = null;
|
||||
}
|
||||
this.toolHandler.closeAll();
|
||||
if (this.cg) {
|
||||
try { this.cg.close(); } catch { /* ignore */ }
|
||||
@@ -168,6 +214,7 @@ export class MCPEngine {
|
||||
this.toolHandler.setDefaultCodeGraph(this.cg);
|
||||
this.startWatching();
|
||||
this.catchUpSync();
|
||||
this.maybeStartPool(resolvedRoot);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
process.stderr.write(`[CodeGraph MCP] Failed to open project at ${resolvedRoot}: ${msg}\n`);
|
||||
|
||||
Reference in New Issue
Block a user