Skip to content

Commit b09b23c

Browse files
7emotionscolbymchenryclaude
authored
fix(watcher): exclude ignored dirs before watching to prevent inotify exhaustion (colbymchenry#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 (colbymchenry#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 (colbymchenry#411): one watcher across agents, now small. Also hardens the colbymchenry#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 <[email protected]> Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent 995da54 commit b09b23c

7 files changed

Lines changed: 202 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,20 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
3434
already attached to the old daemon keep using it while new sessions run
3535
standalone until it idles out — they never mix versions over the socket.
3636

37+
### Fixed
38+
- **The file watcher no longer exhausts the OS file-watch budget on large
39+
repos (#276).** It used to register a recursive watch over the *entire*
40+
project — `node_modules/`, build output, caches and all — and filter only
41+
after the fact. On Linux that meant hundreds of thousands of inotify watches
42+
per project; enough that a second project, or codegraph alongside your editor
43+
/ `next dev`, could hit the per-user ceiling and fail with "OS file watch
44+
limit reached." The watcher now excludes the same directories the indexer
45+
ignores (the built-in default-ignore set **plus** your `.gitignore`) *before*
46+
registering a watch — so on a repo with a 900-directory `node_modules` the
47+
watch count drops from ~1,200 to ~14, even when the project has no
48+
`.gitignore`. (Stacks with the shared daemon from #411: one watcher across
49+
agents, and now that watcher is small.)
50+
3751
## [0.9.5] - 2026-05-25
3852

3953
### Fixed

__tests__/watcher.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,36 @@ describe('FileWatcher', () => {
166166

167167
watcher.stop();
168168
});
169+
170+
it('should not watch node_modules even without a .gitignore (#276/#417)', async () => {
171+
// No .gitignore in testDir — exclusion relies on the built-in
172+
// default-ignore set the indexer uses (buildDefaultIgnore), which a
173+
// .gitignore-only filter would miss.
174+
fs.mkdirSync(path.join(testDir, 'node_modules', 'dep', 'lib'), { recursive: true });
175+
fs.writeFileSync(path.join(testDir, 'node_modules', 'dep', 'index.ts'), 'export const dep = 1;');
176+
177+
const syncFn = vi.fn().mockResolvedValue({ filesChanged: 0, durationMs: 0 });
178+
const watcher = new FileWatcher(testDir, syncFn, { debounceMs: 200 });
179+
watcher.start();
180+
181+
// Let the watcher settle past any residual crawl events.
182+
await new Promise((r) => setTimeout(r, 400));
183+
syncFn.mockClear();
184+
185+
// A source-extension edit INSIDE node_modules must NOT trigger a sync —
186+
// the directory was never watched.
187+
fs.writeFileSync(path.join(testDir, 'node_modules', 'dep', 'lib', 'extra.ts'), 'export const e = 2;');
188+
await new Promise((r) => setTimeout(r, 600));
189+
expect(syncFn).not.toHaveBeenCalled();
190+
191+
// Positive control: a real source edit still triggers sync, proving the
192+
// watcher is live (not merely inert).
193+
fs.writeFileSync(path.join(testDir, 'src', 'live.ts'), 'export const live = 3;');
194+
await waitFor(() => syncFn.mock.calls.length > 0, 5000);
195+
expect(syncFn).toHaveBeenCalled();
196+
197+
watcher.stop();
198+
});
169199
});
170200

