fix(resolution): stop "Resolving refs" wedge on theme-vendoring repos; add exclude config + index watchdogs (#999) (#1009)

Three fixes for a repo that commits a large JS/TS theme/SDK (Metronic under
static/, ~1,600 tracked files):

1. A SECOND "Resolving refs" quadratic that #915 didn't cover. #915 capped
   import-name collisions; this caps method-name collisions (init/update/render
   re-declared on every widget), which flow through matchMethodCall Strategy 3
   and findBestMatch instead. New AMBIGUOUS_NAME_CEILING (default 500, env
   CODEGRAPH_AMBIGUOUS_NAME_CEILING): above it the fuzzy strategies decline
   rather than score K candidates — no proximity score can pick the one true
   target among thousands anyway. Resolving drops from O(K^2) to linear in refs
   (e.g. 900-file synthetic: 28.7s -> 3.4s), edge counts unchanged, and the cap
   never fires on normal repos (max real method-collision ~40).

2. A new `exclude` array in codegraph.json keeps git-TRACKED paths out of the
   index, which .gitignore can't do (enumeration is `git ls-files`). Mirrors the
   existing includeIgnored plumbing across the git, sync, and non-git-walk
   paths.

3. `index`/`init` now install the #850 liveness + #277 ppid watchdogs (which
   were serve-only), so a wedged or orphaned indexer self-terminates instead of
   pinning a core. The --liftoff-only relaunch's spawnSync can't forward
   signals, so killing the parent shim used to orphan the worker.

