fix(indexing): bounded-memory yielding pipeline tail + daemon session fixes (#1212) (#1226)

Large-codebase indexing died at the end of "Resolving refs" two ways:
watchdog kills of healthy work (24k-file Java on Windows, #1212 — third
iteration of the #1091/#1122 class) and hard OOMs (Linux kernel scale,
where v1.3.0 could not complete at any watchdog setting). Root causes:
~31 of 37 dynamic-edge synthesis passes ran start-to-finish with no
yield points, several materialized whole-graph snapshots (kotlin
expect/actual opened with getAllNodes() — 2M nodes in one array; the
C fn-pointer pass retained every C file's contents twice plus every
function node), and the post-index WAL checkpoint ran minutes of
synchronous IO on the main thread, killing even a successful index at
the finish line.

The pipeline tail now follows the same discipline as the rest: never
hold O(graph) in the heap, yield everywhere.

- All synthesis passes stream node-kind scans (cursors, not arrays) and
  yield on time-budgeted checkpoints; language gates skip passes whose
  filters a project's file languages provably can't satisfy.
- kotlin expect/actual filters SQL-side; c-fnptr caches are LRU-bounded,
  units stream one file at a time, and the all-functions array +
  write-only id map are gone; spring reads each .java once, not twice.
- runMaintenance moved to a worker thread (own SQLite connection);
  per-file store commits chunk with yields behind a serialized flush
  chain (preserving #1015 file-order determinism); resolver warm-up
  streams the DISTINCT name set; resolution batch-tail and merged-edge
  inserts run in bounded sub-transactions.
- Daemon: fixed a socket-handoff race that could leave a fresh MCP
  session permanently silent (client-hello tail unshifted into a
  flowing stream with zero listeners — the long-standing #662 test
  flake was this real bug); first tool call no longer queues behind
  the query pool's cold start (pool.ready gate).

Validation: Linux kernel (70,129 files, 2.05M nodes, 6.4M edges) fully
indexes in 27m8s on a 2-core/6GB container at default heap + default
watchdog; llvm-project (180k files) completes under 1GB RSS including
kill-and-sync recovery; synthesized-edge and full-graph parity are
byte-identical vs baseline on elasticsearch/redis/vim; the ex-flaky
daemon test passed 25/25 under load. Env-gated diagnostics kept:
CODEGRAPH_SYNTH_TIMINGS pass/phase timings, CODEGRAPH_MCP_DEBUG hop
tracing. Design record: docs/design/main-thread-stall-followup.md.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-08 23:18:23 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 58b6bf5c60
commit a3f90089e8
21 changed files with 1036 additions and 264 deletions
+23 -6
View File
@@ -120,6 +120,7 @@ function waitFor<T>(
predicate: () => T | undefined | null | false,
timeoutMs: number,
pollMs = 25,
label = '',
): Promise<T> {
return new Promise((resolve, reject) => {
const started = Date.now();
@@ -127,7 +128,12 @@ function waitFor<T>(
let v: T | undefined | null | false;
try { v = predicate(); } catch (e) { return reject(e); }
if (v) return resolve(v as T);
if (Date.now() - started > timeoutMs) return reject(new Error(`Timed out after ${timeoutMs}ms`));
if (Date.now() - started > timeoutMs) {
// Name the wait: an async stack loses the await site, so an unlabeled
// timeout can't tell WHICH step flaked (the #662 test's recurring
// timeout was undiagnosable for exactly this reason).
return reject(new Error(`Timed out after ${timeoutMs}ms${label ? ` waiting for: ${label}` : ''}`));
}
setTimeout(tick, pollMs);
};
tick();
@@ -419,14 +425,25 @@ describe('Shared MCP daemon (issue #411)', () => {
const server = spawnServer(tempDir, env);
servers.push(server);
sendInitialize(server.child, `file://${tempDir}`, 1);
await waitFor(() => findResponse(server.stdout, 1), 10000);
await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 8000);
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000);
await waitFor(() => findResponse(server.stdout, 1), 20000, 25, 'initialize response');
await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 8000, 25, 'daemon attach log');
await waitFor(() => (readLockPid(realRoot) ?? 0) > 0, 8000, 25, 'daemon pidfile');
const daemonPid = readLockPid(realRoot)!;
// A warm call goes through the daemon.
sendMessage(server.child, { jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
await waitFor(() => findResponse(server.stdout, 2), 10000);
try {
await waitFor(() => findResponse(server.stdout, 2), 30000, 25, 'warm tools/call via daemon');
} catch (e) {
// This is the wait that historically flaked — surface WHERE the request
// died: proxy side (stderr) or daemon side (daemon.log).
let daemonLog = '<no daemon.log>';
try { daemonLog = fs.readFileSync(path.join(realRoot, '.codegraph', 'daemon.log'), 'utf8').split('\n').slice(-25).join('\n'); } catch { /* absent */ }
throw new Error(
`${(e as Error).message}\ndaemonAlive=${isAlive(daemonPid)} proxyAlive=${isAlive(server.child.pid!)}\n` +
`--- proxy stderr tail ---\n${server.stderr.slice(-15).join('')}\n--- daemon.log tail ---\n${daemonLog}`
);
}
// Kill the daemon out from under the live proxy.
process.kill(daemonPid, 'SIGTERM');
@@ -434,7 +451,7 @@ describe('Shared MCP daemon (issue #411)', () => {
// The proxy must still be alive and still answer — served in-process now.
expect(isAlive(server.child.pid!)).toBe(true);
await waitFor(() => server.stderr.some((l) => l.includes('serving this session in-process')), 8000);
await waitFor(() => server.stderr.some((l) => l.includes('serving this session in-process')), 8000, 25, 'in-process failover log');
sendMessage(server.child, { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
const resp = await waitFor(() => findResponse(server.stdout, 3), 15000);
expect(resp.result !== undefined || resp.error !== undefined).toBe(true);