Repository navigation
Description
Description
v2 follow-up to #50966, which was closed as a v1 issue. This comes from reading the source at v2.0.26; the location and search files involved are identical on dev.
Every location graph builds its own fff engine. fffLayer calls Fff.create({ basePath }) on each build (packages/core/src/filesystem/search.ts), and each engine holds its own index and its own inotify instance. The kernel does not deduplicate watches across inotify instances, so two graphs for one directory double that directory's watches and index memory.
Two graphs for one directory coexist in three cases:
- Invalidation while borrowed.
invalidatedetaches the cache entry while borrowers keep the old graph (packages/core/src/location-services.ts).LocationServiceMap.reload()invalidates every location and boots replacements immediately (packages/core/src/location-service-map.ts), while a running session keeps its graph until the next step boundary (packages/core/src/session/execution.ts). The other triggers arevcs.init, whose handler runs inside the location it invalidates,debug.location.evict, and idle eviction inpackages/core/src/location-activity.ts, which interrupts sessions but not location-scopedpty.connectsockets (packages/server/src/handlers/pty.ts). - On every v2 release the old graph is never closed.
v2.0.0throughv2.0.26lock[email protected]. ItsRcMapreleasecloses an entry only when!MutableHashMap.has(map, key). Once the replacement exists under an equal key that check is false, and withidleTimeToLive: Duration.infinityit returns without closing. Entry scopes come fromScope.makeUnsafe()and the map's finalizer closes only entries still in the map, so the old graph, including its fff engine, inotify instance and index, lives until the process exits. Each/reloadduring a running session leaks one graph per loaded location. Effect compares by identity from4.0.0-rc.113, anddevlocks4.0.1, so ondevthe old graph closes when its last borrower releases it. - Path spelling.
LocationServiceMap.canonicalnormalizes only onwin32, andAbsolutePathis an unnormalized brand, so different spellings of one directory (symlink, trailing slash,..) get separate graphs elsewhere. The CLI clients all sendprocess.cwd(); plugin-supplied refs (packages/core/src/plugin/host.ts) and other clients can differ.
#52964 shares one refcounted engine per directory string, which collapses case 1 and the leaked engine in case 2 to a single engine. It does not cover the rest of the leaked graph in case 2, or case 3.
The cause proposed in #50966, Location.Ref values differing only by workspaceID, no longer applies: locations with a workspaceID use the ripgrep backend.
OpenCode version
2.0.26
Steps to reproduce
- On Linux, open a session in a git repository and send a prompt that runs several steps.
- Run
/reloadwhile it is running. - After it finishes, in the server process compare
cat /proc/<pid>/fdinfo/* | grep -c '^inotify wd:'withcat /proc/<pid>/fdinfo/* | grep -oP 'ino:\K\S+' | sort -u | wc -l. - Total watches exceed distinct inodes by roughly the repository's directory count, and the gap grows by that much on each repeat of steps 1 to 3.
Operating System
Linux
Activity
opencode-agent commentedon Oct 11, 2026
Thanks for the detailed report. I reproduced this with a simulated model on Linux. The project was a git repo with 40 directories. In each round I started a session step, ran
/reloadwhile it was still running, let the step finish, then counted inotify watches in the server process.2.0.26: each round adds one inotify instance and one directory's worth of watches, and they are never released. Over three rounds the server went from 3 to 6 inotify instances and from 95 to 350 watches, while distinct inodes stayed at 90. This matches your case 2.
Latest
dev(707de58): the old engine is now destroyed once the step finishes, so the permanent leak is gone there. While the session is still running after/reload, though, the same directory still has two fff engines (4 instances and 180 watches, back to 3 and 95 afterwards). That is your case 1, and it still happens ondev. I didn't test the path-spelling case.The script below reproduces it and prints the inotify counts after each step.
Related:
Reproduction script (drive.ts)
Run it with opencode-drive:
opencode-drive run ./drive.ts. It drivesopencodefrom yourPATH; setOC_DEVto the path of an OpenCode checkout to run that from source instead.nshah1d commentedon Oct 15, 2026
Additional Linux observations for the original report, #50966. These measurements are from OpenCode 1.18.31, not a reproduction of the v2 lifecycle bug.
The affected process was an interactive CLI session running for about six hours on Linux 7.0.0-38-generic, x86-64, with 16 logical CPUs. Its working directory was a non-repository development root containing several applications, scratch work, backups and an older Go toolchain.
Two FFF watcher-owner groups were active. Comparing device/inode pairs from their inotify descriptors gave:
Both sets covered the older toolchain, build cache and vendor directories while the session was working on a different project. The second owner group had appeared roughly two hours after the first; its creation trigger was not established.
The owner threads each consumed roughly 85 to 90% CPU. A separate three-second whole-process sample measured 597.9% CPU. Per-thread I/O sampling recorded approximately 2.068 and 1.955 GB/s of read accounting, with zero additional physical read bytes charged during that interval. Those figures describe cached/kernel-served reads, not 4 GB/s of disk throughput.
Content indexing was already disabled: the 1.18.31 search implementation sets both disableContentIndexing and disableMmapCache, and that option pair was present in the running binary. It bundles FFF 0.9.4.
The pinned dependency still has a binary classifier that reads through to EOF. Post-scan directory registration and watcher processing provide a candidate path for repeated classification. No native stack profile was captured, so this is a source-backed explanation to investigate, not an attribution of all process CPU to that function or proof of a memory leak.
A separate clean-process test on 1.19.0 with OPENCODE_DISABLE_FFF=1 passed file lookup, content search and direct reading with zero FFF threads. The affected 1.18.31 session was left running during the investigation; there was no controlled before/after comparison on that process.
Sharing engine ownership should remove the duplicate coverage. It would also be useful to check whether post-rescan registration can repeatedly trigger full-file classification despite content indexing being disabled.