anomalyco / anomalyco/opencode
fff watcher: GitStatusCache loops even after file changes stop, resulting in 100% cpu usage
@rekram1-node is already working on this.
Since Aug 14, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- Avg merge
- 7h 2m
- Merged PRs (30d)
- 384
Description
Description
Summary
An idle opencode session pins one core at ~100% for many minutes. The hot thread is fff-watcher-own. For every batch of file-watcher events it opens the git repository, computes git status, and frees the repository again. A fresh Repository::open per batch means libgit2 re-reads and re-parses the whole 6.3 MB index every cycle — including a SHA-1 hash over the full file — so each cycle costs hundreds of milliseconds. The event queue drains slower than build tools produce events, and the thread keeps burning CPU long after all file writes stop.
The events that trigger this are all gitignored paths: a Gradle daemon writing .gradle/9.6.0/fileHashes/fileHashes.bin, lock files, and executionHistory.bin. A normal Gradle build writes thousands of files under build/ and .gradle/, so on Android/JVM projects every build re-pins the core.
Observed
- Process at ~100% CPU from launch: 4:30 CPU time in 4:37 elapsed.
ps -Mshows one thread at 98.6%; all others idle. - Verified with
fd --changed-within 60s: zero file modifications in the repo in the previous minute. A freshsamplein the same minute still shows the watcher thread at full load in the same stacks, still insideFilePicker::handle_file_modify— it is draining a backlog, not reacting to live events. .git/indexmtime unchanged throughout, so this is not a feedback loop through the index file.
Stack evidence
5-second sample(1) of the process, fff-watcher-own thread (4159 samples total, 3433 in the watcher loop; trimmed, full files attached):
4159 Thread: fff-watcher-own
3433 thread main loop
├─ 1917 fff_search::git::GitStatusCache::git_status_for_paths (+432)
│ └─ 1916 git2::repo::Repository::status_file
│ └─ 1670 git_status_file
│ └─ 1383 git_repository_index__weakptr → git_index__open → git_index_read
│ ├─ 504 parse_index → git_hash_buf → SHA1DCUpdate ← hashes the whole 6.3 MB index
│ ├─ 492 parse_index → git_index_entrymap_put ← mostly __tolower in put/resize
│ └─ 313 parse_index → index_entry_dup → git_path_str_is_valid (next_hfs_char, strncasecmp)
├─ 1516 fff_search::git::GitStatusCache::git_status_for_paths (+1384)
│ └─ read_status_impl → git2::repo::Repository::statuses → git_status_list_new
├─ 459 git_repository_free
├─ 182 git2::repo::Repository::open
├─ 39 fff_search::file_picker::FilePicker::handle_file_modify
└─ 32 git2::repo::Repository::is_path_ignored
Reading of the numbers:
Repository::open(182) andgit_repository_free(459) both show up hot → the repository handle is not kept alive between event batches.- Because of that,
git_status_filehitsgit_index__open→git_index_read→parse_indexon nearly every call: 1383 of 1670 samples insidegit_status_fileare index re-parsing, dominated by SHA1DC hashing of the index file and per-charactertolowerwhile building the case-insensitive entry map. - On top of the per-path
status_filecalls,read_status_implalso runs a fullgit_status_list_newworktree scan (1516 samples).
Why this hurts
Cost per event batch on this repo: full index parse (SHA-1 over 6.3 MB + 28k-entry map build) + per-path status + a full status list scan. That is hundreds of milliseconds per batch. Gradle touches thousands of gitignored files per build, so the watcher falls behind and pins a core for the whole build plus many minutes afterwards, while the session looks idle.
Configuration that should prevent this, but does not
Global opencode.jsonc already excludes every directory involved, and snapshots are off:
"snapshot": false,
"watcher": {
"ignore": ["node_modules/**", "dist/**", ".git/**", "**/build/**", ".kotlin/**", ".gradle/**", ".venv/**", "build/**"]
}
The events driving the CPU burn are all under .gradle/, which this config explicitly ignores. So watcher.ignore has no effect on the fff watcher — consistent with #37740. snapshot: false rules snapshots out as the trigger (also confirmed by .git/index mtime staying unchanged during the burn)
Expected
- The watcher keeps one open
Repository(or at least the parsed index) per repo and invalidates it on.git/indexchange, instead of open → status → free per batch. - Gitignored paths are dropped before the git-status pipeline runs.
is_path_ignoredappears in the samples, but the expensive status work runs for batches whose only content is gitignored build output. - One batched
statuses()call per debounced event group instead of per-pathstatus_file.
Related
- #37740 —
watcher.ignorenever reaches the embedded fff engine; a root.ignorefile is the only channel fff honors. This report is a concrete consequence: the config above ignores.gradle/**, yet fff still runs its git-status pipeline for.gradleevents.
Local workaround
Per #37740, a root .ignore file is the only ignore channel fff honors.
- Root
.ignorewith.gradle/,.git/,**/build/** .ignoreadded to.git/info/excludeso it stays out of the repo
Plugins
OpenCode version
1.18.18
Steps to reproduce
- Open an opencode session in a large repo (tens of thousands of tracked files; a multi-MB
.git/index). - Run any tool that churns gitignored files in the workspace (a Gradle build is a reliable source; anything writing thousands of files under an ignored directory should do).
- Watch the process: one thread (
fff-watcher-own) sits at ~100% during the churn and long after it stops.sample <pid>shows the stacks above.
Screenshot and/or share link
Operating System
macOS (Darwin 25.6.0), Apple Silicon
Terminal
iTerm2, IntelliJ embedded terminal
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.