From 1d2dc326a0454be25155c075b9d92249b27d1233 Mon Sep 17 00:00:00 2001 From: Noah Peeters Date: Mon, 19 Oct 2026 17:09:52 +0200 Subject: [PATCH 1/3] Stop losing file changes on macOS when files are deleted (AI-assisted) Vfs::commit_event calls StdBackend::unwatch for every removed path, but only the roots passed to watch() are watched. unwatch forwarded every path to notify anyway, and notify's FSEvents backend handles an unwatch by tearing down and restarting its event stream; whatever changed during the restart was never reported. Deleting files while others change (a git checkout, a branch switch) therefore dropped events, and the served tree went stale without any error. Only call the watcher's unwatch for paths that are actually watched. The new test deletes 100 files (calling unwatch on each, as commit_event does) while creating 100 others: before this change macOS reported 1 of the 100 creations, now all 100. --- crates/memofs/src/std_backend.rs | 59 ++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/memofs/src/std_backend.rs b/crates/memofs/src/std_backend.rs index 3f0ab2c1..637d65cd 100644 --- a/crates/memofs/src/std_backend.rs +++ b/crates/memofs/src/std_backend.rs @@ -130,7 +130,62 @@ impl VfsBackend for StdBackend { } fn unwatch(&mut self, path: &Path) -> io::Result<()> { - self.watches.remove(path); - self.watcher.unwatch(path).map_err(io::Error::other) + // `Vfs::commit_event` calls this for every removed path, but only the roots passed to + // `watch` are watched. Forwarding any other path to notify made its FSEvents backend on + // macOS restart the event stream, and changes made during the restart were lost. + if self.watches.remove(path) { + self.watcher.unwatch(path).map_err(io::Error::other) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use std::fs; + use std::time::Instant; + + /// `Vfs::commit_event` calls `unwatch` for every removed path, though only the roots passed + /// to `watch` are watched. Forwarding those paths to notify made its macOS (FSEvents) backend + /// restart its event stream each time, and events that happened during a restart were never + /// delivered: deleting files while others were being created lost some of the creations. + #[test] + fn unwatching_paths_that_were_never_watched_loses_no_events() { + let dir = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(dir.path()).unwrap(); + let (old, new) = (root.join("old"), root.join("new")); + fs::create_dir(&old).unwrap(); + fs::create_dir(&new).unwrap(); + for i in 0..100 { + fs::write(old.join(format!("{i}.txt")), "").unwrap(); + } + + let mut backend = StdBackend::new().unwrap(); + backend.watch(&root).unwrap(); + thread::sleep(Duration::from_millis(500)); + let events = backend.event_receiver(); + while events.try_recv().is_ok() {} + + for i in 0..100 { + let removed = old.join(format!("{i}.txt")); + fs::remove_file(&removed).unwrap(); + let _ = backend.unwatch(&removed); + fs::write(new.join(format!("{i}.txt")), "").unwrap(); + } + + let mut created = HashSet::new(); + let deadline = Instant::now() + Duration::from_secs(5); + while created.len() < 100 && Instant::now() < deadline { + if let Ok(VfsEvent::Create(path) | VfsEvent::Write(path)) = + events.recv_timeout(Duration::from_millis(100)) + { + if path.parent() == Some(new.as_path()) { + created.insert(path); + } + } + } + assert_eq!(created.len(), 100, "file creations were not reported"); } } From 75c19ddb6ac650ab2543d5da55bf01b42b216b4f Mon Sep 17 00:00:00 2001 From: Noah Peeters Date: Mon, 19 Oct 2026 17:10:27 +0200 Subject: [PATCH 2/3] Add changelog entry for #1345 (AI-assisted) --- AI-canary.txt | 1 + CHANGELOG.md | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 AI-canary.txt diff --git a/AI-canary.txt b/AI-canary.txt new file mode 100644 index 00000000..780a5852 --- /dev/null +++ b/AI-canary.txt @@ -0,0 +1 @@ +This pull request is AI generated diff --git a/CHANGELOG.md b/CHANGELOG.md index 3943ae5f..11be4ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,10 @@ Making a new release? Simply add the new header with the version and date undern ## Unreleased * LocalizationTables no longer report as changed when nothing changes. [(#1333)] +* Fixed `rojo serve` on macOS missing file changes made while files were being deleted (for example during a `git checkout`), which left the served tree stale. ([#1345]) [#1333]: https://github.com/rojo-rbx/rojo/pull/1333 +[#1345]: https://github.com/rojo-rbx/rojo/pull/1345 ## [7.7.1] (October 1st, 2026) From 10bc251d7f68fd3e8bdf903e1714cc20c3201dfe Mon Sep 17 00:00:00 2001 From: Noah Peeters Date: Mon, 19 Oct 2026 17:14:05 +0200 Subject: [PATCH 3/3] Pace the unwatch regression test so it is not flaky (AI-assisted) A burst of 200 operations within milliseconds sometimes made macOS coalesce events into a rescan, which notify 4 does not deliver, failing the test about 4 times in 10 even with the fix. With a 5 ms pause per iteration the fixed code passed 20 of 20 runs; the unfixed code still loses about a third of the creations (60-67 of 100). --- crates/memofs/src/std_backend.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/memofs/src/std_backend.rs b/crates/memofs/src/std_backend.rs index 637d65cd..f70bedcf 100644 --- a/crates/memofs/src/std_backend.rs +++ b/crates/memofs/src/std_backend.rs @@ -173,6 +173,9 @@ mod test { fs::remove_file(&removed).unwrap(); let _ = backend.unwatch(&removed); fs::write(new.join(format!("{i}.txt")), "").unwrap(); + // Paced so the OS does not coalesce the burst into a rescan, which notify 4 drops on + // its own; this test is about the events unwatch loses. + thread::sleep(Duration::from_millis(5)); } let mut created = HashSet::new();