Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>()` incurs heavy allocation and insertion overhead compared to a fixed-size byte array.
**Action:** Replace `Set<number>` 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<number>` 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<number>()` 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.
88 changes: 69 additions & 19 deletions language-server/src/core/search-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -1649,16 +1655,28 @@ export class SearchEngine implements ISearchProvider {
const heap = new MinHeap<SearchResult>(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<number>() : 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();
Expand All @@ -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) {
Expand Down Expand Up @@ -1776,16 +1804,17 @@ export class SearchEngine implements ISearchProvider {
context: ReturnType<typeof this.prepareSearchContext>,
heap: MinHeap<SearchResult>,
token?: CancellationToken,
visited?: Set<number>,
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);
}
Expand All @@ -1795,13 +1824,18 @@ export class SearchEngine implements ISearchProvider {
context: ReturnType<typeof this.prepareSearchContext>,
heap: MinHeap<SearchResult>,
token?: CancellationToken,
visited?: Set<number>,
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);
}
Expand Down Expand Up @@ -2338,24 +2372,40 @@ export class SearchEngine implements ISearchProvider {
// ⚡ Bolt: Fast index tracking optimization
// Replacing `Set<number>` 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++) {
const indices = this.scopedIndices.get(priorityScopes[s]);
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;
}
Comment on lines +2375 to 2409
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard shared visited tracker against re-entrant callbacks.

processItem(i) can synchronously trigger onResult, and consumer code can re-enter search on the same SearchEngine instance. Because this path mutates shared visitedIndicesBuffer/visitedIndicesList, nested calls can reset/corrupt the outer pass state.

Suggested fix approach (keep fast path, add safe fallback only when re-entrant)
 private searchRemainingItems(
     priorityScopes: SearchScope[],
     maxResults: number,
     processItem: (i: number) => void,
     results: SearchResult[],
     token?: CancellationToken,
 ): void {
+    // Re-entrancy guard: callbacks can synchronously trigger nested searches.
+    if (this.visitedIndicesList.length > 0) {
+        const searchedIndices = new Set<number>();
+        for (let s = 0; s < priorityScopes.length; s++) {
+            const indices = this.scopedIndices.get(priorityScopes[s]);
+            if (!indices) continue;
+            for (let j = 0; j < indices.length; j++) {
+                searchedIndices.add(indices[j]);
+            }
+        }
+        for (let i = 0; i < this.items.length; i++) {
+            if (results.length >= maxResults || token?.isCancellationRequested) break;
+            if (!searchedIndices.has(i)) {
+                processItem(i);
+            }
+        }
+        return;
+    }
+
     if (this.visitedIndicesBuffer.length < this.items.length) {
         this.visitedIndicesBuffer = new Uint8Array(this.items.length);
     }
     this.visitedIndicesList.length = 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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++) {
const indices = this.scopedIndices.get(priorityScopes[s]);
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;
}
// Re-entrancy guard: callbacks can synchronously trigger nested searches.
if (this.visitedIndicesList.length > 0) {
const searchedIndices = new Set<number>();
for (let s = 0; s < priorityScopes.length; s++) {
const indices = this.scopedIndices.get(priorityScopes[s]);
if (!indices) continue;
for (let j = 0; j < indices.length; j++) {
searchedIndices.add(indices[j]);
}
}
for (let i = 0; i < this.items.length; i++) {
if (results.length >= maxResults || token?.isCancellationRequested) break;
if (!searchedIndices.has(i)) {
processItem(i);
}
}
return;
}
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++) {
const indices = this.scopedIndices.get(priorityScopes[s]);
if (indices) {
const len = indices.length;
for (let j = 0; j < len; j++) {
const idx = indices[j];
searchedIndices[idx] = 1;
this.visitedIndicesList.push(idx);
}
}
}
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;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@language-server/src/core/search-engine.ts` around lines 2375 - 2409, The
shared visited tracker (visitedIndicesBuffer / visitedIndicesList) is mutated by
processItem which can synchronously re-enter search; to prevent corrupting outer
state, add a re-entrancy fallback: introduce a per-instance boolean flag (e.g.,
this._isSearching) and in the search loop decide whether to use the shared
buffers or allocate local ones—if this._isSearching is true, create a fresh
Uint8Array and fresh list for searchedIndices/visitedIndicesList for this
invocation; otherwise set this._isSearching = true, use the shared
this.visitedIndicesBuffer / this.visitedIndicesList, and in the try/finally
ensure you clear only the modified slots and reset this._isSearching = false;
reference symbols to change: processItem, visitedIndicesBuffer,
visitedIndicesList, searchedIndices, and add this._isSearching to guard
re-entrant calls.

}

Expand Down
Loading