Repository navigation
Description
Environment
- opencode: v2.0.30 (also reproduced on v2.0.21; both installed via npm
@opencode/cli) - OS: macOS 26.6.2 (Tahoe, Build 25G83), Apple Silicon M4 (arm64)
- Zero user plugins, zero custom providers, brand-new config (fully reproducible with a pristine
HOME)
Symptoms
opencode serveboots fine, then the server process pins 300–900% CPU and memory keeps climbing to several GB (%mem~20% and rising). It never recovers.GET /api/modelhangs until the location-plugin activation timeout and returns 503 "Location plugins did not finish activating" — hence the TUI shows "No items available" in the model picker and "No provider selected".- On v2.0.21 the blowup is deferred to the first chat message: as soon as a message is sent, the server child process hits ~587% CPU + multiple GB RSS, and the TUI logs
Timed out connectingrepeatedly, then shows "Connection lost… Reconnecting". - TUI↔server SSE repeatedly logs
Event stream stalled→ "Restarting service…" loops, even with all proxy env vars unset.
Debug-log evidence (v2.0.30, --log-level debug)
The server's location watcher subscribes to the entire home directory and filesystem root:
watcher subscribe path=/ type=entries ignores=0 role=server
watcher subscribe path=/private type=entries ignores=0
watcher subscribe path=/Library/Application Support type=entries ignores=0
watcher subscribe path=/Users/chuan type=entries ignores=0 <-- $HOME
watcher subscribe path=/Users/chuan/.config/opencode type=directory
This machine's $HOME contains 2,130,792 files (iCloud Drive sync + years of dev projects). After the watcher subscribes, CPU explodes (sampled while at ~900% CPU: all hot samples burn inside JIT-compiled JS frames, no native symbol names available).
Decisive A/B experiment
| Run | $HOME file count |
Server CPU | GET /api/model |
|---|---|---|---|
| Real home | 2,130,792 | 909% peak → ~330% sustained, memory climbing | 503, activation timeout |
Fake empty home (env HOME=/tmp/oc-home opencode serve) |
0 | 0.0–0.2% | 200 OK in 165 ms, full free-model list returned |
# reproduce (A: broken)
opencode serve &
curl -u "opencode:$PW" http://127.0.0.1:4096/api/model # 503 after activation timeout, CPU pinned
# reproduce (B: healthy)
env HOME=$(mktemp -d) opencode serve &
curl -u "opencode:$PW" http://127.0.0.1:4096/api/model # 200 OK in 165 msRelation to #55272
Same failure shape (CPU-heavy work starving the shared event loop → HTTP/SSE stall), but completely different trigger surface: this reproduces with zero user plugins — no 30×3 native-plugin fixture needed. The trigger is the global location watcher subscribing to $HOME// on machines where those trees are huge. Likely same root (event-loop starvation), different entry point.
Expected fix / ask
- Don't recursively watch the whole
$HOME(and/) by default, or - provide an escape hatch (env var / config) to disable or scope the global location watcher, or
- make the initial watch scan incremental/async so it cannot starve the shared event loop.
Happy to provide sample call-stack dumps or any further data points.
Activity
opencode-agent commentedon Oct 16, 2026
Thanks for the detailed report and the A/B comparison. I tried to reproduce this on Linux with
@opencode/[email protected]: aHOMEwith 500k files and a loop that kept creating files deep inside it whileopencode serve --log-level debugran. The debug log showed the same watches you posted (/and$HOMEastype=entries,~/.config/opencodeastype=directory). The server stayed healthy, though: around 10–20% CPU, andGET /api/modelreturned 200 in a few milliseconds.Those
entrieswatches aren't recursive. Each is a single-directoryfs.watchthat only cares about a few config file names. On Linux that's an inotify watch on just that directory, so changes deeper in$HOMEnever reach OpenCode. On macOS, I believe the runtime uses FSEvents for directory watches, which reports changes from the whole subtree. If so, every change under$HOMEor/(such as iCloud Drive syncing) would reach OpenCode only to be discarded. That fits what you're seeing, but I haven't confirmed it.Confirming it needs a maintainer with a Mac, so I've left this for them. Related issues: #55272 (same event-loop starvation, triggered by plugins), #53811 and #54114 (watcher CPU loops on macOS/skill dirs), #45611 (skip watching filesystem roots). If you can share a
sampleof the server process taken while CPU is pinned, that would help show whether the time is spent handlingfs.watchevents.CHANGGELY commentedon Oct 16, 2026
Thanks for the quick reproduction attempt and the platform-semantics hypothesis. New experiments on macOS narrow this down further — and partly correct my own initial framing: the watcher events themselves are NOT the trigger. The trigger is the activation pass started by
GET /api/model(and presumably any request that awaits location activation).Controlled experiment (v2.0.30, real ~2.1M-file
$HOME, fresh boot each time)opencode serve, idle, no requests/,/Users/chuanetc. per debug log — cheaptouch200 new files deep in$HOMEGET /api/modelGET /api/modelHOME=$(mktemp -d)Memory: RSS climbs to ~4.5 GB (
%mem17.8 and rising) during the pinned phase.sampleevidence: a self-recursive JS frameCaptured while CPU was ~900% (
sample <pid> 4). Bun strips symbols so frames show as offsets, but the tree contains the same code offset nested inside itself at many depths — a JS function recursively calling itself:All hot samples (1500+/1526) sit under the main thread inside JIT frames (
in <unknown binary>), consistent with a single runaway synchronous recursion rather than GC or I/O wait. The recursion depth varies between captures, which fits "enumerate/scan a huge tree synchronously" — and the multi-GB RSS growth suggests per-node allocations along the way.Updated picture
Ask (updated)
$HOME//— make it incremental/async or scope it.Full
sampleoutput available on request. Happy to test a fix build.Dante-dan commentedon Oct 16, 2026
Your follow-up rules out the watch-event flood hypothesis. I traced current
dev, but haven't reproduced your macOS failure or identified the hot function. The ancestor config walk checks fixed paths; the built-in skill scans target config/compatibility skill directories. I haven't found evidence that activation enumerates all of HOME.There is a built-in JS CPU profiler in v2.0.30: send
SIGPROFto the server PID before the first/api/modelrequest:After ten seconds it writes
cpu-<pid>-*.cpuprofilein the OpenCode log directory. Could you inspect it locally and share just the hottest function names, source module URLs (with personal paths redacted), and their sample percentages? No workspace contents or full profile are needed. This should help distinguish recursive JS work from the native frames insample.Also, does the request still fail with
OPENCODE_FILEWATCHER_DISABLE=1set when launching the server, keeping HOME and the requested location directory unchanged? That existing switch disables watcher subscriptions; comparing it with the profile would narrow the activation path without adding another setting.CHANGGELY commentedon Oct 16, 2026
Ran both of your asks. Results below — and one of them corrects an inference I made in my previous comment.
1.
kill -PROFcpuprofile (v2.0.30, window 10.07s, 237 nodes, 81 samples)Self time, all frames above 0.05%:
createSocketnode:net:787dlopenvaluesparsefile:///$bunfs/root/chunk-*.jsAncestor chains:
create←L[chunk-kemvbb8p.js:3]←(anonymous)[chunk-kemvbb8p.js:17]←runInAsyncScope[node:async_hooks:202]←evaluate/runLoop[chunk-kemvbb8p.js:17]←afterScheduled/runTasks[chunk-kemvbb8p.js:3]←(root). So the hot nativecreateis invoked from the Effect runtime scheduler chunk.Socket[node:net:787]←Socket[node:_http_server:789]←onServerConnection[node:_http_server:573](root-level child).Root's children:
processTicksAndRejections,afterScheduled,onServerConnection,onNodeHTTPRequest.Correction to my previous comment: my earlier
sample-based "runaway JS recursion" reading was an over-interpretation of trampolined frames. The cpuprofile shows the repeating deepest path is the Effect runtime loop itself —~effect/Effect/evaluate→runLoop→evaluate→E→uu, 6 repeats at depth 40, with self time ≈0.00% each. No user-level JS recursion is burning CPU; ~99% of sampled self time sits in two native frames.Caveat worth being explicit about: 81 samples over 10.07s means exactly one thread was busy for the whole window, while the process reports 800–900% CPU. So the profile appears to cover the main JS thread only; the remaining ~800% is on threads the profiler isn't sampling.
Ignoring the profile, same run measured: CPU 824%, RSS 3.1 GB,
GET /api/model→ 503 after 11.6s.2.
OPENCODE_FILEWATCHER_DISABLE=1— switch verified effective, hang unchangedI first confirmed the switch is actually honored rather than silently ignored: without it and with
--log-level debug, the log emitswatcher subscribe …lines; with it set, zero watcher log lines appear. Yet:/api/model$HOME$HOME+OPENCODE_FILEWATCHER_DISABLE=1So watcher subscriptions are excluded as the trigger — consistent with the event-flood test (idle 0.0%,
touchof 200 files → 0.3%).3. A/B narrowing the trigger surface (relevant to "no evidence activation enumerates all of $HOME")
$HOME/api/model$HOME.config/opencode(same plugin list, accounts, skills)$HOME.local/share/opencode(sessions, storage,opencode.db; 11k files)Not plugins, not skills config, not sessions/storage/DB. Identical config + data inside an empty
$HOMEis fast; the real$HOMEpins. So something outside.config/opencodeand.local/share/opencodeis doing the work.4. Log evidence of what gets subscribed (run
5a8d12cf, v2.0.30)At boot only
location services booted(durationMs=66). The subscriptions appear ~10.5 s later — i.e. lazily at activation, inside the pinned window:type=entriessubscriptions on/and on/Users/chuanland exactly in the pinned window, and/Users/chuanalone is 2.13M entries on this machine.One tension I'd rather flag than paper over: with
OPENCODE_FILEWATCHER_DISABLE=1those subscriptions are absent and the hang still reproduces. So either the entries enumeration runs on a path not gated by that switch, or a second code path walks$HOMEindependently of the watcher.Ask / next
chunk-kemvbb8p.js→ the mangledLthat calls the nativecreate, that would identify the allocation site directly..cpuprofile, or run an instrumented build if you point me at one.$HOMEsubtree triggers this (.cache466k /Library1.57M /Projects713k files) if that's useful to you.Repro on this machine (paths redacted): real
$HOME,opencode serve --print-logs --log-level debugfrom any directory →kill -PROF <server-pid>, wait forCPU profile started, thenGET /api/modelwith the printed password → 503 and event loop starved; same binary with a fresh$HOMEcontaining copies of the real config → 200 in 146 ms.CHANGGELY commentedon Oct 16, 2026
Root cause found: a symlink cycle reachable through the skill roots — not
$HOMEsize, and not the file watcherFirst, a correction of my two earlier comments. Both of my earlier hypotheses are now falsified by direct A/B experiments on the same machine. The real trigger is a self-referencing symlink cycle inside a skill directory, and the runaway happens during skill discovery.
The smoking gun
The symlinked skill target contains a symlink back into itself, so walking
…/.claude/skills/pandaai-factor-onlinenever terminates.Decisive A/B on the real home (macOS, opencode v2.0.30)
Only variable changed: the two cycle symlinks above.
GET /api/model000(25 s timeout)Everything else identical: same
$HOME(2,130,792 files), sameopencode.json, same storage dirs, same watcher configuration. With the cycle gone, v2.0.30 is fully usable on this machine, model list included. So home-directory size is not the cause.Falsified earlier claims
$HOMEstarve the event loop$HOME200 OK, 0.0 % CPU$HOMEis the triggerOPENCODE_FILEWATCHER_DISABLE=1, confirmed with--log-level debug(watcher subscribelines → 0)200 OK, 0.5 % CPU200 OK, 0.1 % CPU<skill>/.claude/skills -> ..200 OK200 OK, 0.0 % CPUplugin: []inopencode.jsonthroughoutMinimum conditions I could reproduce
In my environment
~/.claude/skills,~/.agents/skillsand~/.mirasim/skillsare all symlinks resolving to the same directory, so the same skill gets registered three times — and only then does the scan run away. A single live root holding the exact same cycle did not reproduce, which points at the cross-root de-duplication/merge step rather than at a plain recursive directory walk.Minimal repro
Where the cycle came from (third party, not opencode)
scripts/install.pyshipped with thepandaai-factor-onlineskill. Itsinstall_project()symlinks the skill into<directory>/.claude/skills/<NAME>pointing at the repo rootSRC; when invoked asinstall.py projectfrom inside the repo it falls back toPath.cwd(), i.e.directory == SRC, producing<SRC>/.claude/skills/<NAME> -> <SRC>. There is no guard againstdirectory == SRC.Suggestion
Whatever creates the layout, a directory shape should not be able to take opencode down. Hardening I'd suggest on the opencode side:
realpaths plus a depth cap so a cycle degrades to "skill skipped".Happy to run an instrumented build for any of these, and to re-attach the
cpuprofileif useful.CerisonAutomation commentedon Oct 22, 2026
The watcher subscribes to every ancestor directory of a discovered location — all the way up to
/.opencode 2.1.2 (Homebrew), macOS 27.0.1, M4 Max / 36 GB.
Distinct
watcher startedpaths captured from the server log:path=/is the filesystem root. The/private/var/folders/...chain is the ancestor chain of the agent scratch directory (see #55006) — that location alone pulls in/,/private,/private/var,/private/var/folders,/private/var/folders/m6,.../k0xt0jw57f7831mf7j2q4tqw0000gn,.../Tand.../T/opencode.Two backends appear in the log:
backend=node(3122 events) andbackend=fs-events(1629). Thenodebackend is the fallback used for the ancestor paths FSEvents cannot cover — which is exactly the set that turns a file change anywhere on the volume into an event storm inside the server.Reproduced symptom on this machine:
ripgrepinvoked withcwd=/Users/cb(688 occurrences in the log) together with--glob=*.py— i.e. globbing the entire home directory — and a singlefind "$HOME" -xdev -type f -not -path "$HOME/Library/*"did not finish within 180 s.Cost before mitigation on this machine: 90 MB free RAM, a 13.6 GB compressor, 95 % of a 12 GB swapfile in use, load averages 6.71 / 7.85 / 9.11.
Mitigation available today —
watcher.ignoredoes work for the root walk. With these patterns configured, the/Usersand/Users/cbsubscriptions drop to 0 and only explicitly-scoped directories remain watched:Two caveats worth knowing when reproducing:
watcher.ignoredoes not filter the explicit per-skill-directory subscriptions (~/.claude/skills,~/.agents/skills, and the same pair inside each project-like directory opencode discovers) — those use a different code path. They are individually cheap, but the ignore list alone cannot reduce the subscription set to zero.watcher stoppedand 4754watcher startedevents. A change under one watched skills directory tears down and recrees the watchers for all of them, which is what produces the CPU spike during build churn.A fix that refuses to subscribe to a path that is an ancestor of the workspace root (or of the scratch dir) would remove the need for a user-maintained ignore list entirely.