Skip to content

fix(server): file search no longer freezes the server while fff's watcher reads a large file - #36273

Merged
juliusmarminge merged 2 commits into
mainfrom
factory/issue-11306-a1794592249
Nov 13, 2026
Merged

juliusmarminge merged 2 commits into
mainfrom
factory/issue-11306-a1794592249

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Review item confirmed and fixed: fff's isScanning takes the picker lock, so WorkspaceSearchIndex's two remaining sync calls could still block the event loop while the watcher reads a large file. Those calls were scanSymlinkEntries' indexComplete check, which runs on a first or stale symlink scan before a name search, and hasIncompleteInitialScan after an initial scan timeout. Both now go through one isScanning Effect that awaits finder.getScanProgressAsync(), the existing off-thread progress read added by the fff-node patch in #35460 (runInNewThread, shared by concurrent callers per finder). A failed or rejected read counts as still scanning: search marks the result truncated and keeps the symlink scan result only briefly. hasIncompleteInitialScan returns an Effect, so list and search now yield it. The fff patch is unchanged from ff77366. Not re-measured against a large file in this round; the fix relies on getScanProgressAsync already being off-thread.

Fixes #11306

Reproduction on main

Commit 9b5d997 (origin/main). (1) Mechanism probe: @ff-labs/fff-node 0.11.0 from apps/server, FileFinder.create on a 50-file non-git folder with the same options WorkspaceSearchIndex uses; renamed a 6 GB plain-text file whose page cache had been dropped (posix_fadvise DONTNEED) into the folder; the next synchronous finder.mixedSearch("") blocked the Node event loop for 10,794 ms. (2) Real app: T3CODE_DEV_SEED=0 vp run dev (fresh database), web client driven by headless Chromium (Playwright). Added a non-git project with 50 small files via t3 project add, created a thread in it with the Replay (dev) provider, opened that thread, and typed @src in the composer (projects.searchEntries -> WorkspaceSearchIndex.search -> mixedSearch). Then moved the uncached 6 GB text file into the project root and typed one more character every ~750 ms, while a loop fetched /api/projects from the server every 200 ms. Result: a single WorkspaceSearchIndex.runSearch span took 9,470 ms. Unrelated RPCs (orchestration.searchThreads, server.refreshProviders, server.reportClientActivity) were queued behind it for up to 9.5 s. The HTTP probe took 9,365 ms (baseline under 210 ms). The composer popup sat on 'Searching workspace files…'. The same steps with a git repository as the project did not stall (searches 5-40 ms, probes under 210 ms), so the freeze shows on non-git roots such as a home directory, as reported.

Evidence

Fresh databases, Linux web dev servers, headless Chromium, 1440×900, dark theme, and Replay (dev) on both sides. In matching non-git projects with 50 small files, type @src, rename an uncached 6 GiB text file into the root, and continue typing every approximately 750 ms while probing /api/projects every 200 ms.

Before After
Before recording After recording
2d5e0ae2bd 9d7e5b6dba
Search span: 14,884 ms. Unrelated HTTP request: 14,890 ms; baseline maximum 31 ms. Composer remains on “Searching workspace files…”. Search still waits 12,866 ms, but the slowest unrelated HTTP request is 95 ms; baseline maximum 33 ms. All 154 probes return HTTP 200.

The fix isolates the slow search from the server event loop. Recordings include an explicitly labeled external measurement display; host information is redacted.

Browser driver · Synthetic seed and setup · Measurements and span durations

Not shown:

  • A minutes-long stall and real home-directory contents were not exercised; the synthetic non-git root reproduced the watcher-lock freeze for approximately 15 seconds without accessing private files.
  • Other search entry points and the initial-scan timeout path were not exercised.

Tests

vp test run src/workspace/ src/orchestration-v2/RunFinalizationService.test.ts (apps/server): 6 files, 86 tests passed. vp test run src/workspace/WorkspaceSearchIndex.test.ts, with fakeSymlinkFinder's isScanning now throwing: 10 passed with the fix; with WorkspaceSearchIndex.ts reverted to ff77366, 8 failed. vp run check:changed: unused patches, format, lint, knip 1/2 and 2/2, graphql, typecheck (41 packages), tests (3 files) all passed.

Not in this change

  • If four or more finders wait on fff's lock at once, the shared 4-thread libuv pool (also used by fs and dns) can still fill; raising UV_THREADPOOL_SIZE or adding a global cap would be a separate change.
  • fff's watcher still reads each new file to EOF with no size cap while holding the picker lock (an upstream fff issue); searches on that one index stay slow until the read finishes.
  • Searches queued behind a held lock still run after it is released even when their RPC was interrupted; dropping superseded queries would be a separate change.
  • If four or more finders wait on fff's lock at once, the shared 4-thread libuv pool can still fill; raising UV_THREADPOOL_SIZE or a global cap would be a separate change.

Who did the work

  • Written by Claude Opus 5.5, in Claude Code
  • Reviewed by GPT-6.1 Sol, in Codex, and by Claude Opus 5.5, each on its own
  • Proof captured by GPT-6.1 Sol and checked by Claude Opus 5.5

Run by the t3code factory in T3 Code.

juliusmarminge and others added 2 commits November 13, 2026 18:27
…cher 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 #11306

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted Author is trusted by repo permissions or the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Nov 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Codex Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Codex Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Codex Live turn WebSocket decoded 21.5 KiB 21.5 KiB 0 B (0.0%) 24.4 KiB ✅
Codex Live turn messages 1 1 0 (0.0%) 8 ✅
Claude Total thread wire 5.0 KiB 5.0 KiB 0 B (0.0%) 6.8 KiB ✅
Claude Thread snapshot wire 3.8 KiB 3.8 KiB 0 B (0.0%) 4.9 KiB ✅
Claude Live turn WebSocket wire 1.2 KiB 1.2 KiB 0 B (0.0%) 2.0 KiB ✅
Claude Live turn WebSocket decoded 21.9 KiB 21.9 KiB 0 B (0.0%) 24.4 KiB ✅
Claude Live turn messages 1 1 0 (0.0%) 8 ✅

Other scenarios

All 64 scenario measurements match main.

All 64 scenario measurements
Provider Scenario Metric Main baseline This PR Impact PR ceiling
Codex short turn resume WebSocket decoded 10.5 KiB 10.5 KiB 0 B (0.0%) 12.7 KiB ✅
Codex short turn resume WebSocket messages 2 2 0 (0.0%) 2 ✅
Codex long turn running, cold open HTTP decoded 281.7 KiB 281.7 KiB 0 B (0.0%) 302.7 KiB ✅
Codex long turn running, cold open rows 75 75 0 (0.0%) 75 ✅
Codex long turn running, history page HTTP decoded 92.4 KiB 92.4 KiB 0 B (0.0%) 107.4 KiB ✅
Codex long turn running, history page rows 75 75 0 (0.0%) 75 ✅
Codex long turn finished, cold open HTTP decoded 381.5 KiB 381.5 KiB 0 B (0.0%) 410.2 KiB ✅
Codex long turn finished, cold open rows 151 151 0 (0.0%) 151 ✅
Codex long turn finished, socket cold open WebSocket decoded 381.6 KiB 381.6 KiB 0 B (0.0%) 410.2 KiB ✅
Codex long turn finished, socket cold open WebSocket messages 2 2 0 (0.0%) 2 ✅
Codex long turn finished, socket cold open rows 151 151 0 (0.0%) 151 ✅
Codex long turn finished, work page HTTP decoded 186.2 KiB 186.2 KiB 0 B (0.0%) 200.2 KiB ✅
Codex long turn finished, work page rows 150 150 0 (0.0%) 150 ✅
Codex idle thread resume WebSocket decoded 65 B 65 B 0 B (0.0%) 200 B ✅
Codex idle thread resume WebSocket messages 1 1 0 (0.0%) 1 ✅
Codex fleet shell snapshot HTTP decoded 42.4 KiB 42.4 KiB 0 B (0.0%) 53.7 KiB ✅
Codex fleet shell snapshot rows 27 27 0 (0.0%) 27 ✅
Codex fleet shell subscribe WebSocket decoded 65 B 65 B 0 B (0.0%) 200 B ✅
Codex fleet shell subscribe WebSocket messages 1 1 0 (0.0%) 1 ✅
Codex fleet shell resume check HTTP decoded 18 B 18 B 0 B (0.0%) 100 B ✅
Codex fleet shell resume check rows 0 0 0 0 ✅
Codex stale shell reload HTTP decoded 42.4 KiB 42.4 KiB 0 B (0.0%) 53.7 KiB ✅
Codex stale shell reload rows 27 27 0 (0.0%) 27 ✅
Codex stale shell resume WebSocket decoded 140 B 140 B 0 B (0.0%) 400 B ✅
Codex stale shell resume WebSocket messages 2 2 0 (0.0%) 2 ✅
Codex stale todo list cold open HTTP decoded 72.4 KiB 72.4 KiB 0 B (0.0%) 83.0 KiB ✅
Codex stale todo list cold open rows 75 75 0 (0.0%) 75 ✅
Codex stale todo list cold open nodes 2 2 0 (0.0%) 4 ✅
Codex stale todo list cold open subagents 2 2 0 (0.0%) 4 ✅
Codex stale todo list cold open plans 1 1 0 (0.0%) 2 ✅
Codex stale todo list cold open providerTurns 1 1 0 (0.0%) 2 ✅
Codex stale todo list cold open checkpoints 1 1 0 (0.0%) 2 ✅
Claude short turn resume WebSocket decoded 10.6 KiB 10.6 KiB 0 B (0.0%) 12.7 KiB ✅
Claude short turn resume WebSocket messages 2 2 0 (0.0%) 2 ✅
Claude long turn running, cold open HTTP decoded 283.3 KiB 283.3 KiB 0 B (0.0%) 302.7 KiB ✅
Claude long turn running, cold open rows 75 75 0 (0.0%) 75 ✅
Claude long turn running, history page HTTP decoded 93.4 KiB 93.4 KiB 0 B (0.0%) 107.4 KiB ✅
Claude long turn running, history page rows 75 75 0 (0.0%) 75 ✅
Claude long turn finished, cold open HTTP decoded 384.2 KiB 384.2 KiB 0 B (0.0%) 410.2 KiB ✅
Claude long turn finished, cold open rows 151 151 0 (0.0%) 151 ✅
Claude long turn finished, socket cold open WebSocket decoded 384.3 KiB 384.3 KiB 0 B (0.0%) 410.2 KiB ✅
Claude long turn finished, socket cold open WebSocket messages 2 2 0 (0.0%) 2 ✅
Claude long turn finished, socket cold open rows 151 151 0 (0.0%) 151 ✅
Claude long turn finished, work page HTTP decoded 188.1 KiB 188.1 KiB 0 B (0.0%) 200.2 KiB ✅
Claude long turn finished, work page rows 150 150 0 (0.0%) 150 ✅
Claude idle thread resume WebSocket decoded 66 B 66 B 0 B (0.0%) 200 B ✅
Claude idle thread resume WebSocket messages 1 1 0 (0.0%) 1 ✅
Claude fleet shell snapshot HTTP decoded 43.0 KiB 43.0 KiB 0 B (0.0%) 53.7 KiB ✅
Claude fleet shell snapshot rows 27 27 0 (0.0%) 27 ✅
Claude fleet shell subscribe WebSocket decoded 66 B 66 B 0 B (0.0%) 200 B ✅
Claude fleet shell subscribe WebSocket messages 1 1 0 (0.0%) 1 ✅
Claude fleet shell resume check HTTP decoded 18 B 18 B 0 B (0.0%) 100 B ✅
Claude fleet shell resume check rows 0 0 0 0 ✅
Claude stale shell reload HTTP decoded 43.0 KiB 43.0 KiB 0 B (0.0%) 53.7 KiB ✅
Claude stale shell reload rows 27 27 0 (0.0%) 27 ✅
Claude stale shell resume WebSocket decoded 140 B 140 B 0 B (0.0%) 400 B ✅
Claude stale shell resume WebSocket messages 2 2 0 (0.0%) 2 ✅
Claude stale todo list cold open HTTP decoded 73.4 KiB 73.4 KiB 0 B (0.0%) 83.0 KiB ✅
Claude stale todo list cold open rows 75 75 0 (0.0%) 75 ✅
Claude stale todo list cold open nodes 2 2 0 (0.0%) 4 ✅
Claude stale todo list cold open subagents 2 2 0 (0.0%) 4 ✅
Claude stale todo list cold open plans 1 1 0 (0.0%) 2 ✅
Claude stale todo list cold open providerTurns 1 1 0 (0.0%) 2 ✅
Claude stale todo list cold open checkpoints 1 1 0 (0.0%) 2 ✅

