Skip to content

core: any file write inside the config directory triggers an un-debounced compatibility reload — ~/.claude/skills watcher resubscribe loop (v2.0.23) #53393

Closed

Description

@zhzjm

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/skills present (Claude Code compatibility sources enabled)
  • Global config directory: ~/.config/opencode (holds opencode.json)

Reproduction

  1. Start the TUI/service on v2.0.23 with ~/.claude/skills containing at least one skill.

  2. 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
  3. Tail ~/.local/share/opencode/log/opencode.log.

  4. 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 one started).

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

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

  1. github-actions commented on Oct 5, 2026

    @github-actions
    Contributor

    This issue might be related to existing issues. Please check:

  2. zhzjm commented on Oct 6, 2026

    @zhzjm
    Author

    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 .log there — zero config change), and compatibility.ts:68-71 reloads on every emission with no debounce at all (measured 100–340 teardown/resubscribe cycles per minute; the service survives). Both chains funnel into the same reload() → 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.ts never landed, so no consumer of config.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 sibling agent.ts/command.ts plugins — compatibility.ts's config-chain subscription missed it.

  3. mradwankhalil commented on Oct 11, 2026

    @mradwankhalil
    Contributor

    Independent confirmation on Windows 11 with v2.0.25 (desktop-managed opencode serve), plus a test of #53764 backported to the v2.0.25 tag. 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 onto v2.0.25 (b44eea9): packages/core/src/config/plugin/compatibility.ts is byte-identical at v2.0.25, v2.0.26 and the PR base. Built with bun 1.4.2 and OPENCODE_VERSION=2.0.25.

    A/B on the same machine, isolated profile. Each binary was started as opencode serve with 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:

    build watcher subscribe watcher stopped
    official 2.0.25 312 104
    2.0.25 + #53764 0 0

    A 5 s idle control showed 0 on both. Adding a new SKILL.md under a compatibility skills directory afterwards reloaded identically on both builds (12 subscribes, 4 stops, new skill returned by GET /api/skill), so the debounced file chain is unaffected.

    Tests. The PR's test/config/compatibility.test.ts fails on stock v2.0.25 source (scans.count is 21, expected 1) and passes with the patch. The rest of packages/core/test/config behaves 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.

  4. mradwankhalil commented on Oct 14, 2026

    @mradwankhalil
    Contributor

    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:issue discussion 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.

  5. qretsar commented on Oct 15, 2026

    @qretsar

    Reproduced on macOS with two details not reported yet: the cost multiplies with the number of loaded locations (git worktrees), and a plain git status in the config directory can trigger the loop when skills are nested git repos.

    Environment

    • @opencode/cli v2.0.29 (latest on npm), macOS, Apple Silicon, 10 cores, watcher backend fs-events
    • One opencode serve --service process plus 2 TUI clients

    What we see (server log, about 6 hours)

    • 174,946 watcher subscribe, 36,381 watcher started, 36,298 watcher stopped
    • Restarts come in bursts of about 5 minutes: 936, 993, 1,307, 1,492 and 1,616 watcher started per 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).
    • During a burst opencode serve used 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 plain git status in ~/.config/opencode touches ~/.config/opencode/skills/<skill>/.git/index.lock, and that fires a rescan:

    command in ~/.config/opencode new skills rescanned log lines
    git status +24
    git -c diff.ignoreSubmodules=all status +1

    That index.lock file accounts for 188 of the 218 skills rescanned lines that day. Not all bursts are explained by this trigger, so there is at least one more source we have not identified. Agents run git status in 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 booted 80, location services evicted 79). 43 of the loaded directories are git worktrees of one monorepo. Each worktree has a committed .claude/skills directory (44 tracked files), and the main checkout also has .agents/skills. Every loaded location watches its own .claude/skills, .agents/skills and .opencode/skills plus the global skill directories. Because teardown is per location, one trigger event costs roughly (number of loaded locations) times the work.

    Workarounds

    Suggestions and related items

    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 status in the config dir caused +1 skills rescanned instead of +19 to +24.

  6. larspage commented on Oct 26, 2026

    @larspage

    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" and message="watcher stopped". There is no watcher unsubscribe message in this build, so a census that greps for one reports zero releases and concludes the registry never drains. The release event is watcher stopped.

    Net registry over a single ~4-day server lifetime (~/.claude/skills):

    subscribes = 45,394
    stops      =  2,366
    net        = 43,028   <- still held
    

    So releases do fire, roughly 1 per 19 subscribes, and the net still grows without bound. The companion root ~/.agents/skills behaves 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.md under ~/.claude/skills shows 273 subscribes against 1 stop:

    ~/.claude/skills/unslop/SKILL.md             273 sub,   1 stop,  272 net
    ~/.claude/skills/grill-me/SKILL.md           273 sub,   1 stop,  272 net
    ~/.claude/skills/grill-me-with-docs/SKILL.md 273 sub,   1 stop,  272 net
    ~/.config/opencode/skill                     233 sub,   2 stop,  231 net
    

    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 --service resets 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.

  7. opencodejr commented on Oct 27, 2026

    @opencodejr

    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.log caused 20 watcher stopped / watcher started cycles 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/.agents skill 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/skills on 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.

  8. mradwankhalil commented on Oct 27, 2026

    @mradwankhalil
    Contributor

    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/skills and .agents/skills, three loaded locations, then 20 appends to the profile's unrelated opencode/any-debug.log at 200 ms intervals. Measurement starts after watcher startup becomes quiet. Both servers had zero watcher events in the two-second idle control.

    During the 20 unrelated appends v2.1.4 v2.2.0
    watcher subscribe 252 0
    watcher started 84 0
    watcher stopped 84 0

    Real 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 npm latest currently 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 - stopped is not a native-live-watcher count. watcher subscribe logs each API call, whereas started/stopped log acquisition/finalization of shared RcMap entries. 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.

  9. opencodejr commented on Oct 28, 2026

    @opencodejr

    Closing this as completed. On v2.0.23, unrelated writes in ~/.config/opencode made the ~/.claude/skills watcher restart over and over. On the latest dev (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions