Skip to content

feat: track a path through every ancestor - #123

Draft
shulaoda wants to merge 12 commits into
mainfrom
09-24-feat_track_a_path_through_every_ancestor
Draft

shulaoda wants to merge 12 commits into
mainfrom
09-24-feat_track_a_path_through_every_ancestor

Conversation

@shulaoda

@shulaoda shulaoda commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

A TrackPath watch now follows its path through every ancestor, on every backend:

  • a directory above the watched path moved away or deleted → the path is reported as removed, and the watches below are dropped;
  • that directory back, or created → the path is reported as created and watched again;
  • a path below directories that do not exist yet can be watched; it is reported once it appears.

This is what FSEvents' WatchRoot does natively. Before, inotify and kqueue dropped the watch of a moved ancestor and never re-armed it (kqueue only kept stale fds because its recursive removal walked the disk), Windows ignored the ancestor's event and never opened a handle for a tracked directory that appeared later, and FSEvents suppressed the ROOT_CHANGED that says the root is back.

Per backend:

  • inotify: every existing ancestor gets an entries-only watch (CREATE | DELETE | MOVED_FROM | MOVED_TO), shared and ref-counted; the parent keeps the full mask. vanish_below / rearm_below run on ancestor events.
  • kqueue: the same, plus a look at the child on the way to the roots when an ancestor directory changes; recursive watch removal goes by the held handles instead of walking the disk; NOTE_LINK re-adds only under recursive roots.
  • Windows: ancestors join the handle targets unless a recursive handle covers them; the events of a read are delivered by the server thread, which first re-resolves the tracked paths below any ancestor or tracked path that came or went and converges the handles, so a root reported as created is already watched.
  • FSEvents: a ROOT_CHANGED with the root present is reported as Create. FSEvents skips that ROOT_CHANGED when the root comes back within a few milliseconds of going (probe: ~25% of the time under load, never with a 20 ms gap), so a root that went is looked at again 200 ms and 2 s later and reported as created if it is back; whichever side sees it first reports it, the other stays quiet.

Closes #32. Tests added per backend; get_watch_handles() leaves out the ancestor-only watches.

Fixes after review

A review of this PR (56 confirmed findings, then three review rounds of the fixes) is folded into the five fix(...) and docs commits on top. What changed, per backend:

  • All backends: unwatching a path keeps the watches that other watched paths still need (a directory above other roots stays their ancestor, roots below an unwatched recursive directory stay watched, a file reported through its parent keeps reporting). A directory that comes back is reported without its content. A path that comes back but cannot be watched is reported as an Err through the handler and armed again by watching it again.
  • inotify: a directory reached through several spellings (a symlink and its real path, a bind mount, .., hard links) shares one watch descriptor; every spelling a watched path covers is now reported, with the kernel mask being the union of the spellings' masks and each spelling filtered to what it asked for. A new directory below several spellings is walked once. watch and unwatch are indexed (unwatching 10k roots: ~200 ms). An ancestor that cannot be examined (EACCES, ELOOP) fails watch instead of waiting. A NoTrack file that was reported through its parent's watch is dropped with Remove(File) when the parent moves (see the TargetMode::NoTrack docs).
  • kqueue: check_chain follows an ancestor that is a symlink to a directory and acts only when presence changes; unwatching a missing root returns Ok and releases the ancestor chain; NOTE_LINK no longer drops a recursive NoTrack root; a root created between its stat and the kevent registration is caught; the entry fds of a non-recursive directory root are released on unwatch.
  • FSEvents: roots that FSEvents does not report itself (folded with ten or more siblings or above max_fsevent_paths, nested under another watched root, below directories that do not exist yet) are checked from the disk whenever a directory above them is created, removed or renamed, and reported Remove(Any) / Create with root changed. A root that comes back is reported once (the mark is bound to the file's inode). NoTrack roots get no root changed Create. For a root two or more levels below missing directories, the deepest existing ancestor (at least three directories deep) is watched until the next stream rebuild. The look-again thread sleeps on a Condvar and is joined on Drop.
  • Windows: a chain handle that cannot be opened is skipped instead of failing watch/commit; handles are rebuilt only when a root's presence or an ancestor's existence changed; only the entry whose own directory cannot be opened fails; re-watching a missing root reports Create. These changes ran only on CI (no Windows host here).

108 tests were added across the four backends. Known limits are listed in the TargetMode::TrackPath docs: a symlink retargeted in place (ln -sfn) is not noticed; FSEvents may miss folded roots when a directory is swapped or goes and comes back within milliseconds; anchors are computed at stream rebuild; notify-debouncer-full pairs a rename across spellings when a directory has several.

Note: the entries in the root CHANGELOG.md describe the change for upstream readers; the fork's release notes come from notify/CHANGELOG.md and the commit titles.

@shulaoda
shulaoda force-pushed the 09-24-feat_track_a_path_through_every_ancestor branch 3 times, most recently from 0a7754b to fa6dc9c Compare September 24, 2026 10:00
@shulaoda
shulaoda force-pushed the 09-24-feat_track_a_path_through_every_ancestor branch from fa6dc9c to f07fa4e Compare September 24, 2026 10:11
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.

Allow watching nested non-existent paths

1 participant