Skip to content

fix(watcher): bound fd/watch cost with a native fs.watch hybrid (#644, #496, #555, #628)#650

Merged
colbymchenry merged 1 commit into
mainfrom
fix/watcher-fd-exhaustion
Jun 2, 2026
Merged

fix(watcher): bound fd/watch cost with a native fs.watch hybrid (#644, #496, #555, #628)#650
colbymchenry merged 1 commit into
mainfrom
fix/watcher-fd-exhaustion

Conversation

@colbymchenry
Copy link
Copy Markdown
Owner

Summary

Fixes the macOS file-descriptor exhaustion that took down users' whole machines (#644, #496, #555), plus the related resource-guardrail ask (#628) and the Linux inotify-count half of #579.

Root cause — found by running + lsof, not reading: chokidar v4 holds one OS file descriptor per watched file on macOS. libuv's kqueue backend registers an fd per vnode, and fsevents is installed but v4 no longer uses it — so the source looks innocent. 6,000 files = 6,000 fds, dead linear. That exhausts kern.maxfiles (default ~10–25k) and produces the system-wide ENFILE that made unrelated apps (shell, editor, Docker, browser) fail with "too many open files" until the codegraph daemon was killed. #276 only reduced the count by ignoring directories; the source tree itself still cost 1 fd/file.

Fix

Drop chokidar; rewrite the watcher as a pure-JS native fs.watch hybrid — no new dependency, and it keeps the project's zero-native-addon "any OS builds any bundle" invariant intact:

Platform Strategy Cost
macOS / Windows one recursive fs.watch (FSEvents / ReadDirectoryChangesW) O(1) descriptors
Linux one inotify watch per directory (dynamic add; CODEGRAPH_MAX_DIR_WATCHES cap) O(dirs), not O(files)

Validation (empirical, all three platforms)

Platform How Result
macOS real FileWatcher + lsof 0 extra fds at 6k & 12k files (chokidar: 6k / 12k); edit → sync ✓
Linux real FileWatcher in Docker, /proc/<pid>/fdinfo 31 inotify watches at 6k files; dynamic dir-add ✓; clean stop → 0
Windows recursive primitive on a real VM supported; nested edit ✓; new-dir file ✓

Full test suite green (57 files, 1,112 tests). Watcher tests use an inertForTests seam for determinism under parallel vitest, with one real-fs end-to-end test exercising the genuine native path.

Scope

Fixes #644
Fixes #496
Fixes #555
Fixes #628

🤖 Generated with Claude Code

…#496, #555, #628, #579)

chokidar v4 holds one OS file descriptor per watched file on macOS (libuv's
kqueue backend registers an fd per vnode; fsevents is installed but v4 no
longer uses it). On a large project the `serve --mcp` daemon accumulated tens
of thousands of open REG descriptors and exhausted kern.maxfiles — crashing
unrelated processes system-wide with ENFILE. #276 only trimmed the count by
ignoring directories; the source tree still cost one fd per file.

Replace chokidar with a pure-JS native fs.watch hybrid, keeping codegraph's
zero-native-addon "any OS builds any bundle" invariant:

  - macOS / Windows: a single recursive fs.watch (one FSEvents stream /
    ReadDirectoryChangesW handle) -> O(1) descriptors regardless of repo size.
  - Linux: one inotify watch per directory (O(dirs), dynamic add for new
    dirs, capped via CODEGRAPH_MAX_DIR_WATCHES) instead of per-file watches.

Validated empirically: macOS 0 extra fds at 6k and 12k files; Linux 31 inotify
watches at 6k files (per-file would be 6k); Windows recursive catches nested
and new-directory edits. Full test suite green.

Tests drive the watcher through an inertForTests seam (no OS watcher) for
determinism under parallel vitest, with one real-fs end-to-end test exercising
the genuine native path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment