From ff77366469038f6b50f16d2ade1d979cf6fc80cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 18:27:41 +0000 Subject: [PATCH 1/2] fix(server): file search no longer freezes the server while fff's watcher reads a large file fff's watcher holds its picker lock while it reads each new file to the end, so in a non-git root one large or slow file blocked every search, and closing an index, on the event loop for as long as that read took. The fff-node patch adds fileSearchAsync, directorySearchAsync, mixedSearchAsync and grepAsync, which run the native search on a libuv pool thread. Searches on one finder run one at a time, so a held lock occupies at most one pool thread per finder. destroy() now frees the handle on a pool thread too. WorkspaceSearchIndex uses the async calls. Fixes pingdotgg/t3code#11306 Co-Authored-By: Claude Opus 5.5 --- .../src/workspace/WorkspaceEntries.test.ts | 5 +- .../workspace/WorkspaceSearchIndex.test.ts | 22 +- .../src/workspace/WorkspaceSearchIndex.ts | 27 +- patches/@ff-labs__fff-node@0.11.0.patch | 231 ++++++++++++++++-- pnpm-lock.yaml | 6 +- 5 files changed, 237 insertions(+), 54 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 35b4c5410e960..521e127779984 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -963,10 +963,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { if (created.ok) { const finder = created.value; finders += 1; - // The synchronous rescan waits on fff's picker lock on the event loop. + // The synchronous rescan and grep wait on fff's picker lock on the event loop. finder.scanFiles = () => { throw new Error("refresh must not rescan on the JS thread"); }; + finder.grep = () => { + throw new Error("search must not grep on the JS thread"); + }; const scanFilesAsync = finder.scanFilesAsync.bind(finder); finder.scanFilesAsync = () => { rescans += 1; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 25d1b912b1495..9924720edbe97 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -47,7 +47,7 @@ it.effect("filters image searches before applying the result limit", () => ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), fileItem("public/icon.svg"), ]; - const fileSearch = vi.fn(() => ({ + const fileSearch = vi.fn(async () => ({ ok: true as const, value: { items, @@ -61,7 +61,7 @@ it.effect("filters image searches before applying the result limit", () => waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), isScanning: () => false, watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch, + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -95,7 +95,7 @@ it.effect("fetches past a first page of Git-ignored results", () => fileItem("hidden/c.ts"), fileItem("src/a.ts"), ]; - const fileSearch = vi.fn((_query: string, options?: { pageSize?: number }) => ({ + const fileSearch = vi.fn(async (_query: string, options?: { pageSize?: number }) => ({ ok: true as const, value: { items: items.slice(0, options?.pageSize), @@ -109,7 +109,7 @@ it.effect("fetches past a first page of Git-ignored results", () => waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), isScanning: () => false, watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch, + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -133,7 +133,7 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), isScanning: () => false, watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch: vi.fn(() => ({ + fileSearchAsync: vi.fn(async () => ({ ok: true as const, value: { items, scores: [], totalMatched: 1, totalFiles: 1 }, })), @@ -184,7 +184,7 @@ it.effect("continues whole-word searches after a filtered grep page", () => filteredFileCount: 1, nextCursor: cursor, }); - const grep = vi.fn((_query: string, options?: GrepOptions) => + const grep = vi.fn(async (_query: string, options?: GrepOptions) => options?.cursor ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } : { @@ -197,7 +197,7 @@ it.effect("continues whole-word searches after a filtered grep page", () => waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), isScanning: () => false, watch: () => ({ ok: true as const, value: () => undefined }), - grep, + grepAsync: grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -241,7 +241,7 @@ function fakeSymlinkFinder( let watchFailures = options.watchFailures ?? 0; // fff's pageIndex is an offset into the results, not a page number. const directorySearch = vi.fn( - (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { + async (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { const offset = searchOptions?.pageIndex ?? 0; const items = directories.slice(offset, offset + (searchOptions?.pageSize ?? 100)); return { @@ -269,12 +269,12 @@ function fakeSymlinkFinder( watchers.push(callback); return { ok: true as const, value: () => undefined }; }, - directorySearch, - fileSearch: () => { + directorySearchAsync: directorySearch, + fileSearchAsync: async () => { const items = indexedItems.flatMap((entry) => (entry.type === "file" ? [entry.item] : [])); return { ok: true as const, value: { items, scores: [], totalMatched: items.length } }; }, - mixedSearch: () => ({ + mixedSearchAsync: async () => ({ ok: true as const, value: { items: indexedItems, totalMatched: indexedItems.length }, }), diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 50a89b01ce869..bf98f76dc9012 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -585,16 +585,17 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", - execute: () => Result, + execute: () => Promise>, ): Effect.fn.Return { - const result = yield* Effect.try({ + // fff's searches wait on its picker lock, which its watcher can hold for minutes. + const result = yield* Effect.tryPromise({ try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: `FileFinder.${operation} threw unexpectedly.`, + reason: `FileFinder.${operation} rejected unexpectedly.`, cause, }), }); @@ -616,9 +617,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const directories: string[] = []; // fff's pageIndex is an offset into the results, not a page number. for (let pageIndex = 0; ; pageIndex += SYMLINK_DIRECTORY_PAGE_SIZE) { - const result = yield* Effect.sync(() => { + const result = yield* Effect.promise(async () => { try { - return finder.directorySearch("", { pageIndex, pageSize: SYMLINK_DIRECTORY_PAGE_SIZE }); + return await finder.directorySearchAsync("", { + pageIndex, + pageSize: SYMLINK_DIRECTORY_PAGE_SIZE, + }); } catch { return undefined; } @@ -629,7 +633,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( if (directory) directories.push(directory); } if (result.value.items.length < SYMLINK_DIRECTORY_PAGE_SIZE) return directories; - yield* Effect.yieldNow; } }, ); @@ -833,7 +836,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "mixedSearch", - execute: (pageSize: number) => Result>, + execute: (pageSize: number) => Promise>>, pathOf: (item: I) => string, ): Effect.fn.Return, WorkspaceSearchIndexSearchFailed> { let size = pageSize; @@ -900,7 +903,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( "", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", - (pageSize) => finder.mixedSearch("", { pageSize }), + (pageSize) => finder.mixedSearchAsync("", { pageSize }), mixedItemPath, ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES, hostIsWindows); @@ -941,7 +944,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "fileSearch", - (size) => finder.fileSearch(query, { pageSize: size }), + (size) => finder.fileSearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, imageOnly)); @@ -951,7 +954,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "directorySearch", - (size) => finder.directorySearch(query, { pageSize: size }), + (size) => finder.directorySearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); return merge(mapDirectorySearchResult(result, indexLimit, hostIsWindows)); @@ -960,7 +963,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "mixedSearch", - (size) => finder.mixedSearch(query, { pageSize: size }), + (size) => finder.mixedSearchAsync(query, { pageSize: size }), mixedItemPath, ); return merge(mapMixedSearchResult(result, indexLimit, hostIsWindows)); @@ -998,7 +1001,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( do { const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); const result = yield* runSearch(input.query, input.limit, "grep", () => - finder.grep(searchQuery, { + finder.grepAsync(searchQuery, { mode: regexMode ? "regex" : "plain", smartCase: !input.caseSensitive && !regexMode, maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), diff --git a/patches/@ff-labs__fff-node@0.11.0.patch b/patches/@ff-labs__fff-node@0.11.0.patch index 8468783d2a6a5..198ac430e0d18 100644 --- a/patches/@ff-labs__fff-node@0.11.0.patch +++ b/patches/@ff-labs__fff-node@0.11.0.patch @@ -30,10 +30,10 @@ index 0f90eedca20655ccd30b8f28e433b2094341fbdf..c2ef86927d6e3ab0e8584330fa0ac3fd /** Total number of items (files + dirs) that matched */ totalMatched: number; diff --git a/dist/finder.d.ts b/dist/finder.d.ts -index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c8ba89547 100644 +index 452102d720cc19b0560cfc9e2f1a320687047c92..490dba5277c8d751b53ed239c0ce77f3e11f9c43 100644 --- a/dist/finder.d.ts +++ b/dist/finder.d.ts -@@ -242,6 +242,10 @@ export declare class FileFinder implements FileFinderApi { +@@ -242,6 +242,18 @@ export declare class FileFinder implements FileFinderApi { * background watcher might have missed. */ scanFiles(): Result; @@ -41,10 +41,18 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c + * Trigger a rescan without blocking the JS thread. Patched in T3 Code. + */ + scanFilesAsync(): Promise>; ++ /** `fileSearch` without blocking the JS thread. Patched in T3 Code. */ ++ fileSearchAsync(query: string, options?: SearchOptions): Promise>; ++ /** `directorySearch` without blocking the JS thread. Patched in T3 Code. */ ++ directorySearchAsync(query: string, options?: DirSearchOptions): Promise>; ++ /** `mixedSearch` without blocking the JS thread. Patched in T3 Code. */ ++ mixedSearchAsync(query: string, options?: SearchOptions): Promise>; ++ /** `grep` without blocking the JS thread. Patched in T3 Code. */ ++ grepAsync(query: string, options?: GrepOptions): Promise>; /** * Check if a scan is currently in progress. */ -@@ -254,6 +258,10 @@ export declare class FileFinder implements FileFinderApi { +@@ -254,6 +266,10 @@ export declare class FileFinder implements FileFinderApi { * Get the current scan progress. */ getScanProgress(): Result; @@ -56,7 +64,7 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c * Wait for the initial file scan to complete. * Non-blocking: polls `isScanning` and yields to the event loop between checks. diff --git a/dist/index.js b/dist/index.js -index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588ac7f86cc9 100644 +index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..5d9fbf45095bd2b2ff1c321c03b85414498bde57 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,7 +1,7 @@ @@ -140,7 +148,20 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a if ("ok" in res) return res; freeResult(res.rawPtr); return { ok: true, value: void 0 }; -@@ -426,9 +459,9 @@ function ffiDestroy(handle) { +@@ -417,18 +450,20 @@ function ffiCreate(basePath, frecencyDbPath, historyDbPath, _useUnsafeNoLock, en + } + function ffiDestroy(handle) { + loadLibrary(); +- load({ ++ return load({ + library: LIBRARY_KEY, + funcName: "fff_destroy", + retType: DataType.Void, + paramsType: [DataType.External], +- paramsValue: [handle] ++ paramsValue: [handle], ++ runInNewThread: true ++ }).catch(() => { }); } var FFF_FILE_ITEM_STRUCT = { @@ -153,7 +174,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a size: DataType.U64, modified: DataType.U64, access_frecency_score: DataType.I64, -@@ -445,8 +478,9 @@ var FFF_SCORE_STRUCT = { +@@ -445,8 +480,9 @@ var FFF_SCORE_STRUCT = { distance_penalty: DataType.I32, current_file_penalty: DataType.I32, combo_match_boost: DataType.I32, @@ -164,7 +185,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a }; var FFF_SEARCH_RESULT_STRUCT = { items: DataType.External, -@@ -462,8 +496,8 @@ var FFF_SEARCH_RESULT_STRUCT = { +@@ -462,8 +498,8 @@ var FFF_SEARCH_RESULT_STRUCT = { location_end_col: DataType.I32 }; var FFF_DIR_ITEM_STRUCT = { @@ -175,7 +196,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a max_access_frecency: DataType.I32 }; var FFF_DIR_SEARCH_RESULT_STRUCT = { -@@ -475,9 +509,9 @@ var FFF_DIR_SEARCH_RESULT_STRUCT = { +@@ -475,9 +511,9 @@ var FFF_DIR_SEARCH_RESULT_STRUCT = { }; var FFF_MIXED_ITEM_STRUCT = { item_type: DataType.U8, @@ -188,7 +209,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a size: DataType.U64, modified: DataType.U64, access_frecency_score: DataType.I64, -@@ -500,10 +534,10 @@ var FFF_MIXED_SEARCH_RESULT_STRUCT = { +@@ -500,10 +536,10 @@ var FFF_MIXED_SEARCH_RESULT_STRUCT = { location_end_col: DataType.I32 }; var FFF_GREP_MATCH_STRUCT = { @@ -203,7 +224,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a match_ranges: DataType.External, context_before: DataType.External, context_after: DataType.External, -@@ -598,18 +632,13 @@ function readMixedItemFromRaw(raw) { +@@ -598,18 +634,13 @@ function readMixedItemFromRaw(raw) { } function callAccessor(funcName, resultPtr, index, structDef) { loadLibrary(); @@ -224,7 +245,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } function ptrOffset(base, bytes) { return load({ -@@ -766,6 +795,7 @@ function parseSearchResult(rawPtr) { +@@ -766,6 +797,7 @@ function parseSearchResult(rawPtr) { } const items = []; const scores = []; @@ -232,7 +253,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_search_result_get_item", -@@ -774,13 +804,15 @@ function parseSearchResult(rawPtr) { +@@ -774,13 +806,15 @@ function parseSearchResult(rawPtr) { FFF_FILE_ITEM_STRUCT ); items.push(readFileItemFromRaw(rawItem)); @@ -255,7 +276,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -824,6 +856,7 @@ function parseDirSearchResult(rawPtr) { +@@ -824,6 +858,7 @@ function parseDirSearchResult(rawPtr) { const count = sr.count; const items = []; const scores = []; @@ -263,7 +284,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_dir_search_result_get_item", -@@ -832,13 +865,15 @@ function parseDirSearchResult(rawPtr) { +@@ -832,13 +867,15 @@ function parseDirSearchResult(rawPtr) { FFF_DIR_ITEM_STRUCT ); items.push(readDirItemFromRaw(rawItem)); @@ -286,7 +307,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -897,6 +932,7 @@ function parseMixedSearchResult(rawPtr) { +@@ -897,6 +934,7 @@ function parseMixedSearchResult(rawPtr) { } const items = []; const scores = []; @@ -294,7 +315,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_mixed_search_result_get_item", -@@ -905,13 +941,15 @@ function parseMixedSearchResult(rawPtr) { +@@ -905,13 +943,15 @@ function parseMixedSearchResult(rawPtr) { FFF_MIXED_ITEM_STRUCT ); items.push(readMixedItemFromRaw(rawItem)); @@ -317,7 +338,83 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -1188,7 +1226,11 @@ var FFF_SCAN_PROGRESS_STRUCT = { +@@ -932,7 +972,7 @@ function parseMixedSearchResult(rawPtr) { + } + return { ok: true, value: result }; + } +-function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount) { ++function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -966,9 +1006,10 @@ function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, + comboBoostMultiplier, + minComboCount + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseSearchResult) : parseSearchResult(rawPtr); + } + function ffiGlob(handle, pattern, currentFile, maxThreads, pageIndex, pageSize) { + loadLibrary(); +@@ -995,7 +1036,7 @@ function ffiGlob(handle, pattern, currentFile, maxThreads, pageIndex, pageSize) + }); + return parseSearchResult(rawPtr); + } +-function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, pageSize) { ++function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, pageSize, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1016,11 +1057,12 @@ function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, + // page_size + ], + paramsValue: [handle, query, currentFile ?? "", maxThreads, pageIndex, pageSize], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseDirSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseDirSearchResult) : parseDirSearchResult(rawPtr); + } +-function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount) { ++function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1054,11 +1096,12 @@ function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageS + comboBoostMultiplier, + minComboCount + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseMixedSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseMixedSearchResult) : parseMixedSearchResult(rawPtr); + } +-function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions) { ++function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1107,9 +1150,10 @@ function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartC + afterContext, + classifyDefinitions + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseGrepResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseGrepResult) : parseGrepResult(rawPtr); + } + function ffiMultiGrep(handle, patternsJoined, constraints, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions) { + loadLibrary(); +@@ -1188,7 +1232,11 @@ var FFF_SCAN_PROGRESS_STRUCT = { }; function ffiGetScanProgress(handle) { loadLibrary(); @@ -330,16 +427,18 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a if ("ok" in res) return res; const handlePtr = res.struct.handle; freeResult(res.rawPtr); -@@ -1429,6 +1471,8 @@ var FileFinder = class _FileFinder { +@@ -1429,6 +1477,10 @@ var FileFinder = class _FileFinder { handle; /** Native ids of this instance's active watch subscriptions. */ watchers = /* @__PURE__ */ new Set(); + /** Patched in T3 Code: in-flight native-thread calls by function name. */ + nativeThreadCalls = /* @__PURE__ */ new Map(); ++ /** Patched in T3 Code: the last search queued on a native thread. */ ++ nativeSearchTail = null; constructor(handle) { this.handle = handle; } -@@ -1485,12 +1529,37 @@ var FileFinder = class _FileFinder { +@@ -1485,12 +1537,115 @@ var FileFinder = class _FileFinder { destroy() { if (this.handle !== null) { const handle = this.handle; @@ -349,13 +448,15 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a - ffiWatchCleanupAfterDestroy(handle, this.watchers); - this.watchers.clear(); + this.watchers = /* @__PURE__ */ new Set(); -+ const release = () => { -+ ffiDestroy(handle); ++ // Patched in T3 Code: freeing the handle waits on the picker lock, so it runs on a ++ // native thread, after any native-thread call still using the handle. ++ const release = () => ffiDestroy(handle).finally(() => { + ffiWatchCleanupAfterDestroy(handle, watchers); -+ }; -+ // Patched in T3 Code: a native-thread call may still be using the handle. -+ if (this.nativeThreadCalls.size > 0) { -+ Promise.allSettled(this.nativeThreadCalls.values()).then(release); ++ }); ++ const pending = [...this.nativeThreadCalls.values()]; ++ if (this.nativeSearchTail) pending.push(this.nativeSearchTail); ++ if (pending.length > 0) { ++ Promise.allSettled(pending).then(release); + } else { + release(); + } @@ -376,11 +477,87 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a + }); + this.nativeThreadCalls.set(funcName, call); + return call; ++ } ++ /** ++ * Patched in T3 Code: runs a search on a libuv pool thread. Searches on one finder run one at ++ * a time, so while fff's watcher holds the picker lock a finder holds at most one pool thread. ++ */ ++ searchOnNativeThread(search) { ++ const guard = this.ensureAlive(); ++ if (!guard.ok) return Promise.resolve(guard); ++ const call = (this.nativeSearchTail ?? Promise.resolve()).then(() => { ++ const alive = this.ensureAlive(); ++ return alive.ok ? search(alive.value) : alive; ++ }).catch((cause) => err(String(cause))); ++ this.nativeSearchTail = call; ++ call.finally(() => { ++ if (this.nativeSearchTail === call) this.nativeSearchTail = null; ++ }); ++ return call; ++ } ++ /** Patched in T3 Code: `fileSearch` without blocking the JS thread. */ ++ fileSearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearch( ++ handle, ++ query, ++ options?.currentFile ?? "", ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ options?.comboBoostMultiplier ?? 0, ++ options?.minComboCount ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `directorySearch` without blocking the JS thread. */ ++ directorySearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearchDirectories( ++ handle, ++ query, ++ options?.currentFile ?? null, ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `mixedSearch` without blocking the JS thread. */ ++ mixedSearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearchMixed( ++ handle, ++ query, ++ options?.currentFile ?? "", ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ options?.comboBoostMultiplier ?? 0, ++ options?.minComboCount ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `grep` without blocking the JS thread. */ ++ grepAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiLiveGrep( ++ handle, ++ query, ++ options?.mode ?? "plain", ++ options?.maxFileSize ?? 0, ++ options?.maxMatchesPerFile ?? 0, ++ options?.smartCase ?? true, ++ options?.cursor?._offset ?? 0, ++ options?.pageSize ?? 0, ++ options?.timeBudgetMs ?? 0, ++ options?.enforceTimeBudget ?? false, ++ options?.beforeContext ?? 0, ++ options?.afterContext ?? 0, ++ options?.classifyDefinitions ?? false, ++ true ++ )); + } /** * Check if this instance has been destroyed. */ -@@ -1757,6 +1826,18 @@ var FileFinder = class _FileFinder { +@@ -1757,6 +1912,18 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; return ffiScanFiles(guard.value); } @@ -399,7 +576,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a /** * Check if a scan is currently in progress. */ -@@ -1838,7 +1919,8 @@ var FileFinder = class _FileFinder { +@@ -1838,7 +2005,8 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; const deadline = Date.now() + timeoutMs; while (true) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 744838ebefa9b..bbca8110a7837 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,7 +364,7 @@ patchedDependencies: '@cursor/sdk@1.2.0': 57f502128bf6accd976963b126e227c86a45d815a53a8dc450461beae3bed4a4 '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 '@expo/ui@58.0.27': 9f2547678939f4ec78fe45163b6455aad63d3c80353d7e0cc783f6a61a7ccc2f - '@ff-labs/fff-node@0.11.0': a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768 + '@ff-labs/fff-node@0.11.0': 3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.7.0-beta.3': 4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8 @@ -857,7 +857,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.3)(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.11.0 - version: 0.11.0(patch_hash=a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768) + version: 0.11.0(patch_hash=3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e) '@muse-code/sdk': specifier: 1.4.6 version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) @@ -15148,7 +15148,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.11.0': optional: true - '@ff-labs/fff-node@0.11.0(patch_hash=a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768)': + '@ff-labs/fff-node@0.11.0(patch_hash=3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e)': dependencies: ffi-rs: 1.3.2 optionalDependencies: From 9d7e5b6dbac33289048658d7b20064d7d99926c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 18:41:47 +0000 Subject: [PATCH 2/2] fix(server): workspace search reads fff's scan state off the JS thread fff's isScanning takes the same picker lock the watcher holds while it reads a new file, so the symlink scan and the timed-out initial scan check could still block the event loop before a search went off-thread. Both now read scan progress through getScanProgressAsync. Co-Authored-By: Claude Opus 5.5 --- .../workspace/WorkspaceSearchIndex.test.ts | 29 +++++++++++++++---- .../src/workspace/WorkspaceSearchIndex.ts | 17 ++++++++--- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 9924720edbe97..45102e4c1cfea 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -59,7 +59,10 @@ it.effect("filters image searches before applying the result limit", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), fileSearchAsync: fileSearch, } as unknown as FileFinder; @@ -107,7 +110,10 @@ it.effect("fetches past a first page of Git-ignored results", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), fileSearchAsync: fileSearch, } as unknown as FileFinder; @@ -131,7 +137,10 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), fileSearchAsync: vi.fn(async () => ({ ok: true as const, @@ -195,7 +204,10 @@ it.effect("continues whole-word searches after a filtered grep page", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), grepAsync: grep, } as unknown as FileFinder; @@ -260,7 +272,14 @@ function fakeSymlinkFinder( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + // fff's isScanning waits on the picker lock on the JS thread. + isScanning: () => { + throw new Error("isScanning must not run on the JS thread"); + }, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: (callback: (events: ReadonlyArray) => void) => { if (watchFailures > 0) { watchFailures -= 1; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index bf98f76dc9012..22e307c182616 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -579,7 +579,16 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }), ); - const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); + // fff's isScanning takes the picker lock too, so progress is read off the JS thread. + const isScanning = Effect.promise(async () => { + try { + const progress = await finder.getScanProgressAsync(); + return !progress.ok || progress.value.isScanning; + } catch { + return true; + } + }); + const hasIncompleteInitialScan = () => (initialScanTimedOut ? isScanning : Effect.succeed(false)); const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, @@ -685,7 +694,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }); const scanSymlinkEntries = Effect.fn("WorkspaceSearchIndex.scanSymlinkEntries")(function* () { - const indexComplete = !finder.isScanning(); + const indexComplete = !(yield* isScanning); const directories = yield* listIndexedDirectories(); const scanned = yield* Effect.forEach( ["", ...directories], @@ -898,7 +907,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); const result = yield* searchVisible( "", WORKSPACE_INDEX_PAGE_SIZE, @@ -976,7 +985,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( // matching is subsequence-based, so dropping them still matches "[id]" and "a{b}" paths. // "{" is dropped only when the query finds nothing as a glob, so "*.{ts,tsx}" keeps working. const query = rawQuery.replaceAll("[", ""); - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); let mapped = yield* searchNames(query, limit, kind, imageOnly); if (mapped.entries.length === 0 && query.includes("{")) { mapped = yield* searchNames(query.replaceAll("{", ""), limit, kind, imageOnly);