fix(ui): show synthesis as a 'Linking dynamic dispatch' phase; mute node:sqlite warning spam (#1299)

Two first-run UX bugs surfaced by indexing a real 1,342-file C repo:

1. After 'Resolving refs' hit 100%, the ~40 dynamic-dispatch synthesis
   passes ran with no progress surface, so the bar sat frozen at 100%
   long enough to read as a hang (the C fn-pointer pass alone can hold
   for a while on C-heavy repos). Synthesis now reports per-pass
   progress through a new 'linking' IndexProgress phase, rendered as
   'Linking dynamic dispatch'. The step total is pinned by a test to
   the synthesizer's actual __mark() count so adding a pass without
   bumping it fails loudly.

2. node:sqlite's ExperimentalWarning is emitted once per THREAD, so the
   main process plus every parse worker printed it mid-index,
   interleaved with the progress UI. All launch paths now pass
   --disable-warning=ExperimentalWarning: both bundle launchers, the
   Windows npm-shim invocation, and the CLI self-relaunch
   (NODE_RUNTIME_FLAGS, deliberately excluded from the re-exec gate so
   an older installed launcher never triggers a pointless re-exec, and
   version-gated off nodes older than the flag).

Verified end-to-end on the same repo: zero warnings, live linking bar,
byte-identical graph (50,520 nodes / 148,232 edges). Full suite green.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-15 19:44:09 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 243ef1d3e2
commit ad5300a601
11 changed files with 239 additions and 35 deletions
+1 -1
View File
@@ -73,7 +73,7 @@ const WORKER_RECYCLE_INTERVAL = 250;
* Progress callback for indexing operations
*/
export interface IndexProgress {
phase: 'scanning' | 'parsing' | 'storing' | 'resolving';
phase: 'scanning' | 'parsing' | 'storing' | 'resolving' | 'linking';
current: number;
total: number;
currentFile?: string;
+29 -2
View File
@@ -40,6 +40,31 @@ import { spawnSync } from 'child_process';
*/
export const WASM_RUNTIME_FLAGS: readonly string[] = ['--liftoff-only'];
/**
* Node CLI options (not V8 flags) passed alongside the WASM flags on every
* launch path. `--disable-warning=ExperimentalWarning` mutes node:sqlite's
* "SQLite is an experimental feature" warning, which is emitted once per
* THREAD — the main process plus every parse worker — so during indexing it
* prints repeatedly, interleaved with the progress UI. Node options apply
* process-wide (workers inherit them), so the command line covers everything.
*
* Deliberately NOT part of the {@link processHasWasmRuntimeFlags} re-exec
* gate: a launcher passing only the WASM flags (an older installed bundle
* running a newer dist) must not trigger a whole re-exec over a cosmetic
* warning.
*/
export function nodeRuntimeFlagsFor(nodeVersion: string): readonly string[] {
// `--disable-warning` landed in Node 20.11 / 21.3; older nodes treat it as
// a fatal "bad option" at spawn. Those runtimes can't run codegraph anyway
// (node:sqlite needs >= 22.5), but let them reach our own version messaging
// instead of a cryptic spawn failure.
const [major = 0, minor = 0] = nodeVersion.split('.').map(Number);
const supported = major > 21 || (major === 21 && minor >= 3) || (major === 20 && minor >= 11);
return supported ? ['--disable-warning=ExperimentalWarning'] : [];
}
export const NODE_RUNTIME_FLAGS: readonly string[] = nodeRuntimeFlagsFor(process.versions.node);
/**
* Env var set on the relaunched child so a detection slip can never cause an
* infinite re-exec loop. Also lets users force-disable the relaunch.
@@ -76,8 +101,10 @@ export function buildRelaunchArgv(
scriptArgs: readonly string[],
execArgv: readonly string[] = process.execArgv
): string[] {
const preserved = execArgv.filter((arg) => !WASM_RUNTIME_FLAGS.includes(arg));
return [...WASM_RUNTIME_FLAGS, ...preserved, scriptPath, ...scriptArgs];
const preserved = execArgv.filter(
(arg) => !WASM_RUNTIME_FLAGS.includes(arg) && !NODE_RUNTIME_FLAGS.includes(arg)
);
return [...NODE_RUNTIME_FLAGS, ...WASM_RUNTIME_FLAGS, ...preserved, scriptPath, ...scriptArgs];
}
/**
+53 -23
View File
@@ -509,13 +509,22 @@ export class CodeGraph {
total: unresolvedCount,
});
await this.resolveReferencesBatched((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
});
await this.resolveReferencesBatched(
(current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
},
(done, totalPasses) => {
options.onProgress?.({
phase: 'linking',
current: done,
total: totalPasses,
});
}
);
// Second pass: chained calls whose method lives on a supertype the
// receiver conforms to (protocol-extension / inherited / default-
@@ -735,13 +744,22 @@ export class CodeGraph {
total: unresolvedCount,
});
await this.resolveReferencesBatched((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
});
await this.resolveReferencesBatched(
(current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
},
(done, totalPasses) => {
options.onProgress?.({
phase: 'linking',
current: done,
total: totalPasses,
});
}
);
}
}
@@ -766,13 +784,22 @@ export class CodeGraph {
total: orphanCount,
});
await this.resolveReferencesBatched((current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
});
await this.resolveReferencesBatched(
(current, total) => {
options.onProgress?.({
phase: 'resolving',
current,
total,
});
},
(done, totalPasses) => {
options.onProgress?.({
phase: 'linking',
current: done,
total: totalPasses,
});
}
);
}
if (filesChanged || orphanCount > 0) {
@@ -1001,8 +1028,11 @@ export class CodeGraph {
* Resolve references in batches to keep memory bounded on large codebases.
* Processes chunks of unresolved refs, persisting results after each batch.
*/
async resolveReferencesBatched(onProgress?: (current: number, total: number) => void): Promise<ResolutionResult> {
return this.resolver.resolveAndPersistBatched(onProgress);
async resolveReferencesBatched(
onProgress?: (current: number, total: number) => void,
onSynthesisProgress?: (done: number, total: number) => void
): Promise<ResolutionResult> {
return this.resolver.resolveAndPersistBatched(onProgress, undefined, onSynthesisProgress);
}
/**
+24 -1
View File
@@ -3446,7 +3446,20 @@ async function laravelEventEdges(ctx: ResolutionContext, onYield: MaybeYield): P
* Sidekiq Worker.perform_async → #perform + Laravel event(new X) → listener handle).
* Returns the count added. Never throws into indexing — callers wrap in try/catch.
*/
export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: ResolutionContext): Promise<number> {
/**
* Number of progress steps synthesizeCallbackEdges reports: one per `__mark()`
* call (every synthesis pass, plus the dedupe-merge and edge-insert steps).
* Cosmetic only — drift just makes the progress bar end early or jump — and a
* test pins it to the actual `__mark(` call count so adding a pass without
* bumping this fails loudly instead of silently skewing the bar.
*/
export const SYNTH_PROGRESS_STEPS = 40;
export async function synthesizeCallbackEdges(
queries: QueryBuilder,
ctx: ResolutionContext,
onProgress?: (done: number, total: number) => void
): Promise<number> {
// Each sub-pass below is a whole-graph scan, and there are ~30 of them, all
// running synchronously on the indexer's main thread. Their AGGREGATE can run
// for well over a minute on a large repo — long enough for the #850 liveness
@@ -3456,6 +3469,14 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
// watchdog still catches that. See ./cooperative-yield.
const yieldToLoop = createYielder();
// Synthesis runs AFTER the resolution progress bar reaches 100%, so without
// its own progress the UI freezes at "Resolving refs 100%" for the whole
// tail — long enough on big repos that users conclude the index hung and
// kill it. Report each completed pass; the caller surfaces it as its own
// progress phase. Emit 0/total up front so the phase flips immediately.
let passesDone = 0;
onProgress?.(0, SYNTH_PROGRESS_STEPS);
// Per-pass wall-clock timing to stderr, opt-in via CODEGRAPH_SYNTH_TIMINGS
// (=1: passes over 250ms; =all: every pass). This is the diagnostic that
// located both the #1091/#1122 watchdog stalls and the #1212 OOM — keep it.
@@ -3467,6 +3488,8 @@ export async function synthesizeCallbackEdges(queries: QueryBuilder, ctx: Resolu
if (process.env.CODEGRAPH_SYNTH_TIMINGS && (dt > 250 || process.env.CODEGRAPH_SYNTH_TIMINGS === 'all')) {
console.error(`[synth-timing] ${label}: ${dt}ms`);
}
passesDone++;
onProgress?.(Math.min(passesDone, SYNTH_PROGRESS_STEPS), SYNTH_PROGRESS_STEPS);
};
// Language gating: one indexed DISTINCT over the files table lets a pass
+7 -2
View File
@@ -1258,7 +1258,8 @@ export class ReferenceResolver {
*/
async resolveAndPersistBatched(
onProgress?: (current: number, total: number) => void,
batchSize: number = 5000
batchSize: number = 5000,
onSynthesisProgress?: (done: number, total: number) => void
): Promise<ResolutionResult> {
// Resolution runs on the indexer's MAIN thread, and the #850 liveness
// watchdog SIGKILLs a process whose event loop stalls past its window (60s
@@ -1375,7 +1376,11 @@ export class ReferenceResolver {
// callbacks) that static parsing leaves out. Best-effort — never fail the
// index on it. See docs/design/callback-edge-synthesis.md.
try {
aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(this.queries, this.context);
aggregateStats.byMethod['callback-synthesis'] = await synthesizeCallbackEdges(
this.queries,
this.context,
onSynthesisProgress
);
} catch {
// synthesis is additive and optional; ignore failures
}
+1
View File
@@ -6,6 +6,7 @@ const PHASE_NAMES: Record<string, string> = {
parsing: 'Parsing code',
storing: 'Storing data',
resolving: 'Resolving refs',
linking: 'Linking dynamic dispatch',
};
export interface IndexProgress {