Skip to content

Commit ace9510

Browse files
committed
fix: prevent UI freeze during code indexing by implementing worker threads and throttling (#4188)
1 parent 1be6fce commit ace9510

31 files changed

+2011
-20
lines changed

src/core/webview/webviewMessageHandler.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,17 @@ export const webviewMessageHandler = async (
18101810
}
18111811
break
18121812
}
1813+
case "cancelIndexing": {
1814+
try {
1815+
const manager = provider.codeIndexManager!
1816+
if (manager.isFeatureEnabled) {
1817+
await manager.cancelIndexing()
1818+
}
1819+
} catch (error) {
1820+
provider.log(`Error cancelling indexing: ${error instanceof Error ? error.message : String(error)}`)
1821+
}
1822+
break
1823+
}
18131824
case "clearIndexData": {
18141825
try {
18151826
const manager = provider.codeIndexManager!
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach, vitest } from "vitest"
2+
import { DirectoryScanner } from "../processors/scanner"
3+
import { WorkerPool } from "../workers/worker-pool"
4+
import * as vscode from "vscode"
5+
import * as fs from "fs/promises"
6+
import { listFiles } from "../../glob/list-files"
7+
import { RooIgnoreController } from "../../../core/ignore/RooIgnoreController"
8+
9+
// Mock dependencies
10+
vitest.mock("vscode")
11+
vitest.mock("fs/promises", () => ({
12+
stat: vitest.fn(),
13+
readFile: vitest.fn(),
14+
}))
15+
vitest.mock("../workers/worker-pool")
16+
vitest.mock("../../glob/list-files")
17+
vitest.mock("../../../core/ignore/RooIgnoreController", () => ({
18+
RooIgnoreController: vitest.fn().mockImplementation(() => ({
19+
initialize: vitest.fn().mockResolvedValue(undefined),
20+
filterPaths: vitest.fn().mockImplementation((paths) => paths),
21+
})),
22+
}))
23+
24+
describe("DirectoryScanner Cancellation", () => {
25+
let scanner: DirectoryScanner
26+
let mockWorkerPool: any
27+
let mockEmbedder: any
28+
let mockVectorStore: any
29+
let mockCodeParser: any
30+
let mockCacheManager: any
31+
let mockIgnore: any
32+
let abortController: AbortController
33+
34+
beforeEach(() => {
35+
// Mock worker pool
36+
mockWorkerPool = {
37+
execute: vi.fn().mockResolvedValue({
38+
content: "file content",
39+
hash: "abc123",
40+
}),
41+
shutdown: vi.fn().mockResolvedValue(undefined),
42+
}
43+
vi.mocked(WorkerPool).mockImplementation(() => mockWorkerPool)
44+
45+
// Mock dependencies
46+
mockEmbedder = {
47+
createEmbeddings: vi.fn().mockResolvedValue({ embeddings: [[0.1, 0.2, 0.3]] }),
48+
}
49+
50+
mockVectorStore = {
51+
upsertPoints: vi.fn().mockResolvedValue(undefined),
52+
deletePointsByFilePath: vi.fn().mockResolvedValue(undefined),
53+
deletePointsByMultipleFilePaths: vi.fn().mockResolvedValue(undefined),
54+
}
55+
56+
mockCodeParser = {
57+
parseFile: vi.fn().mockResolvedValue([
58+
{
59+
content: "test code",
60+
file_path: "/test/file.ts",
61+
start_line: 1,
62+
end_line: 10,
63+
},
64+
]),
65+
}
66+
67+
mockCacheManager = {
68+
getHash: vi.fn().mockReturnValue(null),
69+
updateHash: vi.fn().mockResolvedValue(undefined),
70+
deleteHash: vi.fn().mockResolvedValue(undefined),
71+
getAllHashes: vi.fn().mockReturnValue({}),
72+
}
73+
74+
mockIgnore = {
75+
ignores: vi.fn().mockReturnValue(false),
76+
}
77+
78+
// Mock listFiles - returns [files[], hasMore: boolean]
79+
vi.mocked(listFiles).mockResolvedValue([["file1.ts", "file2.ts", "file3.ts"], false])
80+
81+
// RooIgnoreController is already mocked in the module mock above
82+
83+
// Mock file system
84+
vi.mocked(fs.stat).mockResolvedValue({
85+
isDirectory: () => false,
86+
isFile: () => true,
87+
size: 1000,
88+
} as any)
89+
90+
// Mock vscode.workspace.fs
91+
vi.mocked(vscode.workspace.fs.readFile).mockResolvedValue(Buffer.from("file content") as any)
92+
93+
// Create scanner
94+
scanner = new DirectoryScanner(mockEmbedder, mockVectorStore, mockCodeParser, mockCacheManager, mockIgnore)
95+
abortController = new AbortController()
96+
})
97+
98+
afterEach(() => {
99+
vi.clearAllMocks()
100+
})
101+
102+
it("should stop processing when signal is aborted", async () => {
103+
// Mock multiple files
104+
vi.mocked(listFiles).mockResolvedValue([["file1.ts", "file2.ts", "file3.ts", "file4.ts", "file5.ts"], false])
105+
106+
// Track processing
107+
let processedCount = 0
108+
109+
// Mock worker pool to simulate slower processing and check abort signal
110+
mockWorkerPool.execute.mockImplementation(async () => {
111+
processedCount++
112+
113+
// Abort after processing 2 files
114+
if (processedCount === 2) {
115+
// Abort immediately
116+
abortController.abort()
117+
}
118+
119+
// Simulate processing delay
120+
await new Promise((resolve) => setTimeout(resolve, 10))
121+
122+
// Check if aborted
123+
if (abortController.signal.aborted) {
124+
throw new Error("Indexing cancelled")
125+
}
126+
127+
return {
128+
content: "file content",
129+
hash: "abc123",
130+
}
131+
})
132+
133+
// Start scanning
134+
const scanPromise = scanner.scanDirectory("/test/workspace", undefined, undefined, undefined, {
135+
signal: abortController.signal,
136+
})
137+
138+
// Should throw cancellation error
139+
await expect(scanPromise).rejects.toThrow("Indexing cancelled")
140+
141+
// Should have started processing but not completed all files
142+
expect(processedCount).toBeGreaterThan(0)
143+
expect(processedCount).toBeLessThan(5)
144+
})
145+
146+
it("should throw error when cancelled during file processing", async () => {
147+
// Mock multiple files to ensure processing takes time
148+
vi.mocked(listFiles).mockResolvedValue([["file1.ts", "file2.ts", "file3.ts"], false])
149+
150+
// Make worker pool check abort signal
151+
let callCount = 0
152+
153+
mockWorkerPool.execute.mockImplementation(async (task: any) => {
154+
callCount++
155+
156+
// Process first file normally
157+
if (callCount === 1) {
158+
await new Promise((resolve) => setTimeout(resolve, 10))
159+
return {
160+
content: "file content",
161+
hash: "abc123",
162+
}
163+
}
164+
165+
// Abort immediately on second file
166+
abortController.abort()
167+
168+
// Wait a bit then check signal
169+
await new Promise((resolve) => setTimeout(resolve, 5))
170+
171+
if (abortController.signal.aborted) {
172+
throw new Error("Indexing cancelled")
173+
}
174+
175+
return {
176+
content: "file content",
177+
hash: "abc123",
178+
}
179+
})
180+
181+
// Start scanning
182+
const scanPromise = scanner.scanDirectory("/test/workspace", undefined, undefined, undefined, {
183+
signal: abortController.signal,
184+
})
185+
186+
// Should reject with cancellation error
187+
await expect(scanPromise).rejects.toThrow("Indexing cancelled")
188+
189+
// Should have attempted to process at least one file
190+
expect(callCount).toBeGreaterThan(0)
191+
})
192+
193+
it("should clean up worker pool on disposal", async () => {
194+
// Scan without cancelling
195+
await scanner.scanDirectory("/test/workspace", undefined, undefined, undefined, {
196+
signal: abortController.signal,
197+
})
198+
199+
// Dispose scanner
200+
await scanner.dispose()
201+
202+
// Worker pool should be shut down
203+
expect(mockWorkerPool.shutdown).toHaveBeenCalled()
204+
})
205+
206+
it("should complete successfully if not cancelled", async () => {
207+
// Mock simple file structure
208+
vi.mocked(listFiles).mockResolvedValue([["file1.ts", "file2.ts"], false])
209+
210+
// Scan without cancelling
211+
const result = await scanner.scanDirectory("/test/workspace", undefined, undefined, undefined, {
212+
signal: abortController.signal,
213+
})
214+
215+
// Should complete successfully
216+
expect(result.codeBlocks).toHaveLength(2)
217+
expect(result.stats.processed).toBe(2)
218+
expect(result.stats.skipped).toBe(0)
219+
220+
// Should have parsed both files
221+
expect(mockCodeParser.parseFile).toHaveBeenCalledTimes(2)
222+
})
223+
224+
it("should handle cancellation during batch processing", async () => {
225+
// Mock many files to trigger batch processing (BATCH_SEGMENT_THRESHOLD is 50)
226+
const manyFiles = Array(60)
227+
.fill(null)
228+
.map((_, i) => `file${i}.ts`)
229+
vi.mocked(listFiles).mockResolvedValue([manyFiles, false])
230+
231+
// Track embedding calls
232+
let embeddingCallCount = 0
233+
let shouldAbort = false
234+
235+
mockEmbedder.createEmbeddings.mockImplementation(async (texts: string[]) => {
236+
embeddingCallCount++
237+
238+
// First batch should succeed, second should be cancelled
239+
if (embeddingCallCount === 1) {
240+
// Let first batch complete
241+
return {
242+
embeddings: texts.map(() => [0.1, 0.2, 0.3]),
243+
}
244+
} else {
245+
// Simulate delay for second batch
246+
await new Promise((resolve) => setTimeout(resolve, 100))
247+
// Check abort signal
248+
if (shouldAbort || abortController.signal.aborted) {
249+
throw new Error("Indexing cancelled")
250+
}
251+
return {
252+
embeddings: texts.map(() => [0.1, 0.2, 0.3]),
253+
}
254+
}
255+
})
256+
257+
// Start scanning
258+
const scanPromise = scanner.scanDirectory("/test/workspace", undefined, undefined, undefined, {
259+
signal: abortController.signal,
260+
})
261+
262+
// Abort after first batch completes
263+
setTimeout(() => {
264+
shouldAbort = true
265+
abortController.abort()
266+
}, 50)
267+
268+
// Should complete successfully since cancellation happens after processing
269+
const result = await scanPromise
270+
271+
// Should have processed files
272+
expect(result.codeBlocks.length).toBeGreaterThan(0)
273+
expect(embeddingCallCount).toBeGreaterThanOrEqual(1)
274+
})
275+
276+
it("should respect abort signal in listFiles", async () => {
277+
// Make listFiles check abort signal
278+
vi.mocked(listFiles).mockImplementation(async () => {
279+
// Check abort signal
280+
if (abortController.signal.aborted) {
281+
throw new Error("Indexing cancelled")
282+
}
283+
284+
// Simulate delay
285+
await new Promise((resolve) => setTimeout(resolve, 100))
286+
287+
return [["file1.ts", "file2.ts"], false]
288+
})
289+
290+
// Start scanning
291+
const scanPromise = scanner.scanDirectory("/test/workspace", undefined, undefined, undefined, {
292+
signal: abortController.signal,
293+
})
294+
295+
// Abort quickly
296+
setTimeout(() => abortController.abort(), 50)
297+
298+
// Should reject
299+
await expect(scanPromise).rejects.toThrow("Indexing cancelled")
300+
301+
// Should not have reached file parsing
302+
expect(mockCodeParser.parseFile).not.toHaveBeenCalled()
303+
})
304+
})

0 commit comments

Comments
 (0)