From 9a2d3d9a13048650dcd0f2a2a57246091866e6da Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Mon, 6 Apr 2026 09:50:30 -0500 Subject: [PATCH] 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. --- src/extraction/index.ts | 24 +++++++++++++++++++++++- src/index.ts | 16 +++++++++++++--- src/resolution/index.ts | 7 +++++-- src/ui/shimmer-progress.ts | 1 + src/ui/shimmer-worker.ts | 16 +++++++++++++--- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/extraction/index.ts b/src/extraction/index.ts index c026bce..4b40931 100644 --- a/src/extraction/index.ts +++ b/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. diff --git a/src/index.ts b/src/index.ts index 9d0afb1..92af4e8 100644 --- a/src/index.ts +++ b/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 { return this.resolver.resolveAndPersistBatched(onProgress); } diff --git a/src/resolution/index.ts b/src/resolution/index.ts index f4c10aa..b5a4e9e 100644 --- a/src/resolution/index.ts +++ b/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 { 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) { diff --git a/src/ui/shimmer-progress.ts b/src/ui/shimmer-progress.ts index 2535902..9d2191c 100644 --- a/src/ui/shimmer-progress.ts +++ b/src/ui/shimmer-progress.ts @@ -5,6 +5,7 @@ const PHASE_NAMES: Record = { scanning: 'Scanning files', parsing: 'Parsing code', storing: 'Storing data', + finalizing: 'Finalizing', resolving: 'Resolving refs', }; diff --git a/src/ui/shimmer-worker.ts b/src/ui/shimmer-worker.ts index 2644c47..46b9119 100644 --- a/src/ui/shimmer-worker.ts +++ b/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;