Skip to content

fs: report errors from recursive watch events - #66792

Open
codeNinja62 wants to merge 1 commit into
nodejs:mainfrom
codeNinja62:fs-recursive-watch-errors
Open

codeNinja62 wants to merge 1 commit into
nodejs:mainfrom
codeNinja62:fs-recursive-watch-errors

Conversation

@codeNinja62

Copy link
Copy Markdown

Fixes: #66791

On Linux, recursive fs.watch() is the JavaScript watcher in lib/internal/fs/recursive_watch.js. Since #65486 it handles each inotify event with a stat()/lstat() of the entry it names, and calls fs.watch() on directories and symlinks that show up later. When one of those calls fails with anything other than ENOENT, the error is thrown inside the FSEvent callback, where no 'error' listener can see it, and the process exits:

Trigger Thrown from v24.22.0, v26.9.0, v26.12.0 v24.21.0, v26.4.0, v26.8.2
watched directory becomes unreadable (chmod 000) lstatSync() in #onFolderEvent() uncaught EACCES, exit 1 'error', keeps running
new directory already unreadable when its event is handled fs.watch() in #watch() uncaught EACCES, exit 1 'error', keeps running
new symlink into an unreadable directory, or a symlink loop fs.watch() in #watch() uncaught EACCES / ELOOP, exit 1 'error', keeps running
watched directory replaced by a file while events for its entries are queued lstatSync() in #onFolderEvent() uncaught ENOTDIR, exit 1 keeps running, no event

What this changes:

  • The statSync()/lstatSync() calls in #onFolderEvent() and #onEntryEvent() now report failures through 'error', the same way #scanFolder() already handles readdirSync() failures (fs: ignore deleted dirs in recursive watch scan #63686). ENOTDIR from lstatSync() is treated as the entry being gone, which is what statSync() with throwIfNoEntry: false and #scanFolder() already do.
  • #watch() now gets the initial flag its callers already have. After the initial scan, a failure to watch a directory or symlink is reported through 'error' and the entry is dropped, so a later event about it (for example the chmod that makes it readable again) picks it up. During the initial scan, errors still propagate out of fs.watch(), as fs: fix recursive watch error handling #65635 and fs: handle recursive watch setup races #65699 intended.

Why 'error' instead of silently forgetting the entry: the watcher can no longer see inside that directory, the native watchers report this kind of failure through 'error' too, and long-running consumers such as file-watching daemons need to know. Without an 'error' listener the emitter still throws, like any other FSWatcher error.

I didn't add a #closed check before the new emit('error') calls: both handlers return early once the watcher is closed, and so does #watch(). The only code that can run in between is a user-supplied ignore function, which can already lead to 'change' being emitted after close().

Tests (each one crashes the process without this change and passes with it):

  • test-fs-watch-recursive-unreadable-dir.js: a directory made unreadable while watched, and a directory that is already unreadable when its creation is handled and is picked up once it becomes readable. Skipped as root.
  • test-fs-watch-recursive-dir-replaced-by-file.js: the ENOTDIR case; no 'error' is expected.
  • test-fs-watch-recursive-symlink-loop.js: a symlink loop created while watching; 'error' with ELOOP is expected. This covers the symlink branch of #addEntry().

Checked locally on a build of main (Ubuntu on WSL2): the new tests fail before the change and pass after it, 10 runs in a row; tools/test.py 'parallel/test-fs-watch*' 'sequential/test-fs-watch*' passes (49 tests); make lint-js is clean.

v24.22.0 has the same code, since #65486 landed there too, so this should go to v24.x as well.

I used an AI coding assistant to help investigate this and draft the change. I reproduced the crashes myself, reviewed the diff, and ran the tests and lint locally.

On Linux, the recursive fs.watch() fallback resolves inotify events
with stat() and lstat() calls, and arms fs.watch() on directories and
symbolic links that appear later. Errors from those calls other than
ENOENT were thrown from inside the FSEvent callback, where nothing can
catch them, so the process exited: EACCES when a watched directory
became unreadable, or when a new directory or the target of a new
symbolic link was unreadable by the time it was handled; ELOOP for a
symbolic link loop; and ENOTDIR when a watched directory was replaced
by a file while events naming its entries were still queued.

Report such errors through 'error', as #scanFolder() already does for
readdir(), and take ENOTDIR from lstat() to mean that the entry is
gone, as statSync() and #scanFolder() do. A directory or link that
cannot be watched is dropped, so a later event about it picks it up
again. Failures during the initial scan still propagate out of
fs.watch().

Fixes: nodejs#66791
Signed-off-by: Sameer Ahmed <166151464+codeNinja62@users.noreply.github.com>
@nodejs-github-bot nodejs-github-bot added fs Issues and PRs related to file-system APIs and the fs module. needs-ci PRs that need a full CI run. labels Oct 16, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Welcome to Node.js, and thank you for your first contribution!

Before review, please take a moment to read:

Please make sure every commit is signed off. For a first pull request, GitHub Actions require collaborator approval and Jenkins CI must be started by a collaborator or triager, so an initial wait is normal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fs Issues and PRs related to file-system APIs and the fs module. needs-ci PRs that need a full CI run.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fs.watch recursive crashes the process on Linux (uncaught EACCES) when a watched directory becomes unreadable

2 participants