diff --git a/src/brainy.ts b/src/brainy.ts index a17f5897..92858732 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -4647,7 +4647,7 @@ export class Brainy implements BrainyInterface { * // embeddings.length === 3 * // embeddings[0].length === 384 */ - async embedBatch(texts: string[]): Promise { + async embedBatch(texts: string[], options?: { signal?: AbortSignal }): Promise { await this.ensureInitialized() if (texts.length === 0) { @@ -4655,7 +4655,7 @@ export class Brainy implements BrainyInterface { } // Use native WASM batch API: single forward pass instead of N individual calls - return await embeddingManager.embedBatch(texts) + return await embeddingManager.embedBatch(texts, options) } /** @@ -4784,20 +4784,25 @@ export class Brainy implements BrainyInterface { } // === PHASE 2: Find semantic matches with timeout fallback === + // AbortController ensures the background semantic work (WASM batch embedding) + // is cancelled on timeout or error, preventing event loop saturation and + // WASM engine crashes from abandoned promises. const SEMANTIC_TIMEOUT_MS = 10_000 + const abortController = new AbortController() + try { const semanticResult = await Promise.race([ - this.highlightSemanticPhase(query, chunks, threshold, highlightMap), + this.highlightSemanticPhase(query, chunks, threshold, highlightMap, abortController.signal), new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), SEMANTIC_TIMEOUT_MS)) ]) if (semanticResult === 'timeout') { - // Return Phase 1 text-only results on timeout + abortController.abort() const textHighlights = Array.from(highlightMap.values()) return textHighlights.sort((a, b) => b.score - a.score) } } catch { - // On any error in semantic phase, return Phase 1 results + abortController.abort() const textHighlights = Array.from(highlightMap.values()) return textHighlights.sort((a, b) => b.score - a.score) } @@ -4815,14 +4820,19 @@ export class Brainy implements BrainyInterface { query: string, chunks: Array<{ text: string, position: [number, number], contentCategory?: import('./types/brainy.types.js').ContentCategory }>, threshold: number, - highlightMap: Map + highlightMap: Map, + signal?: AbortSignal ): Promise { + if (signal?.aborted) return + // Get query embedding const queryVector = await this.embed(query) + if (signal?.aborted) return // Batch embed all chunks using native WASM batch API const chunkTexts = chunks.map(c => c.text) - const chunkVectors = await this.embedBatch(chunkTexts) + const chunkVectors = await this.embedBatch(chunkTexts, { signal }) + if (signal?.aborted) return // Calculate semantic similarities for (let i = 0; i < chunks.length; i++) { diff --git a/src/embeddings/EmbeddingManager.ts b/src/embeddings/EmbeddingManager.ts index 91b7db0d..51cbc6bf 100644 --- a/src/embeddings/EmbeddingManager.ts +++ b/src/embeddings/EmbeddingManager.ts @@ -219,7 +219,7 @@ export class EmbeddingManager { * @param texts Array of strings to embed * @returns Array of embedding vectors (384 dimensions each) */ - async embedBatch(texts: string[]): Promise { + async embedBatch(texts: string[], options?: { signal?: AbortSignal }): Promise { if (texts.length === 0) return [] const isTestMode = @@ -248,6 +248,10 @@ export class EmbeddingManager { // so other requests (HTTP, timers, I/O) can proceed const allResults: number[][] = [] for (let i = 0; i < texts.length; i += MICRO_BATCH_SIZE) { + if (options?.signal?.aborted) { + return allResults + } + const batch = texts.slice(i, i + MICRO_BATCH_SIZE) const batchResults = await this.engine.embedBatch(batch) allResults.push(...batchResults) diff --git a/src/embeddings/wasm/CandleEmbeddingEngine.ts b/src/embeddings/wasm/CandleEmbeddingEngine.ts index 7c9ba6b0..9eedd35f 100644 --- a/src/embeddings/wasm/CandleEmbeddingEngine.ts +++ b/src/embeddings/wasm/CandleEmbeddingEngine.ts @@ -205,19 +205,27 @@ export class CandleEmbeddingEngine { throw new Error('Engine not properly initialized') } - const startTime = Date.now() + try { + const startTime = Date.now() - const embedding = this.engine.embed(text) - const embeddingArray = Array.from(embedding) + const embedding = this.engine.embed(text) + const embeddingArray = Array.from(embedding) - const processingTimeMs = Date.now() - startTime - this.embedCount++ - this.totalProcessingTimeMs += processingTimeMs + const processingTimeMs = Date.now() - startTime + this.embedCount++ + this.totalProcessingTimeMs += processingTimeMs - return { - embedding: embeddingArray, - tokenCount: 0, // Candle handles tokenization internally - processingTimeMs, + return { + embedding: embeddingArray, + tokenCount: 0, // Candle handles tokenization internally + processingTimeMs, + } + } catch (error) { + console.error('WASM embed failed, marking engine for re-initialization:', error) + this.initialized = false + this.engine = null + this.wasmModule = null + throw error } } @@ -237,10 +245,18 @@ export class CandleEmbeddingEngine { return [] } - const embeddings = this.engine.embed_batch(texts) - this.embedCount += texts.length + try { + const embeddings = this.engine.embed_batch(texts) + this.embedCount += texts.length - return embeddings.map((e) => Array.from(e)) + return embeddings.map((e) => Array.from(e)) + } catch (error) { + console.error('WASM embedBatch failed, marking engine for re-initialization:', error) + this.initialized = false + this.engine = null + this.wasmModule = null + throw error + } } /** diff --git a/src/embeddings/wasm/WASMEmbeddingEngine.ts b/src/embeddings/wasm/WASMEmbeddingEngine.ts index 5adb2baa..2ce3061d 100644 --- a/src/embeddings/wasm/WASMEmbeddingEngine.ts +++ b/src/embeddings/wasm/WASMEmbeddingEngine.ts @@ -52,8 +52,9 @@ export class WASMEmbeddingEngine { * Initialize the engine */ async initialize(): Promise { - // Already initialized - if (this.initialized) { + // Only skip if BOTH this layer and the underlying Candle engine are initialized. + // If Candle crashed and reset itself, we must re-initialize even though our flag is true. + if (this.initialized && this.candleEngine.isInitialized()) { return }