Skip to content

[Bug]: Windows, large repo: run finalization forces a full fff rescan every turn, blocking the backend event loop 15-52 s on the picker lock #22586

Open

Description

@mikeohuo

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

This has the same lock mechanism as #11306, but a different trigger and setup. #11306 is a macOS home-folder project and a search or destroy call. This report is a normal project root (a large monorepo, .gitignore honored) on Windows, where the trigger is the forced rescan after every agent turn.

Area

apps/server

Steps to reproduce

  1. On Windows, open a large repository as a project. Mine is a Unity game monorepo with about 272k tracked files. About 21 GB of those files are 10 MiB or smaller, and many are large YAML text assets (.prefab, .unity, .asset).
  2. Use anything that creates the workspace index for that project: the composer @-mention picker, a file link in chat, or the Files panel.
  3. Run several agent threads in that project at the same time (I run about 12). The agents edit files.
  4. Wait for runs to finish. Each finished run calls WorkspaceSearchIndex.refresh.

Expected behavior

A finished agent run does not stop the backend event loop. The file watcher already keeps the index current, so a run does not need a full rescan.

Actual behavior

The backend stops answering for 15 to 52 s, many times per hour. The desktop shows SocketReadError: An error occurred during Read, then "did not answer" or "reconnecting". The client ping timeout is 15 s (packages/client-runtime/src/rpc/protocol.ts:12), so every block longer than about 15 s drops all clients.

These are the freezes in server.trace.ndjson on 2026-10-21 (UTC). In each one, no span starts or ends anywhere in the process:

Freeze start Duration
00:15:35 51.1 s
00:17:02 22.8 s
00:18:04 15.0 s
00:20:46 42.7 s
00:25:26 23.8 s
00:39:19 32.8 s
00:44:25 36.7 s

Where it blocks

  • In every gap, WorkspaceSearchIndex.refresh is the last span that starts.
  • Its child WorkspaceSearchIndex.waitForIndexReady is the first span after the gap. That child takes 0 to 1.6 s.
  • The only code between the two spans is finder.scanFiles() (apps/server/src/workspace/WorkspaceSearchIndex.ts:417).
  • fff-node 0.11.0 calls fff_scan_files through ffi-rs without runInNewThread, so the call runs on the JS thread.
  • fff_scan_files returns at once. Before that, ScanJob::new_rescan takes shared_picker.read() (scan.rs:69). The block is the JS thread waiting on that parking_lot lock, as in the samples in [Bug]: Backend event loop blocks for minutes inside synchronous fff mixedSearch waiting on watcher lock (home-directory project) — shows as "<label> is reconnecting" #11306.
  • Not every refresh blocks. 15 of 20 calls in one window returned in under 1 ms. The long ones occur when the lock is contended.

Why a large repository on Windows makes this worse

  • The trigger: RunFinalizationService.ts:101-106 calls workspaceEntries.refresh(cwd) after every run. If an index exists for that cwd, WorkspaceEntries.ts:161-198 calls WorkspaceSearchIndex.refresh, which calls scanFiles(). With about 12 threads, a refresh arrives every 30 to 60 s.
  • The sniff: each rescan ends in run_post_scan, which calls sniff_binary_for_non_indexable for the paths index. That runs serially on one thread and reads every file of 10 MiB or less to EOF in 16 KiB chunks (bigram_filter.rs:1161-1187, types.rs:572-600). On this repository that is about 21 GB per pass.
  • The status: a full libgit2 status of the whole repository then runs on the fff-git-status thread.
  • No idle time: a rescan requested during a pass runs immediately after it (rescan_pending). The scanner is almost never idle.
  • Measured reads: the backend process read 5.9 TB in about 8 hours, at 0.6 to 1.5 GB/s. The average read size is 15.9 KiB, which matches the 16 KiB sniff chunk. This rate matches the \PhysicalDisk read counter, so these are cold reads from the disk, not cache hits.
  • Measured CPU per thread: fff-bg-0 4,365 s, fff-git-status 8,256 s (one full core), fff-watcher-own 1,319 s. The thread names come from GetThreadDescription.
  • Kernel time: one stall record shows 50.5 s of system CPU and 4.0 s of user CPU. This agrees with kernel file I/O on the fff threads (NTFS and Defender real-time scanning) while the JS thread waits. That link is an inference.
  • The rescan is redundant: the watcher is already on, because T3 does not pass disableWatch. On Windows the watcher is one recursive watch on the base folder.

The fff fix does not remove this

fff PR 899 (merged 2026-10-11, in 0.11.1-nightly builds only) stops the read at the first NUL byte and caps the read at MAX_FFFILE_SIZE. Binary files contain NUL bytes, but text files do not. On a repository with many large text files, each pass still reads those files to EOF. The T3-side trigger is still the main cost.

