Repository navigation
core: any file write inside the config directory triggers an un-debounced compatibility reload — ~/.claude/skills watcher resubscribe loop (v2.0.23) #53393
Description
Summary
On v2.0.23, ConfigCompatibilityPlugin (packages/core/src/config/plugin/compatibility.ts) reloads on every emission of config.changes() with no debounce, and config.changes() fires on any filesystem event inside the global config directory — regardless of whether the touched file is a config file or whether any config value actually changed.
The practical result: a process that merely appends to a log file under the config directory (in our case a plugin's event-audit log) drives a continuous watcher teardown/resubscribe loop on ~/.claude/skills — measured at 100–340 full rounds per minute while the write source was active, dropping to ~0 once the writes stopped. Skill sources, skill content, and config values were unchanged the entire time.
Environment
- opencode v2.0.23 (TUI + background service)
- Windows 11 (10.0.26200), x64
~/.claude/skillspresent (Claude Code compatibility sources enabled)- Global config directory:
~/.config/opencode(holdsopencode.json)
Reproduction
-
Start the TUI/service on v2.0.23 with
~/.claude/skillscontaining at least one skill. -
From any process, repeatedly append to any file inside the global config directory — it does not need to be a config file, and the filename does not need to be known to opencode:
while true; do echo x >> ~/.config/opencode/any-debug.log; sleep 0.3; done
-
Tail
~/.local/share/opencode/log/opencode.log. -
Observe a repeating 5-line cycle for every write (or burst of writes):
watcher stopped target=<...\.claude\skills> watcher subscribe target=<...\.claude\skills> watcher subscribe target=<...\.claude\skills> watcher subscribe target=<...\.claude\skills> watcher started target=<...\.claude\skills>Sustained rate in our session: 100–340 cycles/minute, purely driven by log appends. With three location instances loaded, each cycle tears down and re-creates three subscriptions (RcMap refcount collapses to one
stopped, and the first re-subscribe logs onestarted).
Root cause
compatibility.ts has two reload trigger chains:
- File chain (debounced): events on the compatibility directories themselves →
debounce(100ms)→ reload. (compatibility.ts:62-67) - Config chain (NOT debounced):
config.changes()→ reload directly. (compatibility.ts:68-71)
reload() calls refresh(), which does FiberMap.clear(watches) — releasing every location instance's subscription on ~/.claude/skills — then re-subscribes and rescans ({*.md,**/SKILL.md}, symlinks followed). There is no comparison of whether the derived compatibility sources actually changed.
Meanwhile config.changes() is emitted straight from the config file watcher for any event under the watched config directory; only the config.updated bus event is gated on configs actually changing. So unrelated writes (a .log file, an editor temp file, a plugin state file) reach the compatibility plugin as reload commands.
This is the exact failure shape that #46843 / #46874 fixed for the sibling plugins in the same directory: config/plugin/agent.ts and config/plugin/command.ts got the eager-subscribe + PubSub.sliding(1) + Stream.debounce(100ms) pattern. compatibility.ts appears to have been missed.
Why this is not a duplicate
- serve: file watcher re-registration storm during bulk skills writes terminates service process #50594 reports the same symptom (watcher re-registration storm on
~/.claude/skills) but via the file chain during bulk skill writes — that chain already has a 100 ms debounce and still storms. Our trigger is the config chain, which has none, and fires on writes outside the skill directories entirely, with zero content change anywhere. - fix(core): skip skill rescans for unrelated config changes #47397 (open PR) teaches
config/plugin/skill.tsto skip rescans when the derived skill-source descriptors are unchanged after a config edit — the compatibility plugin is a separate code path and is not covered. - fix(core): avoid plugin reloads from ecosystem state writes #36173 fixed unrelated writes under
~/.claude/~/.agentsbeing treated as config changes by removing those trees from config watch targets. The config directory itself is (correctly) still watched — the problem here is that everything under it is treated as a config change signal, and one consumer reloads on it without debounce or change detection.
Suggested fix
Apply the established pattern from #46874 to the config.changes() subscription in compatibility.ts (eager subscribe, PubSub.sliding(1), Stream.debounce("100 millis")), and/or skip the reload when the derived compatibility source list is unchanged (same descriptor-comparison approach as #47397). Either alone would collapse the loop; together they also remove the redundant work for genuinely unrelated config edits.
Activity
github-actions commentedon Oct 5, 2026
This issue might be related to existing issues. Please check:
~/.claude/skillswatcher stop/subscribe/start storm, although it attributes the trigger to bulk writes in skill directories and reports service termination; this report identifies writes in the global config directory.zhzjm commentedon Oct 6, 2026
Confirming the bot's read, with one update that matters for triage:
Not the same trigger chain. #50594 goes through the compatibility file chain (events on the skill dirs themselves — already 100 ms debounced, still storms, ends in silent serve death). This issue is the config chain:
config.changes()fires on any filesystem event under the global config directory (our trigger was a plugin appending to its own.logthere — zero config change), andcompatibility.ts:68-71reloads on every emission with no debounce at all (measured 100–340 teardown/resubscribe cycles per minute; the service survives). Both chains funnel into the samereload()→FiberMap.clear(watches)rebuild, so a fix scoped to #50594's file chain would leave this path undebounced and able to drive the identical storm.Update on #47397: it was closed on Oct 5 by the automated PR cleanup (age/low reactions), unmerged — not a technical rejection. The descriptor-comparison fix it proposed for
skill.tsnever landed, so no consumer ofconfig.changes()has change-detection today; the body's "(open PR)" reference should read "closed by cleanup, unmerged". The stronger anchor remains #46874 (merged), which applied the eager-subscribe +PubSub.sliding(1)+Stream.debounce(100 ms)pattern to the siblingagent.ts/command.tsplugins —compatibility.ts's config-chain subscription missed it.mradwankhalil commentedon Oct 11, 2026
Independent confirmation on Windows 11 with v2.0.25 (desktop-managed
opencode serve), plus a test of #53764 backported to thev2.0.25tag. This replaces my earlier note here, which said the local build was blocked — it was not; the build and measurements are below.Trigger in the wild. Two plugins keep state under the global config directory: one rewrites per-session JSON files there at session start, another regenerated agent markdown on every location boot. Neither changes a config value. Every such write produced the teardown/resubscribe cycle described in this issue, once per loaded location. At a time when the config was also slow to load, this coincided with the service failing health checks for about three hours.
Backport. #53764 (
b8ff337) cherry-picks cleanly ontov2.0.25(b44eea9):packages/core/src/config/plugin/compatibility.tsis byte-identical atv2.0.25,v2.0.26and the PR base. Built with bun 1.4.2 andOPENCODE_VERSION=2.0.25.A/B on the same machine, isolated profile. Each binary was started as
opencode servewith its own XDG roots, three locations were loaded through the API, then 25 appends were made to unrelated files under<config>/opencode/plugin-state/at 200 ms intervals. Counts are log lines within that window:watcher subscribewatcher stoppedA 5 s idle control showed 0 on both. Adding a new
SKILL.mdunder a compatibility skills directory afterwards reloaded identically on both builds (12 subscribes, 4 stops, new skill returned byGET /api/skill), so the debounced file chain is unaffected.Tests. The PR's
test/config/compatibility.test.tsfails on stockv2.0.25source (scans.countis 21, expected 1) and passes with the patch. The rest ofpackages/core/test/configbehaves the same with and without the patch on this machine (the same 7 tests fail on both, so the patch adds no new failures there).It would be good to see #53764 reviewed: on Windows desktop setups with plugins that write into the config directory this is easy to hit.
Measurements were gathered on my machine with the help of an AI coding agent.
mradwankhalil commentedon Oct 14, 2026
Overlap check for our local V2 fix set: this issue is already covered by @aarslan007's #53764 (
b8ff3374fa2ac0be8917af4a887d0b0d3f83fd13), which we carry unmodified locally. We are not opening a duplicate PR or claiming authorship of that fix.Two related problems should remain distinct: skipping unrelated compatibility reloads reduces unnecessary work, while legitimate CPU-heavy plugin initialization/full-scope disposal can still starve native event-loop progress (#55272). Fixing scheduling is not a substitute for #53764's debounce/change filtering. The
needs:issuediscussion on #53764 has already been answered by its author with the non-default-branch closing-reference explanation; no code change to that PR is suggested here.qretsar commentedon Oct 15, 2026
Reproduced on macOS with two details not reported yet: the cost multiplies with the number of loaded locations (git worktrees), and a plain
git statusin the config directory can trigger the loop when skills are nested git repos.Environment
@opencode/cliv2.0.29 (latest on npm), macOS, Apple Silicon, 10 cores, watcher backendfs-eventsopencode serve --serviceprocess plus 2 TUI clientsWhat we see (server log, about 6 hours)
watcher subscribe, 36,381watcher started, 36,298watcher stoppedwatcher startedper minute (16:15 to 16:19 UTC). The same burst shape repeats roughly hourly (for example 14:12-14:16 and 15:13-15:19 UTC). Between bursts it is quiet (1 stop in 20 s).opencode serveused 375-425% CPU, about 3.5 GB RSS and 107 threads, and the whole machine felt slow.Trigger we confirmed
~/.config/opencode/skills/contains 1 skill that was a nested git repo (gitlink mode 160000, no.gitmodules); the other 2 earlier counted were symlinks. A plaingit statusin~/.config/opencodetouches~/.config/opencode/skills/<skill>/.git/index.lock, and that fires a rescan:~/.config/opencodeskills rescannedlog linesgit statusgit -c diff.ignoreSubmodules=all statusThat
index.lockfile accounts for 188 of the 218skills rescannedlines that day. Not all bursts are explained by this trigger, so there is at least one more source we have not identified. Agents rungit statusin the config repo often, so this re-arms the loop repeatedly.Amplification across locations
About 25 directories were loaded as locations during the day (
location services booted80,location services evicted79). 43 of the loaded directories are git worktrees of one monorepo. Each worktree has a committed.claude/skillsdirectory (44 tracked files), and the main checkout also has.agents/skills. Every loaded location watches its own.claude/skills,.agents/skillsand.opencode/skillsplus the global skill directories. Because teardown is per location, one trigger event costs roughly (number of loaded locations) times the work.Workarounds
git config submodule.<name>.ignore allfor the nested skill repos (what we are applying)OPENCODE_DISABLE_FILEWATCHER=1(from watcher: fs watcher subscribes/unsubscribes skill directories in a tight loop (100%+ CPU, macOS) #53811) works but is much heavier, since it disables all watchingSuggestions and related items
.git/paths inside skill directories. Either one would cut the cost here..agents/skills) and watcher: fs watcher subscribes/unsubscribes skill directories in a tight loop (100%+ CPU, macOS) #53811.Measurements are from one machine on 2026-10-15; the burst numbers come from counting log lines per minute.
Update: we removed the nested repo (tracked its files in the parent repo); after that
git statusin the config dir caused +1skills rescannedinstead of +19 to +24.larspage commentedon Oct 26, 2026
Confirming on Linux (Fedora), opencode v2.1.3 and v2.1.4, with per-path numbers that may be useful.
Two message names matter here. The log records
message="watcher subscribe"andmessage="watcher stopped". There is nowatcher unsubscribemessage in this build, so a census that greps for one reports zero releases and concludes the registry never drains. The release event iswatcher stopped.Net registry over a single ~4-day server lifetime (
~/.claude/skills):So releases do fire, roughly 1 per 19 subscribes, and the net still grows without bound. The companion root
~/.agents/skillsbehaves the same way (45,389 subscribes, 5,010 stops, 40,379 net), and the two together hold 83,407 of the 89,756 total.Individual skill files leak harder than the directories. Every
SKILL.mdunder~/.claude/skillsshows 273 subscribes against 1 stop:That 273-against-1 ratio is the shape of an un-debounced resubscribe loop: one reload per write, with the teardown never matching the setup.
Rate. A fresh server takes roughly 1,240 net subscriptions in 5 minutes, about 13,600/hour, and the registry returns to the 4-day level in under 7 hours.
Per-process. Restarting
opencode serve --serviceresets the registry to zero and it starts climbing again immediately. Restarting the TUI client does not touch it.v2.1.4 does not fix this. I upgraded to check: the 2.1.3 to 2.1.4 diff (72 commits, 300 files) touches no watcher or file-watching source, and the post-upgrade rate is unchanged.
opencodejr commentedon Oct 27, 2026
Thanks for the detailed report and root-cause analysis. I reproduced this on v2.0.23: with a skill in
~/.claude/skills, 20 appends to an unrelated~/.config/opencode/any-debug.logcaused 20watcher stopped/watcher startedcycles for~/.claude/skills.With the same steps on the latest
dev(f7d0ce1), the watcher stayed up for all 20 writes. That matches #59086, which makes the compatibility plugin reload only when the change is inside its own.claude/.agentsskill sources and debounces those reloads. So this looks fixed already, and the fix should be in the next release (I can't say when that will be). It isn't in any release up to v2.0.29.Related: #54114, a similar watcher loop on
.agents/skillson v2.0.26.The bot will close this issue in 1 day unless you reply that it still happens, e.g. on a newer release that includes #59086.
mradwankhalil commentedon Oct 27, 2026
Thanks for the reproduction and the pointer to #59086. I independently checked this with the stock Windows v2.1.4 and v2.2.0 binaries, not our locally patched build. The unrelated-config-write reload loop is fixed in this v2.2.0 reproduction.
Fresh A/B, 2026-10-27 UTC: separate throwaway XDG/config/data/cache/test-home profiles, fixture skills in both
.claude/skillsand.agents/skills, three loaded locations, then 20 appends to the profile's unrelatedopencode/any-debug.logat 200 ms intervals. Measurement starts after watcher startup becomes quiet. Both servers had zero watcher events in the two-second idle control.watcher subscribewatcher startedwatcher stoppedReal changes still worked on v2.2.0: adding a Claude skill, changing its description, deleting it, and adding an Agents skill were all reflected in
/api/skill. So this is not just disabling watching. The same positive controls also passed on v2.1.4. Only the throwaway servers were stopped; the desktop-managed backend was untouched.Source/release check: #59086 merged as
eba3399588ebe653783725e78bf9ce2902af131a. It is not an ancestor of v2.1.4 (68bc26b7da1990ce10ffecfbc1e09eb13aa5d824) but is an ancestor of v2.2.0 (1097e2668b1e08ad63bdf0b1c4860b9bc01e420c). The v2.2.0 code filters config changes to compatibility sources and routes them through the existing 100 ms debounce. That directly removes the broad config-write trigger.@opencode/cli's npmlatestcurrently resolves to 2.2.0, so the comment's “next release / up to v2.0.29” wording is stale; the v2.1.3/2.1.4 reports above predate inclusion of this fix.One correction to the earlier log census:
subscribe - stoppedis not a native-live-watcher count.watcher subscribelogs each API call, whereasstarted/stoppedlog acquisition/finalization of sharedRcMapentries. In the old-binary run above, 252 subscribe calls coexist with 84 starts and 84 stops. Subtracting subscribe and stopped would incorrectly imply 168 retained native watchers in that window. This does not rule out another leak, but that needs separate live-native/inotify/handle or allocation evidence.For the reload trigger addressed by #59086, this confirms the bot's diagnosis and a released fix. I do not have a reproduction of that trigger on v2.2.0, and am not asserting a multi-day Linux memory/handle soak test passed.
opencodejr commentedon Oct 28, 2026
Closing this as completed. On v2.0.23, unrelated writes in
~/.config/opencodemade the~/.claude/skillswatcher restart over and over. On the latestdev(f7d0ce1) that doesn't happen, because #59086 makes the compatibility plugin reload only for changes in its own skill sources. The fix should be in the next release (I can't say when that will be). We waited a day in case it still happened for you on a newer version.If you still see the loop on a release that includes #59086, please comment here or open a new issue with the version and a log excerpt. Thanks again for the detailed report and root-cause analysis.