fs: Re-establish OS watches for registrations orphaned by a removed recursive watch

A watch registration whose path is covered by a recursive ancestor
registration (native watches on macOS/Windows, poll watches everywhere)
is recorded without an OS watch of its own. When the covering
registration was removed - for example closing a window whose worktree
root covered another window's repository git directory - the surviving
registrations silently stopped receiving events until restart.

On removal of an OS-backed watch, re-watch surviving descendant
registrations that are no longer covered, shallowest first so a
re-watched ancestor covers the orphans below it. Coverage checks now
also require the covering ancestor to actually hold an OS watch.
This commit is contained in:
Lukas Wirth 2026-07-09 11:29:36 +02:00
parent 9cdaff1492
commit 6c87e20de5

View file

@ -712,15 +712,23 @@ impl WatchPaths {
}
/// True if a recursive registration on a strict ancestor already covers
/// `path`. Only poll watches and native macOS/Windows watches are recursive.
/// `path`. Only poll watches and native macOS/Windows watches are recursive,
/// and only registrations backed by an OS watch provide coverage: a covered
/// registration relies on some ancestor's OS watch itself, and that watch may
/// since have been removed.
fn covered_by_recursive_ancestor(&self, path: &SanitizedPath, mode: WatcherMode) -> bool {
if mode != WatcherMode::Poll && !cfg!(any(target_os = "windows", target_os = "macos")) {
return false;
}
path.as_path().ancestors().skip(1).any(|ancestor| {
let ancestor = SanitizedPath::unchecked_new(ancestor);
self.0.contains_key(&WatchKey::exact(ancestor))
|| self.0.contains_key(&WatchKey::folded(ancestor))
[WatchKey::exact(ancestor), WatchKey::folded(ancestor)]
.iter()
.any(|key| {
self.0
.get(key)
.is_some_and(|registration| registration.has_os_watcher)
})
})
}
@ -979,6 +987,82 @@ impl GlobalWatcher {
};
drop(state);
self.unwatch(path.as_path(), mode).log_err();
self.rewatch_orphaned_descendants(&path, mode);
}
/// Re-establishes OS watches for registrations that relied on a removed
/// recursive watch on an ancestor path for their event delivery.
///
/// A registration covered by a recursive ancestor watch is recorded without
/// an OS watch of its own. When the covering watch goes away (e.g. the
/// project that owned it is closed), the surviving registrations would
/// otherwise silently stop receiving events.
fn rewatch_orphaned_descendants(&self, removed_path: &SanitizedPath, mode: WatcherMode) {
if mode != WatcherMode::Poll && !cfg!(any(target_os = "windows", target_os = "macos")) {
return;
}
let mut orphans = {
let state = self.state.lock();
let orphans = state
.watchers
.values()
.filter(|registration| {
registration.mode == mode
&& registration.path.as_path() != removed_path.as_path()
&& path_is_under(
&registration.path,
removed_path,
matches!(registration.key, WatchKey::Folded(_)),
)
})
.map(|registration| (registration.key.clone(), registration.path.clone()))
.collect::<HashMap<_, _>>();
orphans.into_iter().collect::<Vec<_>>()
};
// Watch shallower paths first, so that a re-watched ancestor covers the
// orphans below it and they don't each get a redundant OS watch.
orphans.sort_by_key(|(_, path)| path.as_path().components().count());
for (key, path) in orphans {
let needs_watch = {
let mut state = self.state.lock();
let cooldown_active =
mode == WatcherMode::Native && state.is_native_watch_limit_cooldown_active();
let path_registrations = state.path_registrations(mode);
!cooldown_active
&& path_registrations
.get_mut(&key)
.is_some_and(|path_state| !path_state.has_os_watcher)
&& !path_registrations.covered_by_recursive_ancestor(&path, mode)
};
if !needs_watch {
continue;
}
match self.watch(path.as_path(), mode) {
Ok(()) => {
let mut state = self.state.lock();
if let Some(path_state) = state.path_registrations(mode).get_mut(&key) {
path_state.has_os_watcher = true;
} else {
// The registration was removed while we were watching; drop
// the OS watch again so it doesn't leak.
drop(state);
self.unwatch(path.as_path(), mode).log_err();
}
}
Err(error) if mode == WatcherMode::Native && is_max_files_watch_error(&error) => {
self.start_native_watch_limit_cooldown(path.as_path());
}
Err(error) => {
log::error!(
"failed to re-establish watch for {:?}: {error:#}",
path.as_path()
);
}
}
}
}
fn watch(&self, path: &Path, mode: WatcherMode) -> anyhow::Result<()> {
@ -1222,7 +1306,7 @@ mod tests {
}
#[test]
fn covered_child_registration_is_not_unwatched_after_parent_is_removed() {
fn covered_child_registration_is_rewatched_after_parent_is_removed() {
let backend = Arc::new(Mutex::new(FakeWatchBackend::default()));
let watcher = test_watcher(backend.clone());
let parent = Arc::<Path>::from(Path::new("/repo"));
@ -1237,14 +1321,72 @@ mod tests {
.expect("add covered child watch")
.expect("child watch registered");
// The child piggybacked on the parent's recursive watch; removing the
// parent must give the child an OS watch of its own.
watcher.remove(parent_registration);
watcher.remove(child_registration);
{
let backend = backend.lock();
assert_eq!(backend.watch_calls, &[parent.to_path_buf()]);
assert_eq!(
backend.watch_calls,
&[parent.to_path_buf(), child.to_path_buf()]
);
assert_eq!(backend.unwatch_calls, &[parent.to_path_buf()]);
}
watcher.remove(child_registration);
let backend = backend.lock();
assert_eq!(
backend.unwatch_calls,
&[parent.to_path_buf(), child.to_path_buf()]
);
}
#[test]
fn rewatched_ancestor_covers_deeper_orphans() {
let backend = Arc::new(Mutex::new(FakeWatchBackend::default()));
let watcher = test_watcher(backend.clone());
let root = Arc::<Path>::from(Path::new("/repo"));
let mid = Arc::<Path>::from(Path::new("/repo/a"));
let deep = Arc::<Path>::from(Path::new("/repo/a/b"));
let root_registration = watcher
.add(root.as_ref().into(), WatcherMode::Poll, false, |_| {})
.expect("add root watch")
.expect("root watch registered");
let mid_registration = watcher
.add(mid.as_ref().into(), WatcherMode::Poll, false, |_| {})
.expect("add covered mid watch")
.expect("mid watch registered");
let _deep_registration = watcher
.add(deep.as_ref().into(), WatcherMode::Poll, false, |_| {})
.expect("add covered deep watch")
.expect("deep watch registered");
// Removing the root re-watches the shallowest orphan only; the deeper
// orphan is covered by the re-watched ancestor's recursive watch.
watcher.remove(root_registration);
{
let backend = backend.lock();
assert_eq!(
backend.watch_calls,
&[root.to_path_buf(), mid.to_path_buf()]
);
assert_eq!(backend.unwatch_calls, &[root.to_path_buf()]);
}
// Removing the mid registration orphans the deep one in turn.
watcher.remove(mid_registration);
let backend = backend.lock();
assert_eq!(
backend.watch_calls,
&[root.to_path_buf(), mid.to_path_buf(), deep.to_path_buf()]
);
assert_eq!(
backend.unwatch_calls,
&[root.to_path_buf(), mid.to_path_buf()]
);
}
#[gpui::test]
async fn pending_path_is_registered_once_created(cx: &mut gpui::TestAppContext) {
let temp_dir = tempfile::tempdir().expect("create temp dir");