Possible fixes, smallest first

  1. Do not force scanFiles() on run finalization while the watcher is active. At minimum, make it single-flight with a trailing debounce for each cwd.
  2. Keep fff calls off the event loop. Use ffi-rs runInNewThread or a worker, so a lock wait cannot stop the backend. This also covers the fff_destroy path in [Bug]: Backend event loop blocks for minutes inside synchronous fff mixedSearch waiting on watcher lock (home-directory project) — shows as "<label> is reconnecting" #11306.
  3. When a stable fff release has PR 899, update to it.
  4. Optional: respect the repository's core.fsmonitor. T3's status calls pass -c core.fsmonitor=false, so on a large Windows repository every status check visits all tracked files.

Related

Impact

Major degradation or frequent failure

Version or commit

0.1.1-nightly.20261020.3411 (tag commit d36b7da248). Still present on main @ 8cd561ef6f, which pins @ff-labs/fff-node 0.11.0.

Environment

Windows 11 Pro (10.0.26300), x64, NTFS, Microsoft Defender real-time scanning on. T3 Code Nightly desktop app. The backend runs as the Electron-as-Node child process. fff-node 0.11.0 (fff-bin-win32-x64). Claude and Codex agent threads, about 12 running at the same time.

Activity

  1. juliusmarminge commented on Oct 21, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Thanks for the trace analysis. The trigger matches current main. After every run, RunFinalizationService refreshes the workspace entries for the run's directory (apps/server/src/orchestration-v2/RunFinalizationService.ts:102-106). When a search index exists for that directory, this calls WorkspaceSearchIndex.refresh (apps/server/src/workspace/WorkspaceEntries.ts:161-188), which calls finder.scanFiles() directly (apps/server/src/workspace/WorkspaceSearchIndex.ts:413-417). Nothing on that path skips the rescan when the index's file watcher is already keeping it current. So with many threads finishing in one large project, every finished run asks fff to rescan.

    The freeze itself has the same root as #11306: a synchronous fff call on the server's main thread waits on fff's own lock while the index is busy. That part sits partly in how fff runs its calls. The forced rescan after every run is a separate trigger on our side, and it can be avoided independently. Keeping this open alongside #11306 and accepting it as a bug.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Oct 21, 2026
  3. added a commit that references this issue on Oct 21, 2026
    cfa3be8
  4. mikeohuo commented on Oct 26, 2026

    @mikeohuo
    Author

    Note

    Investigated by Claude Fable 5.1 and Claude Opus 5.5 in Claude Code, with a second read by GPT-6 Astra; written on behalf of the account owner.

    Still present on 0.1.3-nightly.20261026.3726 (bundles @ff-labs/fff-node 0.11.0), same Windows machine and the same Unity repo (274,819 tracked files, 29.7 GB), now with 16 Claude sessions running at once. The numbers are worse than on 21 October, and the blocks now surface as SQLite lock waits, which is why #24895 and #24660 can look like the cause on a Windows desktop with a large repo.

    Rescans, from 107 minutes of server.trace.ndjson

    • WorkspaceSearchIndex.refresh: 48 calls, 975 s in total, p90 85 s, max 138 s. 14 of the 48 exceeded the 15 s client ping timeout. Every one has the parent chain WorkspaceEntries.refresh < RunFinalizationService.refresh < RunFinalizationService.finalize, and on current main the path is unchanged: RunFinalizationService.ts:104 → WorkspaceEntries.refresh → WorkspaceSearchIndex.ts:408 finder.scanFiles(). The waitForIndexReady child took 0 to 3.4 s, so the rest of each refresh is inside scanFiles().
    • Of the 13 intervals of 3 s or more in which no span starts or ends, 12 begin at the same instant as a WorkspaceSearchIndex.refresh and end with it. logs\event-loop-stalls.ndjson recorded 85,369 ms and 82,148 ms, matching two of those gaps; the 122 s and 138 s refreshes do not appear in it.
    • Thread names from GetThreadDescription in the server process: fff-git-status at 99 % of a core for the whole window (2,857 s CPU since 17:32), fff-watcher-own 46 %, fff-bg-0 39 %. The main JavaScript thread averages 4 % and, sampled every second during three refreshes, sat at near-zero CPU in a lock-type wait, as in the samples in [Bug]: Backend event loop blocks for minutes inside synchronous fff mixedSearch waiting on watcher lock (home-directory project) — shows as "<label> is reconnecting" #11306.
    • Process reads: median 1.7 GB/s at 116k operations/s during a refresh, 0.7 GB/s at 63k/s outside one, 94 to 98 % served from the file cache. The 16 provider children write about 0.1 MB/s, so this is not child output. No fff threads existed before 17:32 today, and the stall log has nothing over 7 s before then.
    • Turns finish about every 40 s across the 16 sessions, so the index's 15-minute idle expiry is likely never reached once the picker has created it (inference).

    Knock-on into SQLite

    Over 59 minutes: reader lane 10,703 sqlite spans, 2,673 s, of which 2,213 s is db.lock_wait_ms (max 123 s); writer lane 955 spans, 1,189 s, 1,131 s waiting (max 135 s). 21 of the 29 reader waits over 30 s fall inside a long refresh. The statements at the front of the queue are cheap on their own against the same database (OrchestrationEventStore.latestAgentSequence via ThreadPullRequestServiceV2.synchronize: covering-index search, under 1 ms; ProjectionStore.isNodeStored: primary-key read, 0.5 ms). The reader runs in a worker thread and its span ends only when the main thread processes the reply, so every reader span in flight during a block is inflated by the block. Database: 9.2 GB, 3,440 threads, orchestration_events 1.16 M rows, WAL 9 MB.

    One thing not explained by the rescan: an orchestrationV2.EventSink.write transaction held the writer lock for 136 s at 18:03 with the event loop free, and writers queued behind it (db.lock_owner_at_start.name names it). Possibly the checkpoint admission gate from #23482; not established.

    User-visible result

    SessionStore.recordClientConnection up to 70 s, ws.rpc.orchestration.subscribeThread up to 787 s; the desktop shows " is reconnecting" and remote clients "T3 Code on did not answer".

    #22603 is still the fix for the trigger. Happy to re-measure on a build that includes it.

  5. kurtkuehnert commented on Oct 30, 2026

    @kurtkuehnert

    Note

    Investigated by Claude Opus 5.5 in Claude Code; written on behalf of the account owner.

    Another trigger for the same lock, on macOS with desktop 0.1.5 (@ff-labs/fff-node 0.11.0). Here the freeze lasted over 6 minutes, not seconds, because fff's watcher held the lock while it walked a large folder.

    Setup. The project is a folder that isn't a Git repository. It holds about 60 jj workspaces, and each workspace has a symlink into a shared data folder of about 750,000 files and 26 GB.

    What happened

    1. With the Files index open, a new workspace was created, with its link into the data folder.
    2. fff's watcher treated the new link as a new directory and walked the data folder through it. That's [Bug]: the watcher indexes the target of a new directory symlink that the scan skips, and blocks scan_files while it walks it (0.11.0) dmtrKovalenko/fff#980: the scan skips the link, but the watcher follows it.
    3. The next finished run called WorkspaceSearchIndex.refresh → scanFiles(), which waited on the watcher.
    4. The server answered nothing for over 6 minutes, and the desktop showed "did not answer", then "reconnecting".

    A sample of the server showed the JS thread in fff_scan_files → _pthread_cond_wait, and fff-watcher-own in read and open on files under the data folder. server.trace.ndjson had no span in that window. Killing the backend let the desktop start a new one.

    Before we added a root .ignore, the same project also caused the plain version of this report: indexing the whole folder took 17 s for 1.26 million files. Quitting the app during one of those freezes left the server running with PPID 1, as in #22983.

    This supports the second fix in the report above: keeping fff calls off the event loop would have turned a 6-minute freeze into a slow index.

    Workaround. A root .ignore with the data folders and .local/, where the links live. The full index is now 123,000 files in 0.8 s.

  6. luigiwidmore commented on Nov 5, 2026

    @luigiwidmore

    Note

    Investigated by Claude Opus 5.5 in Claude Code; written on behalf of the account owner.

    Another view of the same trigger, on Linux (0.1.x nightly 2026-11-02; same code in 0.1.8-nightly.20261104.4192, @ff-labs/fff-node 0.11.0). Here it is a sustained disk-read storm, not a UI freeze. The project/thread cwd is the home directory (as in #11306).

    What we measured

    • The server process read 130–165 MB/s from disk without a break (read_bytes ≈ 900 GB in 2 days). Per-thread /proc/<pid>/task/*/io puts 100 % of it on fff-bg-0. The SQLite reader/writer threads were at ~0.
    • Sampling the process's open fds shows fff opening ordinary files all over $HOME (SDK sources, cache dirs, other tools' session logs).
    • Runs in home-rooted threads finish every few minutes. Each one calls RunFinalizationService.refresh → WorkspaceEntries.refresh → finder.scanFiles(), which also renews the 15-minute idle TTL, so the index never expires.

    What fff does on each scan (standalone repro with the bundled fff-node)

    On a 244 MB tree (15.6k files), FileFinder.create + warm-up and then each scanFiles() read ~199 MB. That is exactly the total size of all files under 10 MB. It is the same with disableContentIndexing: true, the same with cacheBudgetMax* set to 1, and the same with disableWatch: true. So disableContentIndexing does not stop the full-content read. Every post-run rescan reads the whole tree again, and for $HOME that far exceeds the page cache.

    The guard T3 turns off

    WorkspaceSearchIndex.createFinder passes enableFsRootScanning: true, enableHomeDirScanning: true. With both false, fff refuses the home dir ("Can not run certain FFF features in a file system root or home directories. Consider smaller per-project directories."). We now run a local patch that sets both to false. Threads rooted at $HOME lose the file picker and find-in-files, but nothing gets indexed, so the post-run refresh is a no-op there.

    Suggestions, in addition to #22603 (not rescanning after every run):

    1. Respect fff's home/root guard, or show the user an explicit "index the home directory?" opt-in, instead of hard-coding it on.
    2. Don't renew the idle TTL from the post-run refresh path, so an index nobody is looking at can expire.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions