diff --git a/.jules/bolt.md b/.jules/bolt.md index b8cd366..6c6fb0b 100644 --- a/.jules/bolt.md +++ b/.jules/bolt.md @@ -9,3 +9,6 @@ ## 2026-04-08 - [Fast Dense Integer Set Tracking] **Learning:** When keeping track of seen integer IDs that are dense and bounded (e.g. from 0 to N), using `new Set()` incurs heavy allocation and insertion overhead compared to a fixed-size byte array. **Action:** Replace `Set` with `new Uint8Array(maxIndex)` and use `array[id] = 1` to track presence, which is ~15x faster and avoids garbage collection pauses in hot paths. (Benchmark context: `N=100,000` IDs, `bun` version 1.2.14, Linux x86_64, Intel Xeon 2.30GHz, 4 cores, 8GB RAM, averaged over 100 iterations comparing `Set` addition vs `new Uint8Array(maxIndex)` indexed assignment `array[id] = 1`). +## 2026-04-08 - [Avoid State Corruption with Reusable Array Trackers] +**Learning:** When using instance-level reusable buffers (like `Uint8Array` combined with a dirty-index list) to replace `new Set()` in hot loops, it is critical to wrap the usage in a `try...finally` block. If the synchronous loop throws an error, the cleanup logic will be skipped, leaving "dirty" indices in the buffer that corrupt subsequent searches for the lifetime of that instance. Additionally, the final fallback pass should only check the buffer—it should never write to it, as no future deduplication passes exist, saving an unnecessary O(N) array write operation. +**Action:** Always place the cleanup/reset of reusable instance-level trackers inside a `finally` block to guarantee state hygiene. Skip writing to the tracker in the final pass of any multi-step search. diff --git a/language-server/src/core/search-engine.ts b/language-server/src/core/search-engine.ts index 39e03d7..b86e6ab 100644 --- a/language-server/src/core/search-engine.ts +++ b/language-server/src/core/search-engine.ts @@ -159,6 +159,10 @@ export class SearchEngine implements ISearchProvider { private inactiveFileItems: SearchableItem[] = []; private filePriorityCacheDirty = true; + // Reusable buffers for index tracking to avoid allocations in hot loops + private visitedIndicesBuffer: Uint8Array = new Uint8Array(0); + private visitedIndicesList: number[] = []; + // String normalization cache (1-item) for relativeFilePath private lastRelativeInput: string | null = null; private lastRelativeOutput: string | null = null; @@ -767,6 +771,8 @@ export class SearchEngine implements ISearchProvider { this.lastRelativeOutput = null; this.activeFileItems = []; this.inactiveFileItems = []; + this.visitedIndicesBuffer = new Uint8Array(0); + this.visitedIndicesList.length = 0; this.invalidateDerivedCaches(); } @@ -1649,16 +1655,28 @@ export class SearchEngine implements ISearchProvider { const heap = new MinHeap(maxResults, (a, b) => a.score - b.score); const searchContext = this.prepareSearchContext(query, scope); const preferredIndices = this.getPreferredIndicesForQuery(scope, query, indices); - const visited = preferredIndices.length > 0 ? new Set() : undefined; + let trackVisited = false; if (preferredIndices.length > 0) { - this.searchWithIndices(preferredIndices, searchContext, heap, token, visited); + trackVisited = true; + if (this.visitedIndicesBuffer.length < this.items.length) { + this.visitedIndicesBuffer = new Uint8Array(this.items.length); + } + this.visitedIndicesList.length = 0; } - if (indices) { - this.searchWithIndices(indices, searchContext, heap, token, visited); - } else { - this.searchAllItems(searchContext, heap, token, visited); + try { + if (preferredIndices.length > 0) { + this.searchWithIndices(preferredIndices, searchContext, heap, token, trackVisited); + } + + if (indices) { + this.searchWithIndices(indices, searchContext, heap, token, trackVisited); + } else { + this.searchAllItems(searchContext, heap, token, trackVisited); + } + } finally { + this.cleanupVisitedTracker(trackVisited); } const results = heap.getSorted(); @@ -1681,6 +1699,16 @@ export class SearchEngine implements ISearchProvider { return results; } + private cleanupVisitedTracker(trackVisited: boolean): void { + if (trackVisited) { + const modifiedLen = this.visitedIndicesList.length; + for (let i = 0; i < modifiedLen; i++) { + this.visitedIndicesBuffer[this.visitedIndicesList[i]] = 0; + } + this.visitedIndicesList.length = 0; + } + } + private getPreferredIndicesForQuery(scope: SearchScope, query: string, indices?: number[]): number[] { const memo = this.lastQueryMemo; if (!memo || memo.scope !== scope) { @@ -1776,16 +1804,17 @@ export class SearchEngine implements ISearchProvider { context: ReturnType, heap: MinHeap, token?: CancellationToken, - visited?: Set, + trackVisited: boolean = false, ): void { for (let j = 0; j < indices.length; j++) { if (j % 500 === 0 && token?.isCancellationRequested) break; const i = indices[j]; - if (visited) { - if (visited.has(i)) { + if (trackVisited) { + if (this.visitedIndicesBuffer[i] !== 0) { continue; } - visited.add(i); + this.visitedIndicesBuffer[i] = 1; + this.visitedIndicesList.push(i); } this.processItemForSearch(i, context, heap); } @@ -1795,13 +1824,18 @@ export class SearchEngine implements ISearchProvider { context: ReturnType, heap: MinHeap, token?: CancellationToken, - visited?: Set, + trackVisited: boolean = false, ): void { const count = context.items.length; for (let i = 0; i < count; i++) { if (i % 500 === 0 && token?.isCancellationRequested) break; - if (visited?.has(i)) { - continue; + if (trackVisited) { + if (this.visitedIndicesBuffer[i] !== 0) { + continue; + } + // Optimization: searchAllItems is the final fallback pass. + // It only needs to check the tracker to skip previously visited items, + // but it never needs to write to it because there are no subsequent passes. } this.processItemForSearch(i, context, heap); } @@ -2338,7 +2372,12 @@ export class SearchEngine implements ISearchProvider { // ⚡ Bolt: Fast index tracking optimization // Replacing `Set` with a pre-allocated `Uint8Array` prevents massive object allocation // and provides O(1) array access. (~15x faster than Set for 1M items). - const searchedIndices = new Uint8Array(this.items.length); + if (this.visitedIndicesBuffer.length < this.items.length) { + this.visitedIndicesBuffer = new Uint8Array(this.items.length); + } + this.visitedIndicesList.length = 0; + + const searchedIndices = this.visitedIndicesBuffer; const priorityScopesLength = priorityScopes.length; for (let s = 0; s < priorityScopesLength; s++) { @@ -2346,16 +2385,27 @@ export class SearchEngine implements ISearchProvider { if (indices) { const len = indices.length; for (let j = 0; j < len; j++) { - searchedIndices[indices[j]] = 1; + const idx = indices[j]; + searchedIndices[idx] = 1; + this.visitedIndicesList.push(idx); } } } - for (let i = 0; i < this.items.length; i++) { - if (results.length >= maxResults || token?.isCancellationRequested) break; - if (searchedIndices[i] === 0) { - processItem(i); + try { + for (let i = 0; i < this.items.length; i++) { + if (results.length >= maxResults || token?.isCancellationRequested) break; + if (searchedIndices[i] === 0) { + processItem(i); + } + } + } finally { + // Fast cleanup: clear only the modified slots using the tracked list + const modifiedLen = this.visitedIndicesList.length; + for (let i = 0; i < modifiedLen; i++) { + searchedIndices[this.visitedIndicesList[i]] = 0; } + this.visitedIndicesList.length = 0; } }