backnotprop / backnotprop/plannotator

Bug: a dirPath subscription registers one inotify watch per file, exhausting fs.inotify.max_user_watches on large repos

Open
#1,439 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
8.7k
Forks
649
Avg merge
11h 12m
Merged PRs (30d)
109

Description

# Bug: a `dirPath` subscription registers one inotify watch per file, exhausting `fs.inotify.max_user_watches` on large repos

## Summary

On Linux, a single recursive folder-browser subscription (`dirPath` on `/api/reference/files/stream`) registers watches proportional to the **file** count of the tree, not the directory count, with no cap. On a large monorepo this consumed nearly every inotify watch the kernel allows for the user, which takes file watching away from every other process on the machine, not just from plannotator.

`FILE_BROWSER_EXCLUDED` is working correctly and is not the problem: `node_modules/` and friends are pruned exactly as intended. The watches are ordinary source files.

## Environment

- plannotator 0.27.9. The relevant files are byte-identical on `main` as of 0.27.10 (`git diff v0.27.9..main` over `packages/shared/file-browser-watch-core.ts`, `packages/shared/reference-common.ts`, `packages/server/reference-watch.ts`, `apps/pi-extension/server/file-browser-watch.ts` is empty), so 0.27.10 does not change this.
- Linux x86_64, chokidar 5.0.0
- Linux is always on the chokidar backend: `nativeRecursiveSupported()` is darwin/win32 only (`packages/shared/file-browser-watch-core.ts:136-138`), so the native recursive `fs.watch` branch is never taken there.

## Reproduction

Fully synthetic, no VCS and no large checkout required:

```bash
mkdir -p /tmp/flatrepo/flat
python3 -c "
import os
for i in range(20000):
open('/tmp/flatrepo/flat/f%05d' % i, 'w').write('x')
"
printf '# plan\n- a\n' > /tmp/flatrepo/plan.md
cd /tmp/flatrepo && plannotator annotate plan.md
# open the folder browser on /tmp/flatrepo, then count this process's inotify watches:
PID=$(pgrep -x plannotator)
for fd in /proc/$PID/fd/*; do
[ "$(readlink $fd)" = "anon_inode:inotify" ] &&
grep -c '^inotify' /proc/$PID/fdinfo/$(basename $fd)
done
```

## Observed vs expected

That tree is **2 directories and 20,001 files**.

Before the subscription:

```
inotify_fds=0 watches=0 rss_kb=265860
```

After:

```
inotify_fds=1 watches=20005 rss_kb=424008
```

20,005 watches for 2 directories, and 158 MB of RSS, roughly 8 KB per watched file. Watch count and memory both scale with file count, so the cost of subscribing to a repo root is the size of the repo.

Expected: a `dirPath` subscription is a coarse "something under here changed" signal, so its cost should not scale with the number of files. Something in the low single digits for this tree.

## Impact at real-world scale

On a large JS/TS monorepo (several hundred thousand files surviving `FILE_BROWSER_EXCLUDED`, spread across a main checkout plus several sibling worktrees), one plannotator process reached roughly **429,000 inotify watches and 6.2 GB RSS within about 7 minutes** of the folder browser being opened.

The host allows about 481,000 watches (`fs.inotify.max_user_watches`), already raised well above the 8,192 default. Everything else on the machine combined was using about 52,000. Those two numbers add up to the ceiling, so the watcher was not finished walking, it was capped by `ENOSPC` from `inotify_add_watch`. The desktop environment then began reporting that it could no longer watch files, and other applications lost file watching until plannotator was killed.

Replaying `chokidar.watch` with plannotator's exact options and its exact `ignored` predicate against the same tree, with a guard that aborts at 90k watches:

```
[tick] watches=68408 watchedDirs=18222 nodeModulesDirs=0
[tick] watches=179041 watchedDirs=66751 nodeModulesDirs=0
ABORT: exceeded threshold 90000
```

`nodeModulesDirs=0` throughout. The exclusion list does its job; watches still outnumber watched directories by roughly 3x, and climb until the kernel refuses.

## Root cause

`packages/shared/file-browser-watch-core.ts:186-199` builds the content watcher as:

```ts
chokidar.watch(target.watchPath, {
ignoreInitial: true,
persistent: true,
ignored: target.ignored,
awaitWriteFinish: { stabilityThreshold: 120, pollInterval: 30 },
});
```

chokidar's `NodeFsHandler._handleFile` ends with `const closer = this._watchWithNodeFs(file, listener);` (`chokidar@5.0.0/handler.js:393`), and `_watchWithNodeFs` calls `setFsWatchListener(path, sysPath.resolve(path), ...)` on the **file path itself** (`handler.js:305-333`). On Linux each of those is one `inotify_add_watch`. So a `dirPath` subscription costs `directories + files`.

Per-file watching is chokidar's normal behavior, not something this code asked for. It exists so chokidar can emit precise per-file `change` events with stats and run `awaitWriteFinish`. Neither is used here: the handler is `watcher.on("all", () => scheduleBroadcast(entry, "files"))` (`file-browser-watch-core.ts:196`), which discards the event entirely, and the broadcast sends the **subscription root** rather than the changed path (`:152-158`). The client's only reaction is to refetch the tree for that root (`packages/ui/hooks/useFileBrowser.ts:127-133, 153`). The full-fidelity watcher is paying for information nothing reads.

For contrast, the native branch does read `filename` and applies the ignore predicate to it (`file-browser-watch-core.ts:219-224`). The chokidar branch has no equivalent.

There is also no cap anywhere on this path. `PLANNOTATOR_FILE_BROWSER_MAX_FILES` bounds only the listing walk (`packages/shared/resolve-file.ts:65-79`); `packages/server/reference-watch.ts:89-105` applies no limit when constructing the watch target. And there is no way for a user to extend the exclusion list, since `FILE_BROWSER_EXCLUDED` is a module constant (`packages/shared/reference-common.ts:3-32`) with no config or env override.

## A Jujutsu-specific aggravator

`.git/` is in `FILE_BROWSER_EXCLUDED`; `.jj/` is not. A jj repo keeps one small file per operation, in flat directories that grow without bound over the life of the repo. On the monorepo above:

```
.jj/repo/op_store/operations ~43,000 files
.jj/repo/index/op_links ~43,000 files
.jj/repo/op_store/views ~43,000 files
.jj/repo/store/extra ~46,000 files
```

That is roughly 213,000 files in about 98 directories, a third of the total watch demand, none of it useful to a file browser. Any jj user with a mature repo pays this on top of their actual source tree.

## Suggested directions

These are suggestions; the right fix is yours to pick.

1. Add `.jj/` to `FILE_BROWSER_EXCLUDED`. One line, matches the existing `.git/` entry, and removes a large slice of the cost for jj users.
2. Bound the watcher the way the listing walk is already bounded, and log once when the bound is hit, so a pathological tree degrades to "no live refresh for this folder" rather than "no file watching anywhere on this machine".
3. Since the consumer only needs a coarse change signal, consider a watch strategy whose cost tracks directory count rather than file count.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.