fix(daemon): ignore .venv and other large dirs in project file watcher (#531)

* fix(daemon): ignore .venv and other large dirs in project file watcher

A project containing a Python virtual environment (.venv) could exhaust
the daemon's file descriptor table — chokidar recursively watched every
file in the tree, opening ~18 000 fds. With the fd table full, macOS
posix_spawn returned EBADF when the daemon tried to create stdio pipes
for a child process (codex exec, or any other agent), surfacing as
"spawn failed: spawn EBADF" on every chat request.

Adds .venv, venv, __pycache__, .mypy_cache, .pytest_cache, .tox,
.ruff_cache, target, vendor, and .cargo to the per-segment IGNORE_NAMES
set so the watcher skips these trees in any project.

* fix(daemon): narrow project-watcher ignores to safe Python dirs only

Remove target, vendor, and .cargo from IGNORE_NAMES — they match any
path segment, so src/vendor/… or .cargo/config.toml (a valid Rust
project file) would be silently dropped from file-change events.

Keep only the Python-specific names (.venv, venv, __pycache__, and the
mypy/pytest/tox/ruff caches) which are never legitimate authored source
at any depth and were the root cause of the fd-exhaustion bug.

Add a real-chokidar test covering all seven newly added ignore dirs.

---------

Co-authored-by: hbrown <hbrown@mitre.org>
This commit is contained in:
brown2hm 2026-05-06 06:07:08 -04:00 committed by GitHub
parent b1121c04f5
commit 99d443c512
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 44 additions and 1 deletions

View file

@ -17,7 +17,24 @@ import { projectDir } from './projects.js';
// against the path *relative to the watch root* so that ancestor directories
// (e.g. the daemon's own `.od/` runtime dir, which contains every project) do
// not accidentally match and silence every event in the tree.
const IGNORE_NAMES = new Set(['.git', 'node_modules', '.od', 'debug', '.DS_Store']);
const IGNORE_NAMES = new Set([
'.git',
'node_modules',
'.od',
'debug',
'.DS_Store',
// Python virtual environments and caches — can contain tens of thousands of
// files, exhausting the process fd table and breaking child-process spawning.
// These names are safe to match at any path depth: a directory named `.venv`
// or `__pycache__` is never legitimate authored source in a project tree.
'.venv',
'venv',
'__pycache__',
'.mypy_cache',
'.pytest_cache',
'.tox',
'.ruff_cache',
]);
export function makeIgnored(rootDir) {
return (absPath) => {
const rel = path.relative(rootDir, absPath);

View file

@ -181,6 +181,32 @@ describe('project-watchers (real chokidar)', () => {
}
}, 8_000);
it('ignores files inside Python venv and cache dirs', async () => {
const { root, projectId } = await makeProjectsRoot();
const events = [];
const sub = subscribe(root, projectId, (e) => events.push(e));
await sub.ready;
const ignoredDirs = ['.venv', 'venv', '__pycache__', '.mypy_cache', '.pytest_cache', '.tox', '.ruff_cache'];
try {
for (const dir of ignoredDirs) {
await mkdir(path.join(root, projectId, dir), { recursive: true });
await writeFile(path.join(root, projectId, dir, 'file.py'), '');
}
await writeFile(path.join(root, projectId, 'real.txt'), 'real');
await waitFor(() => events.some((e) => e.path === 'real.txt'));
const ignored = events.filter((e) =>
ignoredDirs.some((dir) => e.path.startsWith(`${dir}/`)),
);
expect(ignored).toEqual([]);
} finally {
await sub.unsubscribe();
await rm(root, { recursive: true, force: true });
}
}, 8_000);
it('attaches an error listener and survives an emitted error event', async () => {
// Regression for codex P1: chokidar's FSWatcher is an EventEmitter.
// Without an 'error' listener, transient FS faults (ENOSPC, EPERM,