Просмотр исходного кода

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 месяцев назад
Родитель
Сommit
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
  * Progress callback for indexing operations
  */
  */
 export interface IndexProgress {
 export interface IndexProgress {
-  phase: 'scanning' | 'parsing' | 'storing' | 'resolving';
+  phase: 'scanning' | 'parsing' | 'storing' | 'finalizing' | 'resolving';
   current: number;
   current: number;
   total: number;
   total: number;
   currentFile?: string;
   currentFile?: string;
@@ -460,6 +460,16 @@ export class ExtractionOrchestrator {
     const total = files.length;
     const total = files.length;
     let processed = 0;
     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
     // Detect needed languages and load grammars in the parse worker
     const neededLanguages = [...new Set(files.map((f) => detectLanguage(f)))];
     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
     // 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
     // on a fresh worker with a clean heap. Recycle before each attempt so
     // every file gets the absolute cleanest WASM state possible.
     // 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
         // Resolve references to create call/import/extends edges
         if (result.success && result.filesIndexed > 0) {
         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
           // Get count without loading all refs into memory
           const unresolvedCount = this.queries.getUnresolvedReferencesCount();
           const unresolvedCount = this.queries.getUnresolvedReferencesCount();
 
 
@@ -399,7 +409,7 @@ export class CodeGraph {
             total: unresolvedCount,
             total: unresolvedCount,
           });
           });
 
 
-          this.resolveReferencesBatched((current, total) => {
+          await this.resolveReferencesBatched((current, total) => {
             options.onProgress?.({
             options.onProgress?.({
               phase: 'resolving',
               phase: 'resolving',
               current,
               current,
@@ -479,7 +489,7 @@ export class CodeGraph {
               total: unresolvedCount,
               total: unresolvedCount,
             });
             });
 
 
-            this.resolveReferencesBatched((current, total) => {
+            await this.resolveReferencesBatched((current, total) => {
               options.onProgress?.({
               options.onProgress?.({
                 phase: 'resolving',
                 phase: 'resolving',
                 current,
                 current,
@@ -540,7 +550,7 @@ export class CodeGraph {
    * Resolve references in batches to keep memory bounded on large codebases.
    * Resolve references in batches to keep memory bounded on large codebases.
    * Processes chunks of unresolved refs, persisting results after each batch.
    * 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);
     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
    * Processes unresolved references in chunks, persisting edges and cleaning
    * up resolved refs after each batch to avoid accumulating large arrays.
    * up resolved refs after each batch to avoid accumulating large arrays.
    */
    */
-  resolveAndPersistBatched(
+  async resolveAndPersistBatched(
     onProgress?: (current: number, total: number) => void,
     onProgress?: (current: number, total: number) => void,
     batchSize: number = 5000
     batchSize: number = 5000
-  ): ResolutionResult {
+  ): Promise<ResolutionResult> {
     this.warmCaches();
     this.warmCaches();
 
 
     const total = this.queries.getUnresolvedReferencesCount();
     const total = this.queries.getUnresolvedReferencesCount();
@@ -388,6 +388,9 @@ export class ReferenceResolver {
       processed += batch.length;
       processed += batch.length;
       onProgress?.(processed, total);
       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
       // If nothing was resolved or removed in this batch, we'd loop forever
       // on the same rows. Break to avoid infinite loop.
       // on the same rows. Break to avoid infinite loop.
       if (result.resolved.length === 0 && result.unresolved.length === batch.length) {
       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',
   scanning: 'Scanning files',
   parsing: 'Parsing code',
   parsing: 'Parsing code',
   storing: 'Storing data',
   storing: 'Storing data',
+  finalizing: 'Finalizing',
   resolving: 'Resolving refs',
   resolving: 'Resolving refs',
 };
 };
 
 

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

@@ -1,6 +1,16 @@
 import { parentPort, workerData } from 'worker_threads';
 import { parentPort, workerData } from 'worker_threads';
+import { writeSync } from 'fs';
 import type { ShimmerWorkerMessage } from './types';
 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 SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'];
 const ANIM_INTERVAL = 150;
 const ANIM_INTERVAL = 150;
 const FRAMES_PER_GLYPH = 3;
 const FRAMES_PER_GLYPH = 3;
@@ -74,16 +84,16 @@ function render(): void {
     line = `${DM}│${RST}  ${color}${glyph}${RST} ${currentMessage}...`;
     line = `${DM}│${RST}  ${color}${glyph}${RST} ${currentMessage}...`;
   }
   }
 
 
-  process.stdout.write(`\r\x1b[K${line}`);
+  writeStdout(`\r\x1b[K${line}`);
 }
 }
 
 
 function finishPhase(): void {
 function finishPhase(): void {
   if (!currentMessage) return;
   if (!currentMessage) return;
-  process.stdout.write(`\r\x1b[K`);
+  writeStdout(`\r\x1b[K`);
   let detail = '';
   let detail = '';
   if (currentPercent >= 0) detail = ' — done';
   if (currentPercent >= 0) detail = ' — done';
   else if (currentCount > 0) detail = ` — ${formatNumber(currentCount)} found`;
   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 = '';
   currentMessage = '';
   currentPercent = -1;
   currentPercent = -1;
   currentCount = 0;
   currentCount = 0;