fix(cc-watcher): stop burning 65k inotify watches on the plugin cache
`plugins` sat in WATCH_SUBDIRS, so it got fs.watch({recursive:true}).
On Linux that's a userland tree walk costing ONE inotify watch per
directory, and ~/.claude/plugins is the plugin cache: full git checkouts
with node_modules, 44k+ directories. The dashboard process held 65,464
watches against the default fs.inotify.max_user_watches of 65536,
leaving none for systemd/IBus — IBus exited on boot and Unikey died
with it.
Config Explorer only reads two top-level files out of that tree
(installed_plugins.json, known_marketplaces.json), so watch plugins/
flat: 1 watch instead of 44k. The small config subdirs stay recursive.
Measured on the running service: 65,464 -> 96 watches, and touching
installed_plugins.json still broadcasts cc_config_changed.
This commit is contained in:
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user