Repository navigation
fix(watch): bound config file watching and prevent ENOSPC / uncaughtException crash - #5
Conversation
|
Thanks — this also fixes a second, distinct crash mode with the same root cause on Linux, which I can reproduce deterministically. Posting the evidence in case it helps get this merged. Same root cause, different errno: ELOOP
Pi dies mid-session with no prompt and no chance to save. The user's workaround was Deterministic minimal reproductionAutoconf's mkdir -p repo/build
ln -s conf.file repo/build/conf.file # self-referential
ln -s conf.file repo/build/conf.dir # chain that resolves to it
touch repo/build/x # make the recursive watcher rescan the treeThe old code climbs from the project config dir to the nearest existing ancestor and watches that recursively. So the crash needs the config dir's immediate parent to be missing as well:
i.e. a fresh clone of any project that has never had a project-level masking config, plus a dependency build that creates autoconf-style symlinks anywhere in the tree → pi dies. A/B verificationBundled both versions with esbuild and drove them through an identical harness that mirrors the real wiring (global config dir exists like
Environment: Linux, Node v24.20.0, pi 0.85.1, extension v0.6.2. Notes that may help review / merge
One suggestion: since the 250 ms poller already covers the "created later" case on Linux, dropping |
|
Thanks for the fix — the approach looks right (bounded traversal, non-recursive watchers, error handlers on every watcher, lifecycle guard), and the tests cover the crash scenarios well. One blocker: main has since extracted the watch logic from |
- Prevent unbounded ancestor climbing and recursive directory watching on Linux, which exhausted system inotify limits (ENOSPC) over large trees like ~/.pi/agent/npm/node_modules or repo roots. - Bounded ancestor watching strictly to parentDir non-recursively, with dynamic promotion to configDir and configPath upon creation. - Cleanly detach parentWatcher when configDir is created to prevent leaked handles and spurious reload events on parent metadata changes. - Wrap all FSWatcher registrations with safeWatch to immediately attach error listeners, preventing unhandled error events from crashing the host process with uncaughtException. - Add lifecycle stopped guard in watchConfigPaths to prevent timers from firing after teardown. - Add regression tests covering error resilience (ENOSPC/ENOENT), non-recursive parent boundaries, delayed intermediate directory creation, parentWatcher detachment, and directory deletion.
5436cd0 to
1129103
Compare
Problem
When
configDir(~/.pi/agent/pi-data-maskingor<cwd>/.pi/pi-data-masking) does not exist at session start,watchConfigFilecurrently traverses ancestors until it finds an existing directory (while (!existsSync(target)) target = dirname(target);), and attaches a watcher with{ recursive: true }without an'error'listener.On Linux:
recursive: truevianode:internal/fs/recursive_watchby issuing individualinotify_add_watchcalls for every file and folder in the tree.~/.pi/agent/(includingnpm/node_modules/with 34,000+ files,sessions/, etc.), quickly exhausting system inotify limits (fs.inotify.max_user_watches) withENOSPC.sessions/<id>/subagent-artifacts/), Node's recursive watcher attempts to watch the new file, hitsENOSPC, and emits an'error'event on theFSWatcher..on("error", ...)handler is attached, Node's EventEmitter contract triggers anuncaughtException, crashing the entire Pi agent harness (pi exiting due to uncaughtException: Error: ENOSPC: System limit for number of file watchers reached...).Solution
recursive: false).parentDir = dirname(configDir)). Large ancestor directories like<cwd>,~, or/are never watched.configDirdoes not exist yet, watch at mostparentDirnon-recursively, filtered todirName = basename(configDir). Sibling churn (e.g.sessions/,npm/) is ignored.configDiris created,parentWatcheris cleanly detached/closed, and a non-recursive watcher is attached toconfigDir.safeWatch):.on("error", ...)immediately upon watcher creation.ENOSPC,ENOENT, orEPERMwithout allowing unhandled exceptions to take down the host process.stoppedflag inwatchConfigPathsso pending timers never execute after session shutdown.Testing
npm test: All 164 tests pass (159 pre-existing + 5 new regression tests).npm run check:tscclean (0 errors).tests/config-loader.test.ts:watchConfigPaths attaches error handlers to all FSWatchers and survives ENOSPC / ENOENT error eventswatchConfigFile watches parentDir non-recursively when configDir does not exist yet and ignores sibling churnparentWatcher is cleanly detached when configDir is created and dirWatcher is attachedwatchConfigPaths does not climb beyond immediate parent and detects config when intermediate directories are created laterwatchConfigPaths survives watched directory deletion without throwing uncaught exceptions