You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
watcher.ignore never reaches the embedded fff engine — per-dir inotify watches on every indexed dir (192K watches / 4.5GB RSS on a large root); root .ignore file is the only effective channel #37740
watcher.ignore in opencode.json(c)never reaches the embedded fff engine. fff's scanner honors only gitignore-family channels (.gitignore, .git/info/exclude, .ignore), and on Linux its watcher then registers one non-recursive inotify watch for every directory the picker indexed. On a large multi-repo workspace root this produced 192,033 inotify watches and 2.5–4.5 GB RSS in opencode serve, no matter what watcher.ignore contained.
Placing a ripgrep-style .ignore file at the project root is a complete cure: watches dropped 192,033 → 778 and steady-state RSS ~4.5 GB → 448 MB, with file search still working correctly. This is the root cause behind the class of reports like #23190 (closed stale, never root-caused: "only an actual .gitignore inside a real .git repo avoids it" — that observation was correct, and this is why), and it's the Linux/memory sibling of #32511 (fff content-indexing $HOME on macOS). Likely also relevant to #32930 / #16610 (inotify exhaustion) and the memory megathread #20695.
/data — a multi-repo orchestration workspace, 610,201 directories total
Config
watcher.ignore set (see matrix below)
Checked release notes through 1.18.3 — no watcher/picker/memory fix in the range, so this should still reproduce on current.
Evidence chain
1. The watches belong to fff, and watcher.ignore does not affect them.
Resolving every watched inode (/proc/<pid>/fdinfo/<inotify-fd> → inode → path) accounted for 100% of the watches. The top watched subtrees were exactly the directories listed in watcher.ignore:
14036 /data/state/... ← listed in watcher.ignore
5893 /data/runner/... ← listed in watcher.ignore
... (16 more runner/CI trees, all listed)
2. Config-glob matrix (converged watch counts, each state held stable ≥3 min before reading; the scan takes ~5 min to converge after start):
watcher.ignore form
converged inotify watches
absolute paths (/data/repos/**, …)
192,033 (2 inotify instances)
relative (**/repos/**, …)
96,060 — halved
relative + bare (repos/** added)
192,238 — worse again
any of the above + .ignore file at root
778 (1 instance)
The halving is the tell: there are two watcher instances — opencode's own (which does honor watcher.ignore, with relative globs) and fff's (which never sees it). Config changes can only ever fix the first one.
3. Mechanism, from fff-core source (fff.nvim's Rust engine, embedded in the opencode binary — the fff-scan / fff-watcher-own / notify-rs threads):
crates/fff-core/src/walk/ripgrep.rs — the walk is built with .git_ignore(true) .git_exclude(true) .git_global(true) .ignore(true); custom overrides exist only for non-git roots and only from a hardcoded IGNORED_DIRS const (node_modules, __pycache__, …). There is no channel for embedder-supplied patterns like watcher.ignore.
crates/fff-core/src/watcher/background_watcher.rs — on Linux (no kernel recursion in inotify) it iterates picker.for_each_dir(...) and registers RecursiveMode::NonRecursive per directory. Watch set = indexed set. Whatever the scan doesn't prune, the watcher pays for at ~1 KB kernel memory per watch, plus the picker's own index memory.
4. The .ignore cure, verified end-to-end:
.ignore at the project root with anchored top-level patterns:
/repos/
/runner/
/state/
/logs/
...
Result, reproduced exactly across restarts: 778 watches, 650 MB anon / 448 MB RSS (from ~2.5 GB anon), /find/file returns correct results, excluded trees still directly readable via /file/content (only search indexing is affected).
5. fff rescans dynamically — deleting the .ignore file at runtime ballooned watches back to ~192K within minutes, without a restart. (Good news: the reverse means the file can also be added without restarting, though we restarted to reclaim the watches.)
Why this matters beyond memory
In server mode this compounds: on our host the growth ended in the process wedged in mem_cgroup_handle_over_high (uninterruptible sleep, port listening but never accepting — connections queue in the backlog forever) while systemd still reported it healthy. Any deployment that caps the service with MemoryHigh converts this leak into a silent hang rather than a crash-restart.
Suggested fixes (any subset helps)
Wire watcher.ignore (or a dedicated files.ignore) into fff's walk as ignore overrides — the ignore crate's OverrideBuilder already supports this; fff-core would need to accept embedder patterns (currently only the hardcoded IGNORED_DIRS const, and only for non-git roots).
Summary
watcher.ignoreinopencode.json(c)never reaches the embedded fff engine. fff's scanner honors only gitignore-family channels (.gitignore,.git/info/exclude,.ignore), and on Linux its watcher then registers one non-recursive inotify watch for every directory the picker indexed. On a large multi-repo workspace root this produced 192,033 inotify watches and 2.5–4.5 GB RSS inopencode serve, no matter whatwatcher.ignorecontained.Placing a ripgrep-style
.ignorefile at the project root is a complete cure: watches dropped 192,033 → 778 and steady-state RSS ~4.5 GB → 448 MB, with file search still working correctly. This is the root cause behind the class of reports like #23190 (closed stale, never root-caused: "only an actual .gitignore inside a real .git repo avoids it" — that observation was correct, and this is why), and it's the Linux/memory sibling of #32511 (fff content-indexing$HOMEon macOS). Likely also relevant to #32930 / #16610 (inotify exhaustion) and the memory megathread #20695.Environment
opencode serve --hostname <ip> --port 4096/data— a multi-repo orchestration workspace, 610,201 directories totalwatcher.ignoreset (see matrix below)Checked release notes through 1.18.3 — no watcher/picker/memory fix in the range, so this should still reproduce on current.
Evidence chain
1. The watches belong to fff, and
watcher.ignoredoes not affect them.Resolving every watched inode (
/proc/<pid>/fdinfo/<inotify-fd>→ inode → path) accounted for 100% of the watches. The top watched subtrees were exactly the directories listed inwatcher.ignore:2. Config-glob matrix (converged watch counts, each state held stable ≥3 min before reading; the scan takes ~5 min to converge after start):
watcher.ignoreform/data/repos/**, …)**/repos/**, …)repos/**added).ignorefile at rootThe halving is the tell: there are two watcher instances — opencode's own (which does honor
watcher.ignore, with relative globs) and fff's (which never sees it). Config changes can only ever fix the first one.3. Mechanism, from fff-core source (fff.nvim's Rust engine, embedded in the opencode binary — the
fff-scan/fff-watcher-own/notify-rsthreads):crates/fff-core/src/walk/ripgrep.rs— the walk is built with.git_ignore(true) .git_exclude(true) .git_global(true) .ignore(true); custom overrides exist only for non-git roots and only from a hardcodedIGNORED_DIRSconst (node_modules,__pycache__, …). There is no channel for embedder-supplied patterns likewatcher.ignore.crates/fff-core/src/watcher/background_watcher.rs— on Linux (no kernel recursion in inotify) it iteratespicker.for_each_dir(...)and registersRecursiveMode::NonRecursiveper directory. Watch set = indexed set. Whatever the scan doesn't prune, the watcher pays for at ~1 KB kernel memory per watch, plus the picker's own index memory.4. The
.ignorecure, verified end-to-end:.ignoreat the project root with anchored top-level patterns:Result, reproduced exactly across restarts: 778 watches, 650 MB anon / 448 MB RSS (from ~2.5 GB anon),
/find/filereturns correct results, excluded trees still directly readable via/file/content(only search indexing is affected).5. fff rescans dynamically — deleting the
.ignorefile at runtime ballooned watches back to ~192K within minutes, without a restart. (Good news: the reverse means the file can also be added without restarting, though we restarted to reclaim the watches.)Why this matters beyond memory
In server mode this compounds: on our host the growth ended in the process wedged in
mem_cgroup_handle_over_high(uninterruptible sleep, port listening but never accepting — connections queue in the backlog forever) while systemd still reported it healthy. Any deployment that caps the service withMemoryHighconverts this leak into a silent hang rather than a crash-restart.Suggested fixes (any subset helps)
watcher.ignore(or a dedicatedfiles.ignore) into fff's walk as ignore overrides — theignorecrate'sOverrideBuilderalready supports this; fff-core would need to accept embedder patterns (currently only the hardcodedIGNORED_DIRSconst, and only for non-git roots).watcher.ignoredoes not apply to fff and that a root.ignorefile is the effective channel. What is the point of watcher.ignore #6740 ("What is the point of watcher.ignore") and watcher.ignore in opencode.json does not prevent hang in non-git dirs with large subtrees #23190 both died on this gap.Happy to provide the full measurement methodology (inode→path resolution scripts, per-fd watch accounting) if useful.