v5: many concurrent watchers cause child_process.fork() EBADF on macOS (regression from v3)
When chokidar v5 watches a large project directory (depth ≥ 4, or roughly >10,000 files/dirs), any subsequent call to child_process.fork() fails with EBADF. This is a regression from v3 which used fsevents and did not exhibit this problem.
Environment
- OS: macOS Darwin 25.3.0 (macOS 16 beta), Apple Silicon
- Node.js: v24.13.0
- chokidar: 5.0.0
- Reproduced in: Nuxt 4 dev server (spawn EBADF in ForkPool.createFork)
Minimal reproduction
import { fork } from 'child_process';
import chokidar from 'chokidar';
const forkTest = () => new Promise(resolve => {
try {
const p = fork('/dev/null', [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
p.on('error', e => resolve('ERROR: ' + e.code));
p.on('exit', () => { p.kill(); resolve('OK'); });
} catch (e) {
resolve('THREW: ' + e.code);
}
});
// Works fine before watching
console.log(await forkTest()); // → OK
// Watch a large project directory
const watcher = chokidar.watch('/path/to/large/project', {
ignored: /(node_modules|\.git)/,
ignoreInitial: true,
depth: Infinity
});
await new Promise(r => watcher.on('ready', r));
// fork() now fails
console.log(await forkTest()); // → THREW: EBADF
// Closing the watcher restores normal behavior
await watcher.close();
console.log(await forkTest()); // → OKRoot cause analysis
chokidar v5 dropped fsevents and switched to individual node:fs.watch() calls — one per file and one per directory. On macOS, each fs.watch() call opens a file descriptor via kqueue + EVFILT_VNODE and keeps it open for the lifetime of the watch.
For a typical medium-to-large project (e.g. a Nuxt app with Tauri integration), watching the full source tree creates 10,000–40,000 concurrent fs.watch() handles. At approximately >10,500 concurrent handles, child_process.fork() begins failing with EBADF.
We isolated the threshold experimentally:
import { fork } from 'child_process';
import { watch } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
const forkTest = () => new Promise(resolve => {
try {
const p = fork('/dev/null', [], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
p.on('error', e => resolve('ERR:' + e.code));
p.on('exit', () => { p.kill(); resolve('OK'); });
} catch (e) { resolve('THREW:' + e.code); }
});
// Collect all files+dirs recursively
async function getAllPaths(base, depth = 0) {
if (depth > 5) return [];
let paths = [base];
const entries = await readdir(base, { withFileTypes: true });
for (const e of entries) {
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
const p = join(base, e.name);
if (e.isDirectory()) paths.push(...await getAllPaths(p, depth + 1));
else paths.push(p);
}
return paths;
}
const paths = await getAllPaths('/path/to/large/project');
console.log('Total paths:', paths.length); // e.g. 11734
const watchers = [];
for (let i = 0; i < paths.length; i++) {
try { watchers.push(watch(paths[i], () => {})); } catch (e) {}
if (i % 500 === 0) {
const result = await forkTest();
console.log(`watchers=${i + 1}:`, result);
if (result !== 'OK') { console.log('Threshold reached at', i + 1); break; }
}
}
// Output: fails around 10,500Why does this happen? On macOS, posix_spawn() (used internally by Node.js/libuv to create child processes) iterates over all open file descriptors to set up close-on-exec actions for the child. When the process has ~10,000+ open kqueue fds from fs.watch(), this operation fails with EBADF on macOS — a known macOS-specific limitation with large numbers of open file descriptors during posix_spawn().
Note: this affects fork() even with stdio: ['pipe', 'pipe', 'pipe', 'ipc'] — it is not a stdio inheritance issue.
Why chokidar v3 did not have this problem
chokidar v3 used the fsevents native module on macOS, which opens a single FSEventStreamRef for an entire directory tree. This uses Apple's FSEvents framework and does not accumulate per-file kqueue descriptors.
Confirming: a single fs.watch(path, { recursive: true }, ...) call also does not trigger the issue — it uses FSEvents internally and works correctly regardless of directory size.
Impact
Any application using chokidar v5 to watch a large project on macOS will silently break child_process.fork(). We observed this in:
- Nuxt 4 dev server (ForkPool.createFork fails with EBADF after build)
- Likely reproducible in any large Webpack/Vite/Next.js/Nest.js project on macOS
Possible fixes
- Re-add fsevents as an optional dependency on macOS (same as v3). Use it when available; fall back to node:fs.watch only when unavailable. This is the cleanest fix and restores v3 behavior on macOS.
- Use { recursive: true } in fs.watch() on macOS. A single recursive fs.watch() per top-level watched path uses FSEvents internally and avoids accumulating per-file kqueue handles. This would require refactoring depth tracking to be done in JS rather than at the OS level.
- Document the limitation and recommend that users configure ignored patterns to keep the total number of watched paths below ~10,000 on macOS.
Source: paulmillr/chokidar