-
Notifications
You must be signed in to change notification settings - Fork 2.5k
perf(filesearch): move AsyncFzf index construction to a worker thread #4621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
052dc76
8859057
6be81d3
bfd3a07
0dae073
769a236
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -121,6 +121,10 @@ export function useAtCompletion(props: UseAtCompletionProps): void { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||
| dispatch({ type: 'RESET' }); | ||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||
| void fileSearch.current?.dispose?.(); | ||||||||||||||||||||||
| fileSearch.current = null; | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
| }, [cwd, config]); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // Reacts to user input (`pattern`) ONLY. | ||||||||||||||||||||||
|
|
@@ -153,8 +157,15 @@ export function useAtCompletion(props: UseAtCompletionProps): void { | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| // The "Worker" that performs async operations based on status. | ||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||
| let cancelled = false; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const initialize = async () => { | ||||||||||||||||||||||
| try { | ||||||||||||||||||||||
| // Dispose previous instance to prevent worker thread leaks on | ||||||||||||||||||||||
| // re-initialization (cwd/config change triggers RESET → re-init). | ||||||||||||||||||||||
| await fileSearch.current?.dispose?.(); | ||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MEDIUM — Concurrent The
This race pre-dates the PR, but each leaked
Suggested change
A module-level await searcher.initialize();
if (myGeneration !== initGeneration) {
// A newer initialize() already started — dispose ours and bail.
await searcher.dispose?.();
return;
}
fileSearch.current = searcher;
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 0dae073. Added a |
||||||||||||||||||||||
| fileSearch.current = null; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| const searcher = FileSearchFactory.create({ | ||||||||||||||||||||||
| projectRoot: cwd, | ||||||||||||||||||||||
| ignoreDirs: [], | ||||||||||||||||||||||
|
|
@@ -171,13 +182,21 @@ export function useAtCompletion(props: UseAtCompletionProps): void { | |||||||||||||||||||||
| config?.getFileFilteringEnableFuzzySearch() !== false, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| await searcher.initialize(); | ||||||||||||||||||||||
| // Guard against the effect being cleaned up (unmount / cwd change) | ||||||||||||||||||||||
| // or superseded by a newer initialize() while we were awaiting. | ||||||||||||||||||||||
| if (cancelled) { | ||||||||||||||||||||||
| await searcher.dispose?.(); | ||||||||||||||||||||||
| return; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| fileSearch.current = searcher; | ||||||||||||||||||||||
| dispatch({ type: 'INITIALIZE_SUCCESS' }); | ||||||||||||||||||||||
| if (state.pattern !== null) { | ||||||||||||||||||||||
| dispatch({ type: 'SEARCH', payload: state.pattern }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } catch (_) { | ||||||||||||||||||||||
| dispatch({ type: 'ERROR' }); | ||||||||||||||||||||||
| if (!cancelled) { | ||||||||||||||||||||||
| dispatch({ type: 'ERROR' }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| }; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -234,6 +253,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void { | |||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| return () => { | ||||||||||||||||||||||
| cancelled = true; | ||||||||||||||||||||||
| searchAbortController.current?.abort(); | ||||||||||||||||||||||
| if (slowSearchTimer.current) { | ||||||||||||||||||||||
| clearTimeout(slowSearchTimer.current); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,7 +11,7 @@ import { loadIgnoreRules } from './ignore.js'; | |||||||||||||||||||||
| import { ResultCache } from './result-cache.js'; | ||||||||||||||||||||||
| import { crawl } from './crawler.js'; | ||||||||||||||||||||||
| import type { FzfResultItem } from 'fzf'; | ||||||||||||||||||||||
| import { AsyncFzf } from 'fzf'; | ||||||||||||||||||||||
| import { FzfWorkerHandle } from './fzfWorkerHandle.js'; | ||||||||||||||||||||||
| import { unescapePath } from '../paths.js'; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| /** | ||||||||||||||||||||||
|
|
@@ -98,13 +98,19 @@ export interface SearchOptions { | |||||||||||||||||||||
| export interface FileSearch { | ||||||||||||||||||||||
| initialize(): Promise<void>; | ||||||||||||||||||||||
| search(pattern: string, options?: SearchOptions): Promise<string[]>; | ||||||||||||||||||||||
| /** | ||||||||||||||||||||||
| * Release any worker / native resources held by this search instance. | ||||||||||||||||||||||
| * Optional because the directory-only path holds no such resources. | ||||||||||||||||||||||
| * Implementations must be safe to call multiple times. | ||||||||||||||||||||||
| */ | ||||||||||||||||||||||
| dispose?(): Promise<void>; | ||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] Worker thread leak in the CLI's @-picker path. On workspaces with ≥5 000 files, each Fix in
Suggested change
Also dispose the previous instance before overwriting in — qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 81a513d. Added |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| class RecursiveFileSearch implements FileSearch { | ||||||||||||||||||||||
| private ignore: Ignore | undefined; | ||||||||||||||||||||||
| private resultCache: ResultCache | undefined; | ||||||||||||||||||||||
| private allFiles: string[] = []; | ||||||||||||||||||||||
| private fzf: AsyncFzf<string[]> | undefined; | ||||||||||||||||||||||
| private fzf: FzfWorkerHandle | undefined; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| constructor(private readonly options: FileSearchOptions) {} | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -120,7 +126,13 @@ class RecursiveFileSearch implements FileSearch { | |||||||||||||||||||||
| maxDepth: this.options.maxDepth, | ||||||||||||||||||||||
| maxFiles: MAX_CRAWL_FILES, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| this.buildResultCache(); | ||||||||||||||||||||||
| await this.buildResultCache(); | ||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Consider adding a test: it('should release fzf handle on dispose', async () => {
const fileSearch = FileSearchFactory.create({ ... });
await fileSearch.initialize();
await expect(fileSearch.dispose()).resolves.toBeUndefined();
// Idempotent
await expect(fileSearch.dispose()).resolves.toBeUndefined();
});— qwen3.7-max via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in f3b9666. Added two tests in |
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async dispose(): Promise<void> { | ||||||||||||||||||||||
| const handle = this.fzf; | ||||||||||||||||||||||
| this.fzf = undefined; | ||||||||||||||||||||||
| await handle?.dispose(); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| async search( | ||||||||||||||||||||||
|
|
@@ -151,8 +163,13 @@ class RecursiveFileSearch implements FileSearch { | |||||||||||||||||||||
| if (pattern.includes('*') || !this.fzf) { | ||||||||||||||||||||||
| filteredCandidates = await filter(candidates, pattern, options.signal); | ||||||||||||||||||||||
| } else { | ||||||||||||||||||||||
| // Pass a generous limit to the worker so results are trimmed before | ||||||||||||||||||||||
| // IPC serialization — avoids sending 50k+ entries across postMessage | ||||||||||||||||||||||
| // when only ~72 are displayed. The 200 cap leaves headroom for | ||||||||||||||||||||||
| // downstream ignore-filter to drop entries without starving results. | ||||||||||||||||||||||
| const fzfLimit = Math.max(200, (options.maxResults ?? 200) * 3); | ||||||||||||||||||||||
| filteredCandidates = await this.fzf | ||||||||||||||||||||||
| .find(pattern) | ||||||||||||||||||||||
| .find(pattern, fzfLimit) | ||||||||||||||||||||||
| .then((results: Array<FzfResultItem<string>>) => | ||||||||||||||||||||||
| results.map((entry: FzfResultItem<string>) => entry.item), | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
@@ -190,14 +207,21 @@ class RecursiveFileSearch implements FileSearch { | |||||||||||||||||||||
| return results; | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| private buildResultCache(): void { | ||||||||||||||||||||||
| private async buildResultCache(): Promise<void> { | ||||||||||||||||||||||
| this.resultCache = new ResultCache(this.allFiles); | ||||||||||||||||||||||
| // Initialize fuzzy search if enabled (or undefined, default true). | ||||||||||||||||||||||
| if (this.options.enableFuzzySearch !== false) { | ||||||||||||||||||||||
| // The v1 algorithm is much faster since it only looks at the first | ||||||||||||||||||||||
| // occurence of the pattern. We use it for search spaces that have >20k | ||||||||||||||||||||||
| // files, because the v2 algorithm is just too slow in those cases. | ||||||||||||||||||||||
| this.fzf = new AsyncFzf(this.allFiles, { | ||||||||||||||||||||||
| // | ||||||||||||||||||||||
| // Construction is the actual main-thread freeze on large workspaces | ||||||||||||||||||||||
| // (the AsyncFzf constructor is misleadingly named — it runs sync | ||||||||||||||||||||||
| // during `new`). FzfWorkerHandle hosts the instance in a | ||||||||||||||||||||||
| // worker_threads worker once the file count crosses ~5k; below that | ||||||||||||||||||||||
| // it stays in-thread because worker spawn + IPC overhead exceeds the | ||||||||||||||||||||||
| // construction cost. | ||||||||||||||||||||||
| this.fzf = await FzfWorkerHandle.create(this.allFiles, { | ||||||||||||||||||||||
| fuzzy: this.allFiles.length > 20000 ? 'v1' : 'v2', | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] The async
initialize()is fire-and-forget with no cancellation guard. If the component unmounts orcwd/configchanges whileawait searcher.initialize()is in flight (worker spawn + ready handshake takes several ms), the cleanup effect runsfileSearch.current?.dispose?.()— butfileSearch.currentis stillnullfrom line 164, so nothing is disposed. When the pendinginitialize()resolves,fileSearch.current = searcherwrites a live worker into the ref with no owner to dispose it.In React 18 Strict Mode (dev), the double-invocation makes this deterministic: invocation-1's async init completes after cleanup already ran, leaking a worker thread.
Consider adding a
cancelledflag orAbortControllerthat the effect cleanup signals:— qwen3.7-max via Qwen Code /review
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 0dae073. Added a
cancelledflag inside the effect closure — set totruein cleanup. Afterawait searcher.initialize(), if cancelled is true the searcher is disposed immediately instead of being written tofileSearch.current. This covers both the unmount-during-init and the Strict Mode double-invocation scenarios.