فهرست منبع

feat: Fix progress bar hanging and improve UI responsiveness during indexing

Adds strategic yield points and direct stdout writes to prevent progress animation from freezing when the main thread is blocked by synchronous operations. Introduces 'finalizing' phase to smooth transition between parsing and resolving steps, ensuring progress reaches 100% completion.
Colby McHenry 2 ماه پیش
والد
کامیت
9a2d3d9a13
5فایلهای تغییر یافته به همراه55 افزوده شده و 9 حذف شده
  1. 23 1
      src/extraction/index.ts
  2. 13 3
      src/index.ts
  3. 5 2
      src/resolution/index.ts
  4. 1 0
      src/ui/shimmer-progress.ts
  5. 13 3
      src/ui/shimmer-worker.ts

+ 23 - 1
src/extraction/index.ts

@@ -51,7 +51,7 @@ const WORKER_RECYCLE_INTERVAL = 250;
  * Progress callback for indexing operations
  */
 export interface IndexProgress {
-  phase: 'scanning' | 'parsing' | 'storing' | 'resolving';
+  phase: 'scanning' | 'parsing' | 'storing' | 'finalizing' | 'resolving';
   current: number;
   total: number;
   currentFile?: string;
@@ -460,6 +460,16 @@ export class ExtractionOrchestrator {
     const total = files.length;
     let processed = 0;
 
+    // Emit parsing phase immediately so the progress bar appears during worker setup.
+    // The yield lets the shimmer worker flush the phase transition to stdout before
+    // the main thread starts synchronous grammar detection work.
+    onProgress?.({
+      phase: 'parsing',
+      current: 0,
+      total,
+    });
+    await new Promise(resolve => setImmediate(resolve));
+
     // Detect needed languages and load grammars in the parse worker
     const neededLanguages = [...new Set(files.map((f) => detectLanguage(f)))];
 
@@ -719,6 +729,18 @@ export class ExtractionOrchestrator {
       }
     }
 
+    // Report 100% so the progress bar doesn't hang at 99%
+    onProgress?.({
+      phase: 'parsing',
+      current: total,
+      total,
+    });
+
+    // Yield so the shimmer worker's buffered stdout writes can flush.
+    // Worker thread stdout is proxied through the main thread's event loop,
+    // so synchronous work here blocks the animation from rendering.
+    await new Promise(resolve => setImmediate(resolve));
+
     // Retry pass: files that failed due to WASM memory corruption may succeed
     // on a fresh worker with a clean heap. Recycle before each attempt so
     // every file gets the absolute cleanest WASM state possible.

+ 13 - 3
src/index.ts

@@ -390,6 +390,16 @@ export class CodeGraph {
 
         // Resolve references to create call/import/extends edges
         if (result.success && result.filesIndexed > 0) {
+          // Signal transition so progress bar doesn't hang at "Parsing 100%"
+          options.onProgress?.({
+            phase: 'finalizing',
+            current: 0,
+            total: 0,
+          });
+
+          // Yield so shimmer worker can flush the phase transition to stdout
+          await new Promise(resolve => setImmediate(resolve));
+
           // Get count without loading all refs into memory
           const unresolvedCount = this.queries.getUnresolvedReferencesCount();
 
@@ -399,7 +409,7 @@ export class CodeGraph {
             total: unresolvedCount,
           });
 
-          this.resolveReferencesBatched((current, total) => {
+          await this.resolveReferencesBatched((current, total) => {
             options.onProgress?.({
               phase: 'resolving',
               current,
@@ -479,7 +489,7 @@ export class CodeGraph {
               total: unresolvedCount,
             });
 
-            this.resolveReferencesBatched((current, total) => {
+            await this.resolveReferencesBatched((current, total) => {
               options.onProgress?.({
                 phase: 'resolving',
                 current,
@@ -540,7 +550,7 @@ export class CodeGraph {
    * Resolve references in batches to keep memory bounded on large codebases.
    * Processes chunks of unresolved refs, persisting results after each batch.
    */
-  resolveReferencesBatched(onProgress?: (current: number, total: number) => void): ResolutionResult {
+  async resolveReferencesBatched(onProgress?: (current: number, total: number) => void): Promise<ResolutionResult> {
     return this.resolver.resolveAndPersistBatched(onProgress);
   }
 

+ 5 - 2
src/resolution/index.ts

@@ -326,10 +326,10 @@ export class ReferenceResolver {
    * Processes unresolved references in chunks, persisting edges and cleaning
    * up resolved refs after each batch to avoid accumulating large arrays.
    */
-  resolveAndPersistBatched(
+  async resolveAndPersistBatched(
     onProgress?: (current: number, total: number) => void,
     batchSize: number = 5000
-  ): ResolutionResult {
+  ): Promise<ResolutionResult> {
     this.warmCaches();
 
     const total = this.queries.getUnresolvedReferencesCount();
@@ -388,6 +388,9 @@ export class ReferenceResolver {
       processed += batch.length;
       onProgress?.(processed, total);
 
+      // Yield so progress UI can render between batches
+      await new Promise(resolve => setImmediate(resolve));
+
       // If nothing was resolved or removed in this batch, we'd loop forever
       // on the same rows. Break to avoid infinite loop.
       if (result.resolved.length === 0 && result.unresolved.length === batch.length) {

+ 1 - 0
src/ui/shimmer-progress.ts

@@ -5,6 +5,7 @@ const PHASE_NAMES: Record<string, string> = {
   scanning: 'Scanning files',
   parsing: 'Parsing code',
   storing: 'Storing data',
+  finalizing: 'Finalizing',
   resolving: 'Resolving refs',
 };
 

+ 13 - 3
src/ui/shimmer-worker.ts

@@ -1,6 +1,16 @@
 import { parentPort, workerData } from 'worker_threads';
+import { writeSync } from 'fs';
 import type { ShimmerWorkerMessage } from './types';
 
+// Write directly to fd 1 (stdout) instead of writeStdout().
+// In Node.js worker threads, process.stdout is proxied through the main
+// thread's event loop — so if the main thread is blocked (e.g. SQLite),
+// stdout writes from the worker queue up and the animation freezes.
+// fs.writeSync(1, ...) is a direct kernel syscall that bypasses this.
+function writeStdout(s: string): void {
+  writeSync(1, s);
+}
+
 const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
 const ANIM_INTERVAL = 150;
 const FRAMES_PER_GLYPH = 3;
@@ -74,16 +84,16 @@ function render(): void {
     line = `${DM}│${RST}  ${color}${glyph}${RST} ${currentMessage}...`;
   }
 
-  process.stdout.write(`\r\x1b[K${line}`);
+  writeStdout(`\r\x1b[K${line}`);
 }
 
 function finishPhase(): void {
   if (!currentMessage) return;
-  process.stdout.write(`\r\x1b[K`);
+  writeStdout(`\r\x1b[K`);
   let detail = '';
   if (currentPercent >= 0) detail = ' — done';
   else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
-  process.stdout.write(`${DM}│${RST}  ${GRN}◆${RST} ${currentMessage}${detail}\n`);
+  writeStdout(`${DM}│${RST}  ${GRN}◆${RST} ${currentMessage}${detail}\n`);
   currentMessage = '';
   currentPercent = -1;
   currentCount = 0;