171201
describe('callbacks', () => {

package-lock.json

Lines changed: 31 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"license": "MIT",
3434
"dependencies": {
3535
"@clack/prompts": "^1.3.0",
36+
"chokidar": "^4.0.3",
3637
"commander": "^14.0.2",
3738
"fast-string-width": "^3.0.2",
3839
"fast-wrap-ansi": "^0.2.0",

src/extraction/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ const DEFAULT_IGNORE_PATTERNS: string[] = [
166166
* the defaults apply to tracked files too (committing a dependency dir doesn't make
167167
* it project code; the explicit `.gitignore` negation is the only opt-in).
168168
*/
169-
function buildDefaultIgnore(rootDir: string): Ignore {
169+
export function buildDefaultIgnore(rootDir: string): Ignore {
170170
const ig = ignore().add(DEFAULT_IGNORE_PATTERNS);
171171
try {
172172
const rootGitignore = path.join(rootDir, '.gitignore');

src/mcp/daemon.ts

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -280,55 +280,61 @@ export type AcquireResult =
280280
| { kind: 'taken'; existing: DaemonLockInfo | null; pidPath: string };
281281

282282
/**
283-
* Atomically create the daemon pidfile AND write its full record in the same
284-
* call. Returns either an `acquired` result (the caller is now the daemon-elect
285-
* and may construct a {@link Daemon}) or a `taken` result.
283+
* Atomically create the daemon pidfile with its full record already in place.
284+
* Returns either an `acquired` result (the caller is the daemon-elect and may
285+
* construct a {@link Daemon}) or a `taken` result.
286286
*
287-
* must-fix 1 (issue #411 review): the original implementation created the
288-
* pidfile empty under an `O_EXCL` fd and only wrote the body later, after
289-
* `server.listen` resolved. A second candidate that read the pidfile during
290-
* that millisecond-wide window saw an empty file, decoded it as `null`, treated
291-
* it as stale, and `unlink`'d the lock the first daemon still held — producing
292-
* two daemons (two watchers, two writers) on concurrent startup, exactly the
293-
* multi-agent scenario the feature targets. Writing the complete record before
294-
* returning the handle closes that window: a concurrent reader always sees a
295-
* valid pid+version+socketPath, never an empty file. The socket path is
296-
* deterministic from the project root, so it's known here.
287+
* must-fix 1 (issue #411 review): the lockfile must appear in ONE atomic step,
288+
* already complete — never empty, even momentarily. The first attempt at this
289+
* (`O_EXCL` create then a separate `writeSync`) left a microsecond window where
290+
* the file existed but was empty; under concurrent daemon startup a third
291+
* candidate could read that empty file, decode it as `null`, and `unlink` the
292+
* winner's lock → two daemons (two watchers, two writers). The window was
293+
* normally too small to hit, but the chokidar watcher's extra startup time made
294+
* concurrent daemons overlap enough to reproduce it reliably.
295+
*
296+
* The fix writes the complete record to a private temp file, then hard-links it
297+
* into place: `link()` is atomic AND exclusive (EEXIST if the target exists), so
298+
* the pidfile becomes visible in one step already containing a full record.
299+
* Whoever links first wins; everyone else gets EEXIST and reads a complete file.
300+
* There is no empty-file window at all.
297301
*/
298302
export function tryAcquireDaemonLock(projectRoot: string): AcquireResult {
299303
const pidPath = getDaemonPidPath(projectRoot);
300304
// Make sure the .codegraph/ directory exists — the daemon may be the first
301305
// thing to touch it on a fresh-clone-but-already-initialized checkout.
302306
fs.mkdirSync(path.dirname(pidPath), { recursive: true });
303307

308+
const info: DaemonLockInfo = {
309+
pid: process.pid,
310+
version: CodeGraphPackageVersion,
311+
socketPath: getDaemonSocketPath(projectRoot),
312+
startedAt: Date.now(),
313+
};
314+
315+
// Temp name is pid-scoped so racing candidates never collide on it.
316+
const tmp = `${pidPath}.${process.pid}.tmp`;
317+
let acquired = false;
304318
try {
305-
// `wx` = O_CREAT | O_EXCL | O_WRONLY: atomic "create only if absent".
306-
const fd = fs.openSync(pidPath, 'wx', 0o600);
307-
const info: DaemonLockInfo = {
308-
pid: process.pid,
309-
version: CodeGraphPackageVersion,
310-
socketPath: getDaemonSocketPath(projectRoot),
311-
startedAt: Date.now(),
312-
};
319+
fs.writeFileSync(tmp, encodeLockInfo(info), { mode: 0o600 });
313320
try {
314-
// Synchronous write immediately after the create — no await in between —
315-
// so the empty-file window is a single fs.writeSync, not an I/O-bound
316-
// `server.listen`. Combined with the pid-verified `clearStaleDaemonLock`
317-
// below, concurrent candidates can never delete a live daemon's lock.
318-
fs.writeSync(fd, encodeLockInfo(info));
319-
} finally {
320-
fs.closeSync(fd);
321+
fs.linkSync(tmp, pidPath); // atomic + exclusive
322+
acquired = true;
323+
} catch (err: unknown) {
324+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
321325
}
322-
return { kind: 'acquired', pidPath, info };
323-
} catch (err: unknown) {
324-
const e = err as NodeJS.ErrnoException;
325-
if (e.code !== 'EEXIST') throw err;
326+
} finally {
327+
try { fs.unlinkSync(tmp); } catch { /* temp already gone */ }
326328
}
327329

330+
if (acquired) return { kind: 'acquired', pidPath, info };
331+
332+
// Taken. Because the pidfile was link'd atomically it always holds a complete
333+
// record — `existing` is null only for a genuinely corrupt leftover, never a
334+
// mid-write race.
328335
let existing: DaemonLockInfo | null = null;
329336
try {
330-
const raw = fs.readFileSync(pidPath, 'utf8');
331-
existing = decodeLockInfo(raw);
337+
existing = decodeLockInfo(fs.readFileSync(pidPath, 'utf8'));
332338
} catch { /* unreadable lockfile — treat as malformed */ }
333339
return { kind: 'taken', existing, pidPath };
334340
}

0 commit comments

Comments
 (0)