Переглянути джерело

fix(ui): within-pass progress for the C fn-pointer linking pass (#1300)

Follow-up to #1299: the per-pass bar still parked on one number while a
single long pass ran — on C-heavy repos that's the fn-pointer dispatch
pass, which sweeps every C/C++ file four times (typedefs, registrations,
field propagation, dispatch sites) and dominates the linking phase.

The pass now reports a real fraction of its dominant work
(scannedFiles / files×4, at the same per-16-files cadence as its
cooperative yield), and the orchestrator surfaces instrumented passes'
fractions as fractional steps, throttled to whole-percent movement so
the UI message volume stays bounded. The mechanism is opt-in per pass —
any synthesizer that a real repo shows parking the bar can adopt the
same callback.

Verified on the 1,342-file C repo from the report: the linking bar now
moves through 88→89→90 where it previously sat at 88 for the whole
pass; graph byte-identical (50,520 nodes / 148,232 edges).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Colby Mchenry 1 місяць тому
батько
коміт
246aee8373

+ 1 - 1
CHANGELOG.md

@@ -18,7 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 - Callers and impact analysis no longer silently under-count a function that calls the same callee many times. When one caller contained several call sites to the same callee and an internal resolution batch boundary happened to split them, cleanup after the first batch removed the later sites' pending rows before they were ever attempted — their edges were never created, deterministically, and which edges went missing shifted with unrelated changes to the project's total reference count. Post-pass cleanup now targets the exact database row each processed reference came from. Found while validating the operator-call fix on nlohmann/json, where `write_cbor`'s 11 calls to `to_char_type` indexed as 10. (#1269)
 - C++ explicit operator calls — `a.operator+(b)`, `p->operator+(b)`, `a.operator[](3)`, and the other symbolic forms — now produce a `calls` edge to the operator method, so an operator invoked only through the explicit syntax no longer looks uncalled in callers and impact analysis. tree-sitter parses these call sites with the operator name stranded in an error node (never as a normal member access), so the call's target was silently read as just the receiver variable; the operator name is now recovered from the error node and resolved through receiver-type inference like any other member call — a same-named operator on an unrelated class can never capture the edge. Infix uses (`a + b`, `a[i]`) need real type inference and are tracked separately. (#1247)
-- `codegraph init` and `codegraph index` no longer look hung after "Resolving refs" reaches 100%. The dynamic-dispatch linking that runs after resolution (callbacks, React re-renders, C function pointers, and the rest) had no progress display, so on repos where it takes a while — large C codebases especially — the bar just sat frozen at 100% until it finished. That work now shows as its own "Linking dynamic dispatch" progress phase.
+- `codegraph init` and `codegraph index` no longer look hung after "Resolving refs" reaches 100%. The dynamic-dispatch linking that runs after resolution (callbacks, React re-renders, C function pointers, and the rest) had no progress display, so on repos where it takes a while — large C codebases especially — the bar just sat frozen at 100% until it finished. That work now shows as its own "Linking dynamic dispatch" progress phase, and the heaviest pass — C function-pointer linking — additionally reports progress within the pass, so a large C codebase advances the bar smoothly instead of parking it on one number for the bulk of the phase.
 - Indexing no longer prints repeated "SQLite is an experimental feature" warnings that garbled the progress display. The warning comes from Node's built-in SQLite and fired once per parsing worker; it's now suppressed on every launch path.
 
 ## [1.4.1] - 2026-07-10

+ 28 - 0
__tests__/c-fnptr-synthesizer.test.ts

@@ -366,4 +366,32 @@ void setup(int *L) {
     const edges = await load();
     expect(edges.length).toBe(0);
   });
