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..4b3e831d 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 live sync on Linux (`rojo serve` and the `--watch` modes) missing every change inside a directory that was removed or moved aside and then recreated at the same path within another synced directory. ([#1341]) [#1333]: https://github.com/rojo-rbx/rojo/pull/1333 +[#1341]: https://github.com/rojo-rbx/rojo/pull/1341 ## [7.7.1] (October 1st, 2026) diff --git a/crates/memofs/CHANGELOG.md b/crates/memofs/CHANGELOG.md index 3e841b2b..cc7b8774 100644 --- a/crates/memofs/CHANGELOG.md +++ b/crates/memofs/CHANGELOG.md @@ -1,6 +1,9 @@ # memofs Changelog ## Unreleased Changes +* Fixed `StdBackend` on Linux dropping the watches on a directory recreated at a path whose removal was committed afterwards, which stopped events for everything inside it. [#1341] + +[#1341]: https://github.com/rojo-rbx/rojo/pull/1341 # 0.4.0 (2026-07-01) * Added `Vfs::canonicalize`. [#1201] diff --git a/crates/memofs/src/std_backend.rs b/crates/memofs/src/std_backend.rs index 3f0ab2c1..cf6bdf95 100644 --- a/crates/memofs/src/std_backend.rs +++ b/crates/memofs/src/std_backend.rs @@ -122,15 +122,54 @@ impl VfsBackend for StdBackend { { Ok(()) } else { + // This recursive watch covers any watch we already hold below + // `path`. Release those, so that every path in `watches` is the + // only watch covering it and `unwatch` can trust that. Release them + // first: on inotify this watch shares their per-directory entries, + // and unwatching them afterwards would delete its coverage too. + let covered: Vec = self + .watches + .iter() + .filter(|watched| watched.starts_with(path)) + .cloned() + .collect(); + for watched in &covered { + self.watches.remove(watched); + let _ = self.watcher.unwatch(watched); + } + + if let Err(err) = self.watcher.watch(path, RecursiveMode::Recursive) { + // Put back what we released, so a failed watch loses nothing. + // inotify keeps whatever part of the walk succeeded, so drop + // that first to free its watches. + let _ = self.watcher.unwatch(path); + for watched in covered { + if self + .watcher + .watch(&watched, RecursiveMode::Recursive) + .is_ok() + { + self.watches.insert(watched); + } + } + return Err(io::Error::other(err)); + } self.watches.insert(path.to_path_buf()); - self.watcher - .watch(path, RecursiveMode::Recursive) - .map_err(io::Error::other) + Ok(()) } } fn unwatch(&mut self, path: &Path) -> io::Result<()> { - self.watches.remove(path); - self.watcher.unwatch(path).map_err(io::Error::other) + // Only stop a watch we started. Any other path is covered by an + // ancestor's recursive watch, for which inotify keeps an entry per + // directory, keyed by path. By the time a removal is committed, that + // entry may belong to a new directory created at the same path, and + // unwatching it would stop events for that directory and everything + // under it. + if self.watches.remove(path) { + self.watcher.unwatch(path).map_err(io::Error::other) + } else { + Ok(()) + } } }