Status: IMPLEMENTED (same branch as the #1212 tail fix — attribution runs promoted "suspects" to proven culprits fast enough to justify shipping together). What landed, per suspect:
init on the FIXED tail completed every
synthesis pass (cFnPtr alone ran 433s at default heap, yielding throughout)
and was then SIGKILLed by the default-window watchdog at
db.runMaintenance(): PRAGMA optimize + wal_checkpoint(PASSIVE) over a
4.2GB DB with a 593MB WAL is minutes of synchronous IO on 2 cores.
runMaintenance now runs on a worker thread with its own connection
(checkpointing from a second connection is standard; PRAGMA optimize
persists stats in sqlite_stat tables), with a bounded in-line fallback that
skips the checkpoint (close() checkpoints after the CLI disarms the
watchdog).storeExtractionResult chunks its node/edge/ref
inserts (2,000 rows) with time-budgeted yields between; the ordered-commit
pump serializes async stores on a promise chain (preserving the #1015
file-order determinism invariant) and its backpressure now also waits on the
commit chain so the parse buffer stays bounded.warmCachesYielding streams the DISTINCT name set
with yields (the sync warmCaches stays for non-async callers). The 28.2s
sync stall dropped to ≤4s total across the whole sync.[phase-timing] on
CODEGRAPH_SYNTH_TIMINGS) — it is the synchronous git enumeration
(getGitVisibleFiles/collectGitFiles), NOT a hash loop. See "Accepted
residuals" below for why it was left synchronous.Verification: full-graph parity (every node id + edge, sorted dump diff)
byte-identical on fresh redis and vim indexes, baseline vs fixed; full test
suite green; kernel sync worst stall 28.2s → ~4s; ES synthesis tail worst
stall ≤2.7s.
Acceptance gate PASSED: fresh full kernel init (70,129 indexed files,
2,048,673 nodes / 6,402,391 edges) completed in 27m 8s on the 2-core/6GB
container at Node's default heap with the default 60s watchdog — EXIT 0,
identical node/edge counts to the pre-fix partial runs, maintenance 48.8s
off-thread with the WAL fully checkpointed (0 bytes). v1.3.0 could not finish
this repo at all (OOM at default heap; watchdog kill at the maintenance step
even with the tail fixed). Post-run, the one genuine synchronous span the run
exposed — the merged synthesized-edge insert (~275k rows, 20.2s in one
transaction) — was chunked (2k rows + yield) like the rest; redis parity
re-verified byte-identical after.
collectGitFiles' recursive gitlink/submodule
logic (#1038/#1065) or forking sync/async variants — high regression risk
for a CPU-bound span ~6× under the watchdog window even on a 2-core
container (its cost does not get the Windows/Defender per-file-IO
multiplier; it scales with CPU only).If any of these ever shows up in a real watchdog kill, the async-refactor
shape for the scan is: thread a MaybeYield through collectGitFiles'
per-line loop and make getGitVisibleFiles async, keeping scanDirectory
(sync) on the walk fallback only.
Original plan below, kept for the record.
The #850 liveness watchdog SIGKILLs the indexer when its event loop stalls past the window (default 60s). #1091 → #1122/#1137 → #1212 each moved the fix deeper: per-batch yields, per-ref yields, then (with #1212) yields + streamed queries + language gates across the entire dynamic-edge synthesis tail, which eliminated the 14–57s single-pass stalls and the two whole-graph OOMs.
While validating #1212 with an event-loop stall monitor over full init runs
(Linux kernel, 70k indexed files / 2.05M nodes, 2-core 6GB container; and
llvm-project, 180k tracked files, macOS), the phases before the synthesis
tail showed recurring single stalls that nothing currently yields through:
| Run | Phase | Observed single stalls |
|---|---|---|
| kernel (2 cores) | initial scan (t+14s, t+22s) | 5.1s, 10.5s |
| kernel (2 cores) | extraction (t+980–1080s) | 3.0–3.3s |
| kernel (2 cores) | extraction→resolution boundary (t+1354s) | 8.5s |
| llvm (mac, fast) | extraction / early resolution (t+1000–1320s) | 5–14s, recurring |
| kernel (2 cores) | codegraph sync on the same DB (110 files) |
28.2s (single stall) |
None of these approaches 60s on the tested hardware, and none are regressions — they pre-date #1212. But the #1212 pattern (Windows NTFS + Defender, small VMs) multiplies per-file and per-transaction costs several-fold, and 14s × a few-fold is a watchdog kill. These are the spans that will produce the fourth iteration of this bug class if left unmeasured.
ExtractionOrchestrator.storeExtractionResult (src/extraction/index.ts:2065)
runs one synchronous transaction per file (insertNodes + insertEdges +
unresolved-ref batch + FTS triggers). A giant generated file (llvm has
many multi-MB generated .inc/.cpp) inserts tens of thousands of nodes in
one unyielding span. The parse pool (#1015) moved parsing off-thread; the
commit is still a single main-thread block per file.warmCaches (src/resolution/index.ts:319)
calls getAllNodeNames() (src/db/queries.ts:1879, SELECT DISTINCT name
over the whole node table) plus getAllFilePaths() synchronously. On the
kernel's 2M-row table the DISTINCT alone is seconds; it is the prime suspect
for the 8.5s boundary stall and the 28.2s sync stall (sync also enters
resolution via the orphan sweep, #1191).resolveAndPersistBatched (src/resolution/index.ts) runs per-5000-ref
synchronous spans: insertEdges(batch),
deleteSpecificResolvedReferences × 2 (a 5000-statement transaction), and
getUnresolvedReferencesCount(). On a multi-GB DB each is a solid block.Extend the env-gated timing that located #1212 (CODEGRAPH_SYNTH_TIMINGS) to
the suspects — or add a sibling CODEGRAPH_PHASE_TIMINGS — so each suspect
logs spans >250ms with a label:
storeExtractionResult (log file path + node count when slow — this
also identifies the offending generated files),warmCaches (split getAllNodeNames vs getAllFilePaths),resolveAndPersistBatched,Re-run the stall monitor + timings on the two existing indexes (assets below). Attribution first: the fix for each suspect is different, and #1180 showed the first guess is often wrong.
maybeYield() between chunks.
Invariant to preserve: files must still commit in scan order, whole-file
at a time from the resolver's perspective (#1015 — resolution disambiguates
same-named candidates by insertion order; chunking within one file keeps
the order stable). The existing index-completeness marker (index_state)
already covers a mid-file kill.SELECT DISTINCT name with a cursor
(stmt.iterate()), building the Set with a periodic maybeYield() — an
async warmCachesYielding() used from the async entry points
(resolveAndPersistBatched, the sync path), leaving the sync warmCaches()
for callers that can't await. Memory is unchanged (the Set already exists).getUnresolvedReferencesCount is an indexed aggregate; leave it unless
timing says otherwise.init on the kernel index (2-core/6GB container) and
llvm-project shows no single event-loop stall > ~2s in any phase.codegraph sync on the kernel DB shows the same bound (kills the 28.2s span).getNodeAndEdgeCount + a sorted node-id dump).cg1212 (2 cores / 6GB, node:22-bookworm) with the Linux
kernel cloned at /work/linux and its 4.2GB index.stall-monitor.cjs (preload; logs event-loop gaps >1s with timestamps),
synth-only.mjs / synth-watchdog.mjs (drive resolution+synthesis directly
against an existing index — ~2 min iteration instead of a 40-min re-index),
parity.mjs (synthesized-edge set differ).CODEGRAPH_WATCHDOG_TIMEOUT_MS is the authoritative kill/no-kill test.