+
+  // This is the pass that parks the "Linking dynamic dispatch" bar on C-heavy
+  // repos, so it reports a within-pass fraction of its file sweeps. Pin that
+  // the fractions arrive, stay in (0, 1], and never go backwards.
+  it('reports a monotonic within-pass progress fraction over its file sweeps', async () => {
+    // Enough files to cross the per-16-files reporting cadence several times
+    // across the four file sweeps.
+    for (let i = 0; i < 33; i++) write(`f${i}.c`, `void fn${i}(void) { }\n`);
+    const cg = await CodeGraph.init(dir, { silent: true });
+    await cg.indexAll();
+    const { cFnPointerDispatchEdges } = await import('../src/resolution/c-fnptr-synthesizer');
+    const fractions: number[] = [];
+    await cFnPointerDispatchEdges(
+      (cg as any).queries,
+      (cg as any).resolver.context,
+      async () => {},
+      (f: number) => fractions.push(f)
+    );
+    cg.close?.();
+    expect(fractions.length).toBeGreaterThanOrEqual(4);
+    for (const f of fractions) {
+      expect(f).toBeGreaterThan(0);
+      expect(f).toBeLessThanOrEqual(1);
+    }
+    for (let i = 1; i < fractions.length; i++) {
+      expect(fractions[i]!).toBeGreaterThanOrEqual(fractions[i - 1]!);
+    }
+  });
 });

+ 23 - 5
src/resolution/c-fnptr-synthesizer.ts

@@ -308,11 +308,29 @@ const INCLUDE_RE = /#[ \t]*include[ \t]+"([^"\n]+)"/g;
 /** Included files worth scanning for registration tables (e.g. a generated `.def`). */
 const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i;
 
-export async function cFnPointerDispatchEdges(_queries: QueryBuilder, ctx: ResolutionContext, onYield: MaybeYield): Promise<Edge[]> {
+export async function cFnPointerDispatchEdges(
+  _queries: QueryBuilder,
+  ctx: ResolutionContext,
+  onYield: MaybeYield,
+  onFraction?: (fraction: number) => void
+): Promise<Edge[]> {
   let scannedFiles = 0;
   const files = ctx.getAllFiles().filter((f) => C_CPP_EXT.test(f));
   if (files.length === 0) return [];
 
+  // Within-pass progress: this is the pass that parks the "Linking dynamic
+  // dispatch" bar on C-heavy repos, so it reports a real fraction of its
+  // dominant work. `files` is swept once per file loop below (passes A, C, D,
+  // E — pass B is node-bound and comparatively brief), reported at the same
+  // per-16-files cadence as the cooperative yield.
+  const FILE_SWEEPS = 4;
+  const tick = async (): Promise<void> => {
+    if ((++scannedFiles & 15) === 0) {
+      onFraction?.(scannedFiles / (files.length * FILE_SWEEPS));
+      await onYield();
+    }
+  };
+
   // Cache raw + stripped source per file, LRU-BOUNDED. The old unbounded Maps
   // retained every C/C++ file's raw AND stripped text for the whole pass —
   // multiple GB on the Linux kernel, one of the two OOM culprits in #1212.
@@ -358,7 +376,7 @@ export async function cFnPointerDispatchEdges(_queries: QueryBuilder, ctx: Resol
   const fnPtrTypedefs = new Set<string>();
   const fnTypeTypedefs = new Set<string>();
   for (const file of files) {
-    if ((++scannedFiles & 15) === 0) await onYield();
+    await tick();
     const s = src(file);
     if (!s || !s.includes('typedef')) continue;
     FNPTR_TYPEDEF_RE.lastIndex = 0;
@@ -764,7 +782,7 @@ export async function cFnPointerDispatchEdges(_queries: QueryBuilder, ctx: Resol
   // ---- Pass C: registrations — stream each file (and its qualifying local
   // includes) through processUnit, one at a time.
   for (const file of files) {
-    if ((++scannedFiles & 15) === 0) await onYield();
+    await tick();
     const env = new Map<string, MacroDef>();
     const objEnv = new Map<string, string>();
     const defined = new Set<string>();
@@ -846,7 +864,7 @@ export async function cFnPointerDispatchEdges(_queries: QueryBuilder, ctx: Resol
   const FIELD_ASSIGN_RE = /(\w+)\s*(?:->|\.)\s*(\w+)\s*=\s*(\w+)\s*(?:->|\.)\s*(\w+)/g;
   const propagations: { to: string; from: string }[] = [];
   for (const file of files) {
-    if ((++scannedFiles & 15) === 0) await onYield();
+    await tick();
     const s = src(file);
     if (!s || !s.includes('=')) continue;
     for (const fn of ctx.getNodesInFile(file)) {
@@ -897,7 +915,7 @@ export async function cFnPointerDispatchEdges(_queries: QueryBuilder, ctx: Resol
   const edges: Edge[] = [];
   const seen = new Set<string>();
   for (const file of files) {
-    if ((++scannedFiles & 15) === 0) await onYield();
+    await tick();
     const s = src(file);
     if (!s) continue;
     for (const fn of ctx.getNodesInFile(file)) {

+ 19 - 3
src/resolution/callback-synthesizer.ts

@@ -3474,8 +3474,24 @@ export async function synthesizeCallbackEdges(
   // 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.
+  // Emissions are throttled to whole-percent movement (each consumes a UI
+  // message); values may be fractional steps from within-pass reporting.
   let passesDone = 0;
-  onProgress?.(0, SYNTH_PROGRESS_STEPS);
+  let lastPct = -1;
+  const emit = (value: number): void => {
+    if (!onProgress) return;
+    const v = Math.min(value, SYNTH_PROGRESS_STEPS);
+    const pct = Math.floor((v / SYNTH_PROGRESS_STEPS) * 100);
+    if (pct === lastPct) return;
+    lastPct = pct;
+    onProgress(v, SYNTH_PROGRESS_STEPS);
+  };
+  // A single long pass otherwise parks the bar between steps; a pass that
+  // takes this callback reports a 0..1 fraction of its own work, surfaced
+  // here as fractional progress within its step.
+  const subProgress = (fraction: number): void =>
+    emit(passesDone + Math.max(0, Math.min(fraction, 1)));
+  emit(0);
 
   // 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
@@ -3489,7 +3505,7 @@ export async function synthesizeCallbackEdges(
       console.error(`[synth-timing] ${label}: ${dt}ms`);
     }
     passesDone++;
-    onProgress?.(Math.min(passesDone, SYNTH_PROGRESS_STEPS), SYNTH_PROGRESS_STEPS);
+    emit(passesDone);
   };
 
   // Language gating: one indexed DISTINCT over the files table lets a pass
@@ -3560,7 +3576,7 @@ export async function synthesizeCallbackEdges(
   const sidekiqEdges = has('ruby') ? await sidekiqDispatchEdges(ctx, yieldToLoop) : NONE; await yieldToLoop(); __mark('sidekiqEdges');
   const erlangBehaviourEdges = has('erlang') ? await erlangBehaviourDispatchEdges(queries, ctx, yieldToLoop) : NONE; await yieldToLoop(); __mark('erlangBehaviourEdges');
   const laravelEdges = has('php') ? await laravelEventEdges(ctx, yieldToLoop) : NONE; await yieldToLoop(); __mark('laravelEdges');
-  const cFnPtrEdges = has('c', 'cpp') ? await cFnPointerDispatchEdges(queries, ctx, yieldToLoop) : NONE; await yieldToLoop(); __mark('cFnPtrEdges');
+  const cFnPtrEdges = has('c', 'cpp') ? await cFnPointerDispatchEdges(queries, ctx, yieldToLoop, subProgress) : NONE; await yieldToLoop(); __mark('cFnPtrEdges');
   const goframeEdges = has('go') ? await goframeRouteEdges(ctx, yieldToLoop) : NONE; await yieldToLoop(); __mark('goframeEdges');
   const nixOptionEdges = has('nix') ? await nixOptionPathEdges(queries, yieldToLoop) : NONE; await yieldToLoop(); __mark('nixOptionEdges');