Tests: ubiquitous-name ceiling, exclude (incl. tracked-file exclusion on git +
non-git), orphan self-termination (POSIX), and ppid-parser units. Shared the
ppid parsers out of mcp/index.ts into mcp/ppid-watchdog.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-26 20:25:47 -05:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d3179f5004
commit 45d3293c6a
12 changed files with 805 additions and 100 deletions
+68 -54
View File
@@ -34,6 +34,7 @@ import { getGlyphs } from '../ui/glyphs';
import { buildNode25BlockBanner, buildNodeTooOldBanner, MIN_NODE_MAJOR } from './node-version-check';
import { installFatalHandlers } from './fatal-handler';
import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime-flags';
import { installCommandSupervision } from './command-supervision';
import { EXTRACTION_VERSION } from '../extraction/extraction-version';
import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry';
@@ -506,19 +507,25 @@ program
// Indexing runs by default now. The legacy -i/--index flag is still
// accepted (so existing muscle memory and scripts don't break) but is a
// no-op — initializing always builds the initial index.
// Supervise the index: self-terminate if orphaned or wedged (#999).
const supervision = installCommandSupervision('init');
let result: IndexResult;
if (options.verbose) {
result = await cg.indexAll({
onProgress: createVerboseProgress(),
verbose: true,
});
} else {
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
});
await progress.stop();
try {
if (options.verbose) {
result = await cg.indexAll({
onProgress: createVerboseProgress(),
verbose: true,
});
} else {
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
});
await progress.stop();
}
} finally {
supervision.stop();
}
printIndexResult(clack, result, projectPath);
await recordIndexTelemetry(cg, result);
@@ -627,51 +634,58 @@ program
const { default: CodeGraph } = await loadCodeGraph();
const cg = await CodeGraph.open(projectPath);
if (options.quiet) {
// Quiet mode: no UI, just run. `index` is a full re-index, so clear the
// existing graph and rebuild from scratch (see the note below — #874).
// Supervise the indexer: self-terminate if orphaned (parent shim killed)
// or if the main thread wedges — neither was guarded on this path (#999).
const supervision = installCommandSupervision('index');
try {
if (options.quiet) {
// Quiet mode: no UI, just run. `index` is a full re-index, so clear the
// existing graph and rebuild from scratch (see the note below — #874).
cg.clear();
const result = await cg.indexAll();
if (!result.success) process.exit(1);
cg.destroy();
return;
}
const clack = await importESM('@clack/prompts');
clack.intro('Indexing project');
// `index` is a FULL re-index: clear the existing graph and rebuild it from
// scratch so the result is identical to a fresh `init`. Without the clear,
// indexAll() skips every unchanged file by its content hash and reports
// "0 nodes, 0 edges" against the already-populated graph — which reads as
// "index wiped my index" (#874). For fast incremental updates use `sync`.
cg.clear();
const result = await cg.indexAll();
if (!result.success) process.exit(1);
let result: IndexResult;
if (options.verbose) {
result = await cg.indexAll({
onProgress: createVerboseProgress(),
verbose: true,
});
} else {
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
});
await progress.stop();
}
printIndexResult(clack, result, projectPath);
await recordIndexTelemetry(cg, result);
if (!result.success) {
process.exit(1);
}
clack.outro('Done');
cg.destroy();
return;
} finally {
supervision.stop();
}
const clack = await importESM('@clack/prompts');
clack.intro('Indexing project');
// `index` is a FULL re-index: clear the existing graph and rebuild it from
// scratch so the result is identical to a fresh `init`. Without the clear,
// indexAll() skips every unchanged file by its content hash and reports
// "0 nodes, 0 edges" against the already-populated graph — which reads as
// "index wiped my index" (#874). For fast incremental updates use `sync`.
cg.clear();
let result: IndexResult;
if (options.verbose) {
result = await cg.indexAll({
onProgress: createVerboseProgress(),
verbose: true,
});
} else {
process.stdout.write(`${colors.dim}${getGlyphs().rail}${colors.reset}\n`);
const progress = createShimmerProgress();
result = await cg.indexAll({
onProgress: progress.onProgress,
});
await progress.stop();
}
printIndexResult(clack, result, projectPath);
await recordIndexTelemetry(cg, result);
if (!result.success) {
process.exit(1);
}
clack.outro('Done');
cg.destroy();
} catch (err) {
error(`Failed to index: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
+77
View File
@@ -0,0 +1,77 @@
/**
* Process supervision for long-running CLI commands (`index` / `init --index`).
*
* Indexing a large repo can run for a while on the main thread, and #999
* surfaced two ways that goes wrong when nothing is watching it:
*
* 1. **Orphaned worker.** `index` runs in a child re-exec'd with
* `--liftoff-only` (the WASM-flag relaunch). Its parent blocks in
* `spawnSync`, so when the parent shim is killed it cannot forward the
* signal — the child keeps running, now orphaned, pinning a core. The PPID
* watchdog (#277) notices the parent/host went away and exits the child.
* 2. **Wedged indexer.** The `#850` main-thread liveness watchdog — which
* SIGKILLs a process whose event loop stops turning — was wired only into
* the MCP `serve` path, so a wedged `index`/`init` was never auto-killed.
*
* Both reuse the exact mechanisms `serve` already uses; this just makes them
* available to a one-shot command. Best-effort and self-disabling: a missing
* watchdog never blocks the command from running. Both honour the same env
* switches as `serve` (`CODEGRAPH_NO_WATCHDOG`, `CODEGRAPH_PPID_POLL_MS=0`).
*/
import { installMainThreadWatchdog } from '../mcp/liveness-watchdog';
import { supervisionLostReason, parsePpidPollMs, parseHostPpid } from '../mcp/ppid-watchdog';
import { isProcessAlive } from '../mcp/daemon-registry';
import { HOST_PPID_ENV } from '../extraction/wasm-runtime-flags';
export interface CommandSupervision {
/** Tear down both watchdogs. Idempotent; call when the command finishes. */
stop(): void;
}
/**
* Install the liveness + PPID watchdogs for the duration of a CLI command.
* `label` is used in the shutdown notice (e.g. `"index"`). Returns a handle
* whose `stop()` must be called when the command completes so neither watchdog
* outlives it.
*/
export function installCommandSupervision(label: string): CommandSupervision {
// Liveness watchdog: a separate process that SIGKILLs us if our event loop
// stops turning for too long (a wedged synchronous loop). Self-disables on
// CODEGRAPH_NO_WATCHDOG.
const liveness = installMainThreadWatchdog();
// PPID watchdog: detect that the parent (or the host threaded past the
// relaunch shim) died and we've been orphaned, then exit instead of leaking.
const originalPpid = process.ppid;
const hostPpid = parseHostPpid(process.env[HOST_PPID_ENV]);
const pollMs = parsePpidPollMs(process.env.CODEGRAPH_PPID_POLL_MS);
let ppidTimer: ReturnType<typeof setInterval> | null = null;
if (pollMs > 0) {
ppidTimer = setInterval(() => {
const reason = supervisionLostReason({
originalPpid,
currentPpid: process.ppid,
hostPpid,
isAlive: isProcessAlive,
});
if (reason) {
try {
process.stderr.write(`[CodeGraph ${label}] Parent process exited (${reason}); aborting.\n`);
} catch { /* stderr gone with the parent — exit anyway */ }
process.exit(1);
}
}, pollMs);
// Never let the watchdog itself keep the process alive past its real work.
ppidTimer.unref();
}
let stopped = false;
return {
stop(): void {
if (stopped) return;
stopped = true;
if (ppidTimer) clearInterval(ppidTimer);
liveness?.stop();
},
};
}