diff --git a/server/__tests__/cc-watcher.test.js b/server/__tests__/cc-watcher.test.js new file mode 100644 index 00000000..df4257e3 --- /dev/null +++ b/server/__tests__/cc-watcher.test.js @@ -0,0 +1,73 @@ +/** + * @file Unit tests for cc-watcher's inotify budget. Node's recursive fs.watch is + * a userland tree walk on Linux that costs one inotify watch per directory, so + * recursively watching ~/.claude/plugins (plugin cache: git checkouts with + * node_modules) exhausted fs.inotify.max_user_watches and starved the desktop. + * These tests pin which watched subdirs may be recursive. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const path = require("path"); +const os = require("os"); + +let tmpHome; +let realWatch; +let calls; + +describe("cc-watcher inotify budget", () => { + beforeEach(() => { + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-watcher-")); + process.env.CLAUDE_HOME = tmpHome; + for (const d of ["agents", "commands", "skills", "output-styles", "hooks", "plugins"]) { + fs.mkdirSync(path.join(tmpHome, d), { recursive: true }); + } + // The real shape that caused the leak: a deep tree under plugins/. + fs.mkdirSync(path.join(tmpHome, "plugins", "cache", "x", "y", "node_modules", "z"), { + recursive: true, + }); + + calls = []; + realWatch = fs.watch; + fs.watch = (target, opts, cb) => { + calls.push({ target, recursive: !!(opts && opts.recursive) }); + return realWatch( + target, + typeof opts === "function" ? {} : opts, + typeof opts === "function" ? opts : cb + ); + }; + delete require.cache[require.resolve("../lib/cc-watcher")]; + delete require.cache[require.resolve("../lib/claude-home")]; + }); + + afterEach(() => { + require("../lib/cc-watcher").stopCcWatcher(); + fs.watch = realWatch; + delete process.env.CLAUDE_HOME; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("never watches plugins/ recursively", () => { + require("../lib/cc-watcher").startCcWatcher({ broadcast: () => {} }); + const pluginsWatches = calls.filter((c) => c.target === path.join(tmpHome, "plugins")); + assert.equal(pluginsWatches.length, 1, "plugins/ should get exactly one watch"); + assert.equal(pluginsWatches[0].recursive, false, "plugins/ must not be watched recursively"); + }); + + it("still watches plugins/ so its top-level config files fire", () => { + require("../lib/cc-watcher").startCcWatcher({ broadcast: () => {} }); + assert.ok(calls.some((c) => c.target === path.join(tmpHome, "plugins"))); + }); + + it("keeps recursion for the small config subdirs", () => { + require("../lib/cc-watcher").startCcWatcher({ broadcast: () => {} }); + for (const sub of ["agents", "commands", "skills", "output-styles", "hooks"]) { + const w = calls.find((c) => c.target === path.join(tmpHome, sub)); + assert.ok(w, `${sub} should be watched`); + assert.equal(w.recursive, true, `${sub} should stay recursive`); + } + }); +}); diff --git a/server/lib/cc-watcher.js b/server/lib/cc-watcher.js index 7563251b..99afd270 100644 --- a/server/lib/cc-watcher.js +++ b/server/lib/cc-watcher.js @@ -65,7 +65,18 @@ const IGNORED_PREFIXES = [ // never the whole of ~/.claude/, so the recursive watcher never registers // interest in high-churn dirs (backups/, projects/, logs/) whose transient // files crash Node's Linux userland recursive watcher mid-stat. -const WATCH_SUBDIRS = ["agents", "commands", "skills", "output-styles", "hooks", "plugins"]; +const WATCH_SUBDIRS = ["agents", "commands", "skills", "output-styles", "hooks"]; + +// Config surfaces that are directories but are watched NON-recursively, because +// their tree is enormous and none of it is config. `plugins/` holds the plugin +// cache — full git checkouts with `node_modules/` (44k+ directories here). On +// Linux, Node's recursive fs.watch is a userland walk that registers ONE inotify +// watch per directory, so watching it recursively burned ~65k of the default +// 65536 `fs.inotify.max_user_watches` and starved the rest of the desktop +// (systemd, IBus/Unikey) of watches. Config Explorer only reads two top-level +// files from it (`installed_plugins.json`, `known_marketplaces.json`, see +// cc-discovery.js), both caught by a flat watch costing exactly one watch. +const WATCH_SUBDIRS_FLAT = ["plugins"]; let started = false; let timer = null; @@ -120,11 +131,13 @@ function safeWatchHome({ home, broadcast }) { // Recursively watch ONLY the relevant config subdirs (never backups/, projects/, // logs/, …) so nested changes still fire without watching the high-churn trees. - for (const sub of WATCH_SUBDIRS) { + // WATCH_SUBDIRS_FLAT get the same treatment minus recursion — see above. + for (const sub of [...WATCH_SUBDIRS, ...WATCH_SUBDIRS_FLAT]) { const dir = path.join(home, sub); + const recursive = WATCH_SUBDIRS.includes(sub); try { if (!fs.existsSync(dir)) continue; - const w = fs.watch(dir, { recursive: true }, (_event, filename) => { + const w = fs.watch(dir, { recursive }, (_event, filename) => { const full = filename ? path.join(dir, filename) : dir; if (!isRelevantUnderHome(home, full)) return; scheduleEmit(broadcast, full);