Skip to content

fix: never abandon a recursive watch, report what failed instead - #970

Open
martinpitt wants to merge 3 commits into
notify-rs:mainfrom
martinpitt:watch-race
Open

fix: never abandon a recursive watch, report what failed instead#970
martinpitt wants to merge 3 commits into
notify-rs:mainfrom
martinpitt:watch-race

Conversation

@martinpitt

Copy link
Copy Markdown

A subdirectory whose watch could not be installed previously ended the whole recursive add: every directory the walk had not reached yet was left eternally unwatched. Nothing retried it and nothing reported it, and the only symptom was a program that stopped seeing changes in part of its tree.

To fix this, carry on with the rest of the walk and hand each failure to the event handler, so that callers can tell their coverage is incomplete and act on it. The event loop reports failures for the same reason, rather than dropping everything that is not the watch limit.

Watching a tree that holds one unreadable directory succeeds now, covering everything else, where it used to fail as a whole. That is the treatment a directory appearing later already got, except that its failure was dropped rather than reported.

Cover both cases with tests.


Contribution Agreement

By contributing, you agree to the license terms (CC Zero 1.0 for notify crate, MIT/Apache 2.0 for the rest) and the code of conduct in CODE_OF_CONDUCT.md.

I agree.

Changelog

Add an entry to CHANGELOG.md if applicable. Create a new section ## notify (unreleased) if not already present.

Done.

Testing

The test suite will run after creating this PR. If builds fail, you must either fix the errors, ask for help, or provide a detailed explanation of why failures are expected. If you don't, a maintainer may prompt you, but it will take longer for your contribution to be reviewed.

I'll watch out for them. I covered both fixes with tests, and they fail without the fix.

Code Quality

Running cargo fmt and cargo clippy is appreciated but not required.

Done, they pass.

@martinpitt

martinpitt commented Aug 1, 2026

Copy link
Copy Markdown
Author

FTR, I ran into this with the buck2 build system. This will need a corresponding half to fix this for good (rebuilding the watch trees that failed, see facebook/buck2#1434). This was several hours of debugging.. there was an unreadable directory appearing during some split second, which cut off an entire branch of the watched tree, and from then on the daemon kept sending old data until I killed it. That effect was very subtle, I just kept wondering why my changes to the tree didn't work and I got outdated file contents.

daandemeyer pushed a commit to daandemeyer/buck2 that referenced this pull request Aug 4, 2026
A directory on which the daemon holds no inotify watch is invisible:
nothing under it produces an event, so buck keeps serving whatever it
read last. This had two causes:

 - Dropped events cleared DICE, but the directories created while they
   were being dropped were never watched.

 - A watcher error discarded the events buffered with it and failed the
   next command, after which the daemon carried on as if nothing had
   been missed.

To fix this, treat both as the same thing: clear DICE, as before, and
then register the tree again so that it covers the project. The new
registration is built before the old one is dropped, so nothing goes
unwatched in between, and the overlap only duplicates events.

Paths that cannot be watched at all (like an inaccessible directory) are
remembered, so that they trigger that recovery once rather than on every
command.

This depends on the `notify` crate actually reporting these failures to
us, which needs notify-rs/notify#970. Until that
lands, a failed watch install is not among the errors that reach us.

The new unit test passes with that notify fix; it stays `#[ignore]`d
here until a notify release carrying it is picked up.

Signed-off-by: Martin Pitt <martin@amutable.com>
daandemeyer added a commit to daandemeyer/buck2 that referenced this pull request Aug 4, 2026
Switch the notify watcher to a notify fork (patched in via
the daandemeyer/notify GitHub fork) that adds a watch_filtered()
API taking a filter which decides what gets watched: recursive scans
do not descend into rejected directories, directories created later
are checked against the filter before being auto-watched, and events
beneath rejected directories are suppressed.

The notify watcher passes a filter rejecting buck-out, ignored
directories, and paths buck cannot represent. On Linux (inotify)
these never get watch descriptors, so they generate no events at
all: this eliminates the dominant source of useless wakeups during
builds (buck-out writes), stops large buck-out trees from exhausting
fs.inotify.max_user_watches, and speeds up daemon startup. On macOS
(FSEvents) and Windows, which cannot watch directories selectively,
the events are suppressed inside the notify backend before reaching
our callback.

The filter prunes any directory whose own path matches an ignore
pattern, so a file-shaped glob (e.g. *.tmp) matching a directory
name prunes that whole subtree.

The fork also carries notify-rs/notify#970, which reports a failed
watch install instead of dropping it, so the coverage-recovery test
added earlier in this series no longer has to be #[ignore]d.

Signed-off-by: Daan De Meyer <daan@amutable.com>
@martinpitt

Copy link
Copy Markdown
Author

Pushed a clean rebase against current main.

daandemeyer pushed a commit to daandemeyer/buck2 that referenced this pull request Aug 5, 2026
A directory on which the daemon holds no inotify watch is invisible:
nothing under it produces an event, so buck keeps serving whatever it
read last. This had two causes:

 - Dropped events cleared DICE, but the directories created while they
   were being dropped were never watched.

 - A watcher error discarded the events buffered with it and failed the
   next command, after which the daemon carried on as if nothing had
   been missed.

To fix this, treat both as the same thing: clear DICE, as before, and
then register the tree again so that it covers the project. The new
registration is built before the old one is dropped, so nothing goes
unwatched in between, and the overlap only duplicates events.

Paths that cannot be watched at all (like an inaccessible directory) are
remembered, so that they trigger that recovery once rather than on every
command.

This depends on the `notify` crate actually reporting these failures to
us, which needs notify-rs/notify#970. Until that
lands, a failed watch install is not among the errors that reach us.

The new unit test passes with that notify fix; it stays `#[ignore]`d
here until a notify release carrying it is picked up.

Signed-off-by: Martin Pitt <martin@amutable.com>
daandemeyer added a commit to daandemeyer/buck2 that referenced this pull request Aug 5, 2026
Switch the notify watcher to a notify fork (patched in via
the daandemeyer/notify GitHub fork) that adds a watch_filtered()
API taking a filter which decides what gets watched: recursive scans
do not descend into rejected directories, directories created later
are checked against the filter before being auto-watched, and events
beneath rejected directories are suppressed.

The notify watcher passes a filter rejecting buck-out, ignored
directories, and paths buck cannot represent. On Linux (inotify)
these never get watch descriptors, so they generate no events at
all: this eliminates the dominant source of useless wakeups during
builds (buck-out writes), stops large buck-out trees from exhausting
fs.inotify.max_user_watches, and speeds up daemon startup. On macOS
(FSEvents) and Windows, which cannot watch directories selectively,
the events are suppressed inside the notify backend before reaching
our callback.

The filter prunes any directory whose own path matches an ignore
pattern, so a file-shaped glob (e.g. *.tmp) matching a directory
name prunes that whole subtree.

The fork also carries notify-rs/notify#970, which reports a failed
watch install instead of dropping it, so the coverage-recovery test
added earlier in this series no longer has to be #[ignore]d.

Signed-off-by: Daan De Meyer <daan@amutable.com>
Comment thread notify/CHANGELOG.md Outdated
- FEATURE: [windows] report created file/folder kinds when they can be determined [#935]
- CHANGE: [macOS] improve FSEvents callback performance by avoiding unnecessary allocations and repeated handler locking
- PERF: [kqueue] avoid filesystem walks for recursive kqueue unwatch
- FIX: never abandon a recursive watch, report what failed instead

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- FIX: never abandon a recursive watch, report what failed instead
- FIX: [inotify] never abandon a recursive watch, report what failed instead

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and also added the PR ref. I also added a second entry for the walk errors you suggested. LMK if I should rather merge them.

Unrelated: [#964] a few lines up has no link definition.

Comment thread notify/src/inotify.rs Outdated
Err(err) if matches!(err.kind, ErrorKind::PathNotFound) => {}
// Neither should anything else: returning would leave every directory the walk has
// not reached yet unwatched, with nothing to notice. Report it and carry on.
Err(err) => self.event_handler.handle_event(Err(err)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For recursive additions triggered by add_watch(..., watch_self = false) reaches this arm. The error is sent to the handler and add_watches_for_paths then returns Ok(()), so the existing reached_limit check in handle_inotify never sees it and cannot stop processing the remaining paths, it's a waste of processing time.

Could we special-case MaxFilesWatch before this arm and return Err(err)? That would preserve the existing stop-on-limit behavior while still continuing for recoverable, path-specific failures.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh yes, thanks for spotting! This was even worse than wasting time: once the limit is hit, every remaining directory would have handed the handler its own copy of the same error. 馃檲

To make this more robust, I split out that policy decision into a separate function and WalkFailure type, to avoid this oversight. It's a little more intrusive that way, but I think/hope worth it. Let me know if you don't like it.

Comment thread notify/src/inotify.rs Outdated
if let ErrorKind::MaxFilesWatch = add_watch_error.kind {
self.event_handler.handle_event(Err(add_watch_error));
let reached_limit = matches!(add_watch_error.kind, ErrorKind::MaxFilesWatch);
self.event_handler.handle_event(Err(add_watch_error));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_watches contains directories discovered through CREATE and MOVED_TO events. By the time this code processes such an event, the directory may already have been deleted or renamed; the inotify documentation explicitly calls out this race (see the Limitations and caveats section on https://man7.org/linux/man-pages/man7/inotify.7.html).

We should continue skipping PathNotFound here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, fixed. The event loop consults the same policy as the recursive walk now, and PathNotFound maps to skipping it silently.

Note, since it is not obvious from the diff: add_watch stats the path before anything else, and Error::io_watch already maps ENOENT to ErrorKind::PathNotFound. So the created-then-deleted directory really does arrive as that kind and really is filtered.

Comment thread notify/src/inotify.rs

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that there is another source of partial recursive watches that your PR doesn't currently cover: errors produced by WalkDir. So some fail paths will be ignored continuously. You might also want to touch that part if your problem would be affected.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and it is the same bug one layer up: filter_dir threw away every walkdir::Error, so a directory that could not be listed silently pruned that whole subtree. Fixed in a separate commit.

This requires a little dance: An unreadable directory fails both inotify_add_watch and read_dir, so for tasteful/useful error reporting these duplicates need to be filtered out. That commit does it and unit-tests it.

Adding watches down a tree already treats one failure differently from
the rest: a directory that vanishes between being listed and being
watched is skipped; anything else ends the walk.

The distinction lived in a match guard inside the inotify watch-adding
code, where it was hard to reach for unit tests, at least in restricted
build environments. Move the policy into a plain function over the error
kind.

This by itself does not change behaviour.

Signed-off-by: Martin Pitt <martin@amutable.com>
A subdirectory whose watch could not be installed previously ended the
whole recursive add: every directory the walk had not reached yet was
left eternally unwatched. Nothing retried it and nothing reported it,
and the only symptom was a program that stopped seeing changes in part
of its tree.

To fix this, carry on with the rest of the walk and hand each failure to
the event handler, so that callers can tell their coverage is incomplete
and act on it. The event loop consults the same policy now, rather than
dropping every failure that is not the watch limit.

Two failures keep the treatment they had: a directory that vanished
between being listed and being watched stays silent, and the watch limit
still ends the walk, as every directory after it would only run into the
same limit.

Watching a tree that holds one unreadable directory succeeds now,
covering everything else, where it used to fail as a whole. That is the
treatment a directory appearing later already got, except that its
failure was dropped rather than reported.

Cover both cases with tests.

Signed-off-by: Martin Pitt <martin@amutable.com>
The walk dropped every error `walkdir()` handed it. A directory it could
not list caused the whole subtree below it to get silently ignored. That
is the same problem the previous fixed for from failed watches, one
layer up. It is reachable by default because symlinks are followed
(unless configured otherwise), so a link into its own ancestor loses
everything the walk had not seen yet.

Hand walk errors to the same policy that gets applied to failed watches.
A vanished directory stays silent, the rest reach the event handler, and
the caller knows which subtree is missing.

Report the error pair that a single unreadable directory produces only
once: it can neither be watched nor be listed, and walkdir reports the
second right after the first. But for the caller these look identical
and should not cause duplicated treatment.

Signed-off-by: Martin Pitt <martin@amutable.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants