fix(watcher): exclude ignored dirs before watching to prevent inotify exhaustion (#276)

The file watcher registered a recursive watch over the entire project (node_modules, build output, caches included) and filtered only in the callback — exhausting the Linux inotify budget on large repos (#276). It now uses chokidar and excludes the same directories the indexer ignores (built-in default-ignore set + the project .gitignore) BEFORE registering a watch, so the watch count on a 900-dir node_modules drops from ~1200 to ~14 even with no .gitignore. Stacks with the shared daemon (#411): one watcher across agents, now small.

Also hardens the #411 daemon lockfile against a concurrent-startup race the new watcher timing made reproducible — the lock is now created atomically with its content (temp-write + hard-link), so racing daemons can never both win. Validated on macOS, Linux (Docker), and Windows (chokidar + fs.linkSync on NTFS).

Co-Authored-By: Colby McHenry <me@colbymchenry.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lorenzo Feng
2026-05-25 20:12:44 -05:00
committed by GitHub
co-authored by Colby McHenry Claude Opus 4.7
parent 995da54430
commit b09b23cf54
7 changed files with 197 additions and 79 deletions
+30
View File
@@ -166,6 +166,36 @@ describe('FileWatcher', () => {
watcher.stop();
});
it('should not watch node_modules even without a .gitignore (#276/#417)', async () => {
// No .gitignore in testDir — exclusion relies on the built-in
// default-ignore set the indexer uses (buildDefaultIgnore), which a
// .gitignore-only filter would miss.
fs.mkdirSync(path.join(testDir, 'node_modules', 'dep', 'lib'), { recursive: true });
fs.writeFileSync(path.join(testDir, 'node_modules', 'dep', 'index.ts'), 'export const dep = 1;');
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
watcher.start();
// Let the watcher settle past any residual crawl events.
await new Promise((r) => setTimeout(r, 400));
syncFn.mockClear();
// A source-extension edit INSIDE node_modules must NOT trigger a sync —
// the directory was never watched.
fs.writeFileSync(path.join(testDir, 'node_modules', 'dep', 'lib', 'extra.ts'), 'export const e = 2;');
await new Promise((r) => setTimeout(r, 600));
expect(syncFn).not.toHaveBeenCalled();
// Positive control: a real source edit still triggers sync, proving the
// watcher is live (not merely inert).
fs.writeFileSync(path.join(testDir, 'src', 'live.ts'), 'export const live = 3;');
await waitFor(() => syncFn.mock.calls.length > 0, 5000);
expect(syncFn).toHaveBeenCalled();
watcher.stop();
});
});
describe('callbacks', () => {