Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/rewatch-overlapping-unwatch.fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Rewatch: keep watching a source folder on Linux when an overlapping recursive source folder is removed from `rescript.json`. https://github.com/rescript-lang/rescript/pull/8839
Comment thread
cknitt marked this conversation as resolved.
172 changes: 167 additions & 5 deletions rewatch/src/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,12 +216,32 @@ fn register_watches(
registered
}

/// Brings the registered watches from `current` to `next`, touching only the paths that changed.
/// Whether `update_watches` registers only the new paths, leaving unchanged watches alone, rather
/// than registering every path again (see `update_watches` for why this differs per platform).
const REGISTER_ONLY_NEW_WATCHES: bool = cfg!(target_os = "macos");

/// Brings the registered watches from `current` to `next`, which happens on every full rebuild:
/// after a `rescript.json` change, but also when a source file is added, removed or renamed.
///
/// Stale watches are unwatched first: those whose path is no longer in `next`, and those whose
/// directory was replaced since it was registered. What happens to the other watches depends on
/// how the platform's watcher backend (from the `notify` crate) treats overlapping watches:
///
/// - **macOS (FSEvents):** every `watch`/`unwatch` call restarts the event stream, and events that
/// happen during the restart are lost. Each registered path is watched independently, so
/// unwatching one path never affects another. Only the new paths are registered, so a full
/// rebuild that keeps the same paths doesn't restart the stream at all.
///
/// - **Linux (inotify) and other platforms:** notify keeps one watch per directory, shared by
/// every registered path that reaches it: overlapping source folders, other spellings of a
/// folder (`..`, symlinks) and directory symlinks inside a recursive folder. Unwatching a stale
/// path can therefore also drop a watch that is still needed (#8825). So every path in `next` is
/// registered again after the unwatches. Registering a directory that is already watched only
/// updates its existing watch, so watches that weren't dropped keep reporting events without a
/// gap, and those that were dropped are restored.
///
/// Some backends restart their event stream on every `watch`/`unwatch` call (FSEvents on macOS
/// does), and events that happen during the restart are lost. Leaving unchanged watches alone
/// avoids those gaps entirely in the common case where a full rebuild keeps the same paths.
/// Watches whose directory was replaced since registration are registered again.
/// Windows takes this path too. It has no stable directory identity, so every watch counts as
/// stale there and is unwatched and registered again on each full rebuild.
///
/// Returns the watches that are registered afterwards, which excludes paths that failed to register.
fn update_watches(
Expand All @@ -236,6 +256,9 @@ fn update_watches(
for watch in stale {
let _ = watcher.unwatch(&watch.path);
}
if !REGISTER_ONLY_NEW_WATCHES {
return register_watches(watcher, next);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve recursive mode when replaying overlapping watches

On Linux, when next contains a recursive parent and a non-recursive child or alias of a directory it covers, replaying every entry can overwrite notify 5.2's single inotify record for that directory with is_recursive = false if the non-recursive entry is registered last. Because compute_watch_paths returns fresh HashMap iteration order, a full rebuild can downgrade a previously recursive watch; directories subsequently created below that point are not added to inotify, so edits inside them are missed. Register non-recursive targets before recursive targets, or otherwise preserve the strongest mode for shared directory identities.

Useful? React with 馃憤聽/ 馃憥.

Comment on lines 256 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rescan after restoring indirectly removed watches

When a stale recursive watch overlaps a retained child, unwatch removes the child's shared inotify descriptor and the child is restored only later by register_watches. An edit during that interval occurs after initialize_build has already scanned the sources and produces no event, leaving its output stale. Although the prior gap report was answered by retaining unchanged entries, the final stale-only loop still drops these particular retained entries indirectly; rescan after registration or otherwise reconcile changes made during this gap.

Useful? React with 馃憤聽/ 馃憥.

}
let added: WatchPaths = next
.iter()
.filter(|(path, mode)| {
Expand Down Expand Up @@ -999,4 +1022,143 @@ mod tests {
std::fs::create_dir(&dir).expect("dir should be recreated");
assert!(!watch.is_still_valid_for(&dir, RecursiveMode::Recursive));
}

/// Creates the symlinks `(link, target)` of `links`, registers `before` with a real watcher,
/// updates the watches to `after`, and checks that a file written to `write_dir` afterwards is
/// reported. Paths are relative to a temp dir, and `""` is the temp dir itself.
#[cfg(target_os = "linux")]
fn assert_update_keeps_watching(
before: &[(&str, RecursiveMode)],
after: &[(&str, RecursiveMode)],
write_dir: &str,
links: &[(&str, &str)],
) {
use std::sync::mpsc;

let temp_dir = tempfile::TempDir::new().expect("temp dir should be created");
let root = temp_dir.path();
for (link, target) in links {
std::fs::create_dir_all(root.join(target)).expect("dir should be created");
if let Some(parent) = root.join(link).parent() {
std::fs::create_dir_all(parent).expect("dir should be created");
}
std::os::unix::fs::symlink(root.join(target), root.join(link))
.expect("symlink should be created");
}
let to_watch_paths = |paths: &[(&str, RecursiveMode)]| -> WatchPaths {
paths
.iter()
.map(|(path, mode)| (root.join(path), *mode))
.collect()
};
for (path, _) in to_watch_paths(before).iter().chain(&to_watch_paths(after)) {
std::fs::create_dir_all(path).expect("dir should be created");
}
std::fs::create_dir_all(root.join(write_dir)).expect("dir should be created");

let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(tx, Config::default()).expect("watcher should start");
let current = register_watches(&mut watcher, &to_watch_paths(before));
let registered = update_watches(&mut watcher, current, &to_watch_paths(after));
assert_eq!(registered.len(), after.len());

let file = root.join(write_dir).join("Demo.res");
std::fs::write(&file, "let x = 2").expect("file should be written");
// Events name the file through whichever spelling of its directory notify registered
let resolved_file = std::fs::canonicalize(&file).expect("file should resolve");
let is_file = |path: &PathBuf| std::fs::canonicalize(path).ok().as_ref() == Some(&resolved_file);
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
match rx.recv_timeout(remaining) {
Ok(Ok(event)) if event.paths.iter().any(is_file) => return,
Ok(_) => continue,
Err(_) => panic!(
"no event for {} after updating watches from {before:?} to {after:?}",
file.display()
),
}
}
}

/// inotify shares one watch per directory between overlapping paths, so unwatching one path
/// can drop another (#8825). Watches that remain configured must keep reporting events.
#[cfg(target_os = "linux")]
#[test]
fn keeps_watching_after_an_overlapping_watch_is_removed() {
use RecursiveMode::{NonRecursive, Recursive};

// A full rebuild that keeps the same paths, overlapping ones included
assert_update_keeps_watching(
&[
("", NonRecursive),
("src", Recursive),
("src/retained", NonRecursive),
],
&[
("", NonRecursive),
("src", Recursive),
("src/retained", NonRecursive),
],
"src/retained",
&[],
);
// A folder kept below a removed recursive folder
assert_update_keeps_watching(
&[
("", NonRecursive),
("src", Recursive),
("src/retained", NonRecursive),
],
&[("", NonRecursive), ("src/retained", NonRecursive)],
"src/retained",
&[],
);
assert_update_keeps_watching(
&[("src", Recursive), ("src/retained", Recursive)],
&[("src/retained", Recursive)],
"src/retained/nested",
&[],
);
// A folder removed below a kept recursive folder
assert_update_keeps_watching(
&[("src", Recursive), ("src/retained", NonRecursive)],
&[("src", Recursive)],
"src/retained",
&[],
);
// A folder that stops being recursive
assert_update_keeps_watching(
&[("", Recursive), ("src", NonRecursive)],
&[("", NonRecursive), ("src", NonRecursive)],
"src",
&[],
);
// Other spellings of a folder below the removed one
assert_update_keeps_watching(
&[("other/../src/retained", NonRecursive), ("src", Recursive)],
&[("other/../src/retained", NonRecursive)],
"src/retained",
&[],
);
assert_update_keeps_watching(
&[("link", NonRecursive), ("src", Recursive)],
&[("link", NonRecursive)],
"src/retained",
&[("link", "src/retained")],
);
// A recursive folder reaching a separately watched one through a symlink
assert_update_keeps_watching(
&[("shared", NonRecursive), ("src", Recursive)],
&[("src", Recursive)],
"shared",
&[("src/link", "shared")],
);
assert_update_keeps_watching(
&[("shared", NonRecursive), ("src", Recursive)],
&[("shared", NonRecursive)],
"shared",
&[("src/link", "shared")],
);
}
}
Loading