fix(index): yield during resolution so the liveness watchdog can't kill a valid large index (#1091) (#1105)

The #850 liveness watchdog SIGKILLs a process whose main-thread event loop
stalls past its window (60s default). It was extended to `index`/`init` in
#999, but reference resolution and callback-edge synthesis run synchronously
on that same thread — so on a large repo a legitimate, in-progress index gets
killed, and users had to disable the watchdog entirely (CODEGRAPH_NO_WATCHDOG=1).

Make the long synchronous spans yield cooperatively so the heartbeat keeps
firing during real work, while a genuinely wedged span (which never reaches a
yield) still trips the watchdog:

- synthesizeCallbackEdges yields between its whole-graph passes, and the heavy
  scanners (closure-collection, event-emitter, JSX-child, object-registry,
  field-channel) yield within their loops;
- batched resolution sub-chunks each batch with yields;
- the deferred chained-call and this-member post-passes yield per ref.

Behaviour-preserving — only timing changes; node/edge counts are identical.

Validated end-to-end with the real watchdog armed at the default 60s: the
released build is SIGKILLed partway through indexing the Swift compiler (27k
files, ~1.1M edges) and the TypeScript compiler, while the fixed build indexes
both to completion.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-01 12:13:42 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6dd5512d8d
commit ed39233f1a
7 changed files with 258 additions and 45 deletions
+41
View File
@@ -0,0 +1,41 @@
/**
* Cooperative yielding for long synchronous resolution spans.
*
* Reference resolution and callback-edge synthesis run on the indexer's MAIN
* thread — unlike parsing, which is off-thread in the parse worker. The #850
* liveness watchdog (armed on `index`/`init` since #999) SIGKILLs the process
* when that thread doesn't turn its event loop for the timeout window (default
* 60s), because its heartbeat is a `setInterval` on that same thread. On a large
* repo, resolving refs + synthesizing dynamic-dispatch edges legitimately runs
* for minutes, so a span that never yields starves the heartbeat and the
* watchdog kills a VALID, in-progress index — the exact symptom of #1091 (the
* progress bar freezes at wherever it last rendered — 88% / 100% — then the
* process is killed).
*
* `createYielder` returns a `maybeYield()` that yields (via `setImmediate`) only
* once more than `budgetMs` of wall-clock has elapsed since the last yield, so
* fast repos pay essentially nothing while slow ones give the heartbeat a
* regular window to fire. Call it at natural boundaries in a long loop (between
* batches, between synthesis passes).
*
* This does NOT weaken the watchdog. A genuinely wedged loop — an infinite or
* non-terminating span, the case the watchdog exists to catch — never reaches a
* yield point, so the heartbeat still stops and the SIGKILL still fires. We only
* stop killing work that is demonstrably making progress.
*/
/** Yield when more than `budgetMs` of wall-clock has passed since the last yield. */
export type MaybeYield = () => Promise<void>;
/** Default budget: well under the watchdog's minimum heartbeat cadence (~1s), so
* a heartbeat byte always has a chance to land between yields. */
export const DEFAULT_YIELD_BUDGET_MS = 250;
export function createYielder(budgetMs: number = DEFAULT_YIELD_BUDGET_MS): MaybeYield {
let last = Date.now();
return async function maybeYield(): Promise<void> {
if (Date.now() - last < budgetMs) return;
await new Promise<void>((resolve) => setImmediate(resolve));
last = Date.now();
};
}