On a very large repo (the report is a ~93k-file / 5.7GB-DB Java monorepo) the first MCP `tools/call` after a fresh `serve --mcp` could hang for 10+ minutes with zero output, and with the liveness watchdog on, the daemon was SIGKILLed mid-query instead. Root cause: the post-open catch-up reconcile that the first tool call is gated on does ~2*N synchronous `fs.existsSync`/`fs.statSync` calls plus a load-all-files query in two non-yielding loops. On a huge repo that wedges the event loop for minutes, which (a) trips the 60s watchdog (it SIGKILLs a process whose loop stops turning) and (b) blocks the first call the whole time. Two complementary fixes: - Make the reconcile yield. `ExtractionOrchestrator.sync()` now uses the yielding `scanDirectoryAsync`, and both O(files) reconcile loops `await setImmediate` every SYNC_RECONCILE_YIELD_INTERVAL (1000) files. The loop can no longer wedge the main thread, so the watchdog stays fed and the socket / any concurrent read stays responsive while a big reconcile runs. Results are unchanged — only yield points are added. - Time-box the catch-up gate. The first `tools/call` now waits on the reconcile for at most CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS (default 3000ms), then serves and lets the reconcile finish in the background (which now yields, so the served call runs concurrently). `=0` restores the old unbounded wait. On a normal repo the reconcile finishes well under the budget, so behavior is unchanged. Tests: adds two time-box cases to mcp-catchup-gate (serves promptly when the reconcile runs long; `=0` restores the unbounded wait). Full suite green (1655 passed). Validated end-to-end through the real daemon: first call returns at the ~3s time-box instead of waiting an injected 8s reconcile; no-delay control unchanged; `=0` opt-out waits the full reconcile. 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
2010c2d2b5
commit
ace8d8a0d0
@@ -110,6 +110,57 @@ describe('MCP catch-up gate', () => {
|
||||
expect(cg.getStats().fileCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not hang the first call when catch-up runs past the timeout (#905)', async () => {
|
||||
// The issue #905 hang: on a huge repo the post-open reconcile takes minutes,
|
||||
// and gating the first tool call on all of it reads as a multi-minute hang.
|
||||
// With the time-box, the call is served promptly and the reconcile finishes
|
||||
// in the background.
|
||||
const prev = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
||||
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '50';
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
let gateResolved = false;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
timer = setTimeout(() => { gateResolved = true; resolve(); }, 5000);
|
||||
});
|
||||
handler.setCatchUpGate(gate);
|
||||
|
||||
const started = Date.now();
|
||||
const res = await handler.execute('codegraph_search', { query: 'survivor' });
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
expect(res.content[0].text).toMatch(/survivor/);
|
||||
// Served on the timeout (~50ms), NOT after the 5s reconcile.
|
||||
expect(gateResolved).toBe(false);
|
||||
expect(elapsed).toBeLessThan(2000);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
||||
else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS=0 restores the unbounded wait', async () => {
|
||||
const prev = process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
||||
process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = '0';
|
||||
try {
|
||||
let gateResolved = false;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
setTimeout(() => { gateResolved = true; resolve(); }, 80);
|
||||
});
|
||||
handler.setCatchUpGate(gate);
|
||||
|
||||
const res = await handler.execute('codegraph_search', { query: 'survivor' });
|
||||
// With the time-box disabled, the call waits for the full reconcile.
|
||||
expect(gateResolved).toBe(true);
|
||||
expect(res.isError).toBeFalsy();
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS;
|
||||
else process.env.CODEGRAPH_CATCHUP_GATE_TIMEOUT_MS = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('gate that rejects does not break the tool call', async () => {
|
||||
// A catch-up sync failure (lock contention, transient FS error) must
|
||||
// not poison tool dispatch — the engine logs it, the handler proceeds.
|
||||
|
||||
Reference in New Issue
Block a user