Baseline: 065aca4 · PR result: 9d7e5b6 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 108.5 KiB
  • Claude decoded thread snapshot: 108.8 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@macroscopeapp

macroscopeapp Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved at 9d7e5b6

Macroscope's review found this PR approvable — Workspace searches and finder cleanup move off the JavaScript thread to avoid freezing the server during watcher lock contention. Searches are serialized per finder, and cleanup waits for pending native calls; no product-default or diagnostic-suppression changes are introduced.

You can add or adjust custom eligibility rules. Learn more.

@coderabbitai

coderabbitai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Explain Change →

No actionable comments were generated in the recent review.

Walkthrough

FileFinder adds asynchronous native operations. WorkspaceSearchIndex uses asynchronous search and scan-progress calls for workspace listing and search. Tests use asynchronous FileFinder mocks and check that selected paths do not call synchronous APIs.

Changes: Workspace search

Layer / File(s) Summary
FileFinder asynchronous operations
patches/@ff-labs__fff-node@0.11.0.patch
FileFinder adds asynchronous scan, search, grep, and scan-progress methods that run native operations on native threads. Searches are serialized, destruction waits for pending work, and parsing changes include a 200-item limit for per-item scores.
Workspace search integration
apps/server/src/workspace/WorkspaceSearchIndex.ts, apps/server/src/workspace/WorkspaceSearchIndex.test.ts, apps/server/src/workspace/WorkspaceEntries.test.ts
WorkspaceSearchIndex awaits asynchronous scan checks and search operations. Tests use asynchronous mocks and guard against selected synchronous calls.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: High

Sequence Diagram(s)

sequenceDiagram
  participant WorkspaceSearchIndex
  participant FileFinder
  participant NativeLoader
  WorkspaceSearchIndex->>FileFinder: Request async search or scan progress
  FileFinder->>NativeLoader: Run FFI operation on native thread
  NativeLoader-->>FileFinder: Return operation result
  FileFinder-->>WorkspaceSearchIndex: Resolve asynchronous result
Loading

Suggested reviewers: t3dotgg, sidhartha1s

Merge Risk: Minimal · up to 9d7e5

This change moves workspace file search and scan-progress checks off the server's main thread, so a busy file watcher should no longer freeze the Files panel. No actionable merge-blocking risk was found. The fix has not been re-measured against a very large file.

Pre-merge checks: Passed 3 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Out of Scope Changes check Inconclusive The core changes are in scope: WorkspaceSearchIndex.ts, its tests, WorkspaceEntries.test.ts (a grep guard), and the fff-node patch that supplies getScanProgressAsync and the other async methods. The p… Explain in the PR description why each non-async patch change (CJS export path, asar.unpacked binary resolution, FFI string decoding and types, path_alignment_bonus) is required for this fix. If one is not required, move it to a separate ch…
Passed Passed checks: 3 passed

Check name Status Explanation
Title check Passed The title names the main change: file search in the server no longer blocks while fff's watcher reads a large file. The changeset moves WorkspaceSearchIndex's remaining synchronous fff scan-state read…
Linked Issues check Passed [#11306] Requirement: synchronous file-search work must not block the backend event loop, and a busy watcher must not hang the Files panel call. The change summary for apps/server/src/workspace/Worksp…
Description check Passed The description follows the repository template closely. It has Problem, Change, Scope, and Verification sections, and it links issue #11306. The Problem section explains the event-loop stall from fff…
Full details: Title check

Explanation

The title names the main change: file search in the server no longer blocks while fff's watcher reads a large file. The changeset moves WorkspaceSearchIndex's remaining synchronous fff scan-state reads (isScanning and hasIncompleteInitialScan) to the asynchronous getScanProgressAsync path, which addresses that freeze. The title follows a conventional commit format and is specific.

Full details: Linked Issues check

Explanation

[#11306] Requirement: synchronous file-search work must not block the backend event loop, and a busy watcher must not hang the Files panel call. The change summary for apps/server/src/workspace/WorkspaceSearchIndex.ts states that searches, scan-progress checks, and refresh scans move from synchronous FileFinder calls to async counterparts (mixedSearchAsync, fileSearchAsync, grepAsync, getScanProgressAsync). The symlink indexComplete check and hasIncompleteInitialScan now use one isScanning Effect that awaits getScanProgressAsync(). A failed or rejected read counts as still scanning, so the search returns a truncated result instead of failing. The fff-node patch adds the async methods on native threads. Tests in WorkspaceSearchIndex.test.ts were updated to async mocks, and the symlink finder's isScanning mock throws on the JS thread. The PR reports 10 passing tests with the fix and 8 failures with the old file. The issue's suggested fix (off-thread mixedSearch and isScanning) is implemented in code. The PR states that the real-app timing was not re-measured after the fix, so the runtime improvement is not yet measured. Home-directory scanning defaults are not changed, which the issue lists only as an optional alternative.

Full details: Description check

Explanation

The description follows the repository template closely. It has Problem, Change, Scope, and Verification sections, and it links issue #11306. The Problem section explains the event-loop stall from fff's picker lock. The Change section names the two synchronous calls that now go through getScanProgressAsync and states the failure behavior. The Scope section links the issue that shows the bug. The Verification section gives specific test commands, counts, and before/after measurements, and it lists what was not checked. It also names the models and harness used, as the template asks. The description is long and includes a 'Not in this change' section, which goes beyond the template. Those parts are non-critical, so they do not lower the assessment. The title is absent from the provided text, so the conventional commit title requirement cannot be verified. That is a minor gap.

Full details: Out of Scope Changes check

Explanation

The core changes are in scope: WorkspaceSearchIndex.ts, its tests, WorkspaceEntries.test.ts (a grep guard), and the fff-node patch that supplies getScanProgressAsync and the other async methods. The patch diff against the merge base is +204/-27 and includes items that the PR description does not mention: the CommonJS package export changing from dist/index.cjs to dist/index.js, NPM-package binary resolution that checks for an .asar.unpacked counterpart, FFI string-pointer decoding and string data-type changes, and a path_alignment_bonus score field. The PR description says the patch is unchanged from ff77366 and that the async progress read comes from #35460. It does not explain why those other patch changes are needed for this issue. Whether they come from #35460 or are unrelated could not be established from the summary alone, and no repository inspection was performed in this assessment.

Resolution

Explain in the PR description why each non-async patch change (CJS export path, asar.unpacked binary resolution, FFI string decoding and types, path_alignment_bonus) is required for this fix. If one is not required, move it to a separate change or remove it. If #35460 already contains these changes, confirm that by citing the base revision that includes them.

Finishing Touches

Generate unit tests (beta)

  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Recent review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 065aca4 and 9d7e5b6.

Files ignored due to path filters: 1

  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
Files selected for processing: 4

  • apps/server/src/workspace/WorkspaceEntries.test.ts
  • apps/server/src/workspace/WorkspaceSearchIndex.test.ts
  • apps/server/src/workspace/WorkspaceSearchIndex.ts
  • patches/@ff-labs__fff-node@0.11.0.patch

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:trusted Author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

1 participant