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
@@ -205,6 +205,63 @@ describe('FileWatcher', () => {
|
||||
expect(watcher.getDegradedReason()).toBeNull();
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('warns once (NOT degrade) when Linux inotify watches are exhausted (ENOSPC)', () => {
|
||||
// ENOSPC only arises on the Linux per-directory path; force it so the test
|
||||
// runs the per-directory branch on any host. Synchronous test, restored in
|
||||
// finally — no await window for another test to observe the override.
|
||||
const realPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
|
||||
try {
|
||||
// Empty-but-for-one-subdir temp dir: the root watch succeeds, then the
|
||||
// child watch hits the (simulated) inotify budget — the realistic
|
||||
// "partial watch installed, then exhausted" shape.
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-inotify-'));
|
||||
fs.mkdirSync(path.join(dir, 'sub'));
|
||||
const onDegraded = vi.fn();
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const emitter = new EventEmitter();
|
||||
let calls = 0;
|
||||
const okWatcher = {
|
||||
on: (event: string, handler: (...a: unknown[]) => void) => {
|
||||
emitter.on(event, handler);
|
||||
return okWatcher;
|
||||
},
|
||||
close: () => {},
|
||||
} as unknown as fs.FSWatcher;
|
||||
__setFsWatchForTests(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) return okWatcher; // root dir watch succeeds
|
||||
const err = new Error('ENOSPC: System limit for number of file watchers reached') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOSPC';
|
||||
throw err; // every subsequent dir exhausts the inotify budget
|
||||
});
|
||||
const watcher = new FileWatcher(
|
||||
dir,
|
||||
vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 }),
|
||||
{ debounceMs: 100, onDegraded }
|
||||
);
|
||||
|
||||
try {
|
||||
// NON-fatal: the watcher starts (partial watch on the root), does NOT
|
||||
// degrade, and warns exactly once with the actionable sysctl remedy.
|
||||
expect(watcher.start()).toBe(true);
|
||||
expect(watcher.isActive()).toBe(true);
|
||||
expect(watcher.isDegraded()).toBe(false);
|
||||
expect(onDegraded).not.toHaveBeenCalled();
|
||||
const inotifyWarnings = warnSpy.mock.calls.filter(
|
||||
(c) => typeof c[0] === 'string' && c[0].includes('inotify watch limit')
|
||||
);
|
||||
expect(inotifyWarnings).toHaveLength(1);
|
||||
expect(String(inotifyWarnings[0]![0])).toContain('fs.inotify.max_user_watches');
|
||||
} finally {
|
||||
watcher.stop();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('lock contention degradation (#876)', () => {
|
||||
|
||||
Reference in New Issue
Block a user