fix(watcher): warn (don't degrade) on Linux inotify watch exhaustion (ENOSPC) (#893)
On the Linux per-directory watch path, hitting fs.inotify.max_user_watches surfaces as ENOSPC — which the degrade logic added for #876 (EMFILE/ENFILE only) did not catch, so it fell through to the silent "skip this directory" branch: a large repo got a partial watch set with no hint why edits in unwatched directories stopped auto-syncing. ENOSPC is non-fatal — raise the limit and partial watching keeps working — so it now warns ONCE, naming the exact knob (fs.inotify.max_user_watches, with the sysctl to set it), instead of degrading. It also stops attempting further doomed watches for the session (every inotify_add_watch would fail too). Installed watches keep firing; `codegraph sync` / git sync hooks cover the remainder. Validated on macOS (forced per-directory path) and real Linux (Docker) — the new test asserts a single warning naming fs.inotify.max_user_watches, no degrade, and a live partial watch. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
beca7116a0
commit
ab107b325a
+58
-2
@@ -53,6 +53,19 @@ const EXHAUSTION_REASON =
|
||||
'OS watch/file limit exhausted; auto-sync disabled. Run `codegraph sync` ' +
|
||||
'(or install git sync hooks) to refresh the graph after changes.';
|
||||
|
||||
/**
|
||||
* Actionable, NON-fatal warning for Linux inotify watch-count exhaustion.
|
||||
* Unlike {@link EXHAUSTION_REASON} this does not disable the watcher — the
|
||||
* watches already installed keep working — so it names the exact kernel knob to
|
||||
* raise instead.
|
||||
*/
|
||||
const INOTIFY_LIMIT_REASON =
|
||||
'Linux inotify watch limit reached (fs.inotify.max_user_watches); live ' +
|
||||
'watching now covers only part of the project, so edits in unwatched ' +
|
||||
'directories will not auto-sync. Raise the limit (e.g. `sudo sysctl ' +
|
||||
'fs.inotify.max_user_watches=1048576`, persisted in /etc/sysctl.d) and ' +
|
||||
'restart, or run `codegraph sync` (or install git sync hooks) to refresh.';
|
||||
|
||||
/**
|
||||
* True when an error is OS watch/file-descriptor exhaustion (EMFILE/ENFILE).
|
||||
* Prefers the structured `err.code`; falls back to message matching ONLY when
|
||||
@@ -67,6 +80,17 @@ function isWatchResourceExhaustion(err: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an error is Linux inotify *watch-count* exhaustion. `fs.watch`
|
||||
* surfaces a hit `fs.inotify.max_user_watches` as ENOSPC ("no space" = no watch
|
||||
* descriptors left, NOT disk space). This only arises on the Linux
|
||||
* per-directory path; it is non-fatal (raise the limit and partial watching
|
||||
* keeps working), so it warns rather than degrading.
|
||||
*/
|
||||
function isInotifyWatchExhaustion(err: unknown): boolean {
|
||||
return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOSPC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Native recursive `fs.watch` is only reliable on macOS and Windows; on Linux
|
||||
* (and AIX) it throws `ERR_FEATURE_UNAVAILABLE_ON_PLATFORM`. We branch on this
|
||||
@@ -215,6 +239,14 @@ export class FileWatcher {
|
||||
private dirWatchers = new Map<string, fs.FSWatcher>();
|
||||
/** Set once the per-directory watch cap is hit, so we log only once. */
|
||||
private dirCapWarned = false;
|
||||
/**
|
||||
* Set once the Linux inotify watch limit (ENOSPC) is hit. Double duty: we
|
||||
* warn only once, AND we stop attempting new directory watches for the rest
|
||||
* of the session — once the kernel budget is exhausted every further
|
||||
* `inotify_add_watch` fails too, so trying the rest of the tree is pure
|
||||
* waste. NON-fatal (does not degrade): installed watches keep working.
|
||||
*/
|
||||
private inotifyLimitWarned = false;
|
||||
/**
|
||||
* One-way latch: the reason live watching was permanently disabled at runtime
|
||||
* (watch-resource exhaustion, or lock contention past the retry budget), or
|
||||
@@ -401,8 +433,10 @@ export class FileWatcher {
|
||||
private watchTree(dir: string, markExisting: boolean): void {
|
||||
// A degrade() mid-walk (exhaustion on an earlier directory) calls stop(),
|
||||
// which sets `stopped`; bail so the recursion unwinds without adding more
|
||||
// watches to a watcher that is shutting down.
|
||||
if (this.stopped || this.degradedReason) return;
|
||||
// watches to a watcher that is shutting down. `inotifyLimitWarned` does the
|
||||
// same after ENOSPC — the kernel budget is gone, so stop trying the rest of
|
||||
// the tree (every add would fail) while keeping the watches already set.
|
||||
if (this.stopped || this.degradedReason || this.inotifyLimitWarned) return;
|
||||
if (this.dirWatchers.has(dir)) return;
|
||||
if (this.dirWatchers.size >= maxDirWatches()) {
|
||||
if (!this.dirCapWarned) {
|
||||
@@ -425,6 +459,10 @@ export class FileWatcher {
|
||||
// limping along with a partial watch set.
|
||||
if (isWatchResourceExhaustion(err)) {
|
||||
this.degrade(EXHAUSTION_REASON, { error: String(err), dir });
|
||||
} else if (isInotifyWatchExhaustion(err)) {
|
||||
// ENOSPC = inotify watch budget exhausted. NON-fatal: keep the watches
|
||||
// we have and tell the user the knob to raise (warn once).
|
||||
this.warnInotifyLimit({ error: String(err), dir });
|
||||
}
|
||||
// ENOENT / EACCES on a single directory stays non-fatal: skip it quietly.
|
||||
return;
|
||||
@@ -434,6 +472,9 @@ export class FileWatcher {
|
||||
this.degrade(EXHAUSTION_REASON, { error: String(err), dir });
|
||||
return;
|
||||
}
|
||||
if (isInotifyWatchExhaustion(err)) {
|
||||
this.warnInotifyLimit({ error: String(err), dir });
|
||||
}
|
||||
this.unwatchDir(dir);
|
||||
});
|
||||
this.dirWatchers.set(dir, w);
|
||||
@@ -560,6 +601,20 @@ export class FileWatcher {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn ONCE that the Linux inotify watch budget is exhausted (ENOSPC), and
|
||||
* stop adding new watches for the rest of this session — every further
|
||||
* `inotify_add_watch` would fail too, so walking the rest of the tree is
|
||||
* waste. Unlike {@link degrade} this is NON-fatal: the watches already
|
||||
* installed keep firing, and `codegraph sync` covers the unwatched remainder.
|
||||
* The message names the kernel knob to raise (`fs.inotify.max_user_watches`).
|
||||
*/
|
||||
private warnInotifyLimit(context: Record<string, unknown> = {}): void {
|
||||
if (this.inotifyLimitWarned) return;
|
||||
this.inotifyLimitWarned = true;
|
||||
logWarn(INOTIFY_LIMIT_REASON, { watchedDirs: this.dirWatchers.size, ...context });
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether live watching has degraded permanently (until the next start()).
|
||||
* Distinct from {@link isActive}: a degraded watcher is inactive, but an
|
||||
@@ -603,6 +658,7 @@ export class FileWatcher {
|
||||
}
|
||||
this.dirWatchers.clear();
|
||||
this.dirCapWarned = false;
|
||||
this.inotifyLimitWarned = false;
|
||||
this.lockRetryCount = 0;
|
||||
// NB: degradedReason is intentionally NOT reset here — it must survive the
|
||||
// stop() that degrade() triggers so isDegraded() stays true. start() clears it.
|
||||
|
||||
Reference in New Issue
Block a user