From 480277fc1f4e8b790bf2b07953a255d5f6eaacaf Mon Sep 17 00:00:00 2001 From: Mitchell Date: Sun, 30 Aug 2026 13:25:24 -0500 Subject: [PATCH] Fix stale filesystem event path handling (AI-assisted) --- AI-canary.txt | 1 + CHANGELOG.md | 2 + src/change_processor.rs | 90 +++++++++++++++++++++++++++++++++++------ 3 files changed, 81 insertions(+), 12 deletions(-) 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 0ac21a66..1f407dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,9 +35,11 @@ Making a new release? Simply add the new header with the version and date undern * Fixed `rojo serve` silently stopping syncing file changes on Windows when the served project path was a verbatim (`\\?\`) path, because tree paths and file-watcher event paths were canonicalized to different forms. ([#1290]) * Fixed `rojo sourcemap --absolute` emitting verbatim (`\\?\`) paths on Windows, which broke require types in luau-lsp. ([#1290]) * The plugin now disables the `Check for Updates` setting if you block access to `api.github.com`. ([#1297]) +* Fixed `rojo serve` crashing when queued filesystem events refer to paths whose ancestors have already been removed. ([#1314]) [#1290]: https://github.com/rojo-rbx/rojo/pull/1290 [#1297]: https://github.com/rojo-rbx/rojo/pull/1297 +[#1314]: https://github.com/rojo-rbx/rojo/issues/1314 ## [7.7.0] (July 1st, 2026) diff --git a/src/change_processor.rs b/src/change_processor.rs index c1f245f2..75d80dac 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -2,9 +2,9 @@ use crossbeam_channel::{select, Receiver, RecvError, Sender}; use jod_thread::JoinHandle; use memofs::{IoResultExt, Vfs, VfsEvent}; use rbx_dom_weak::types::{Ref, Variant}; -use std::path::PathBuf; use std::{ - fs, + fs, io, + path::{Path, PathBuf}, sync::{Arc, Mutex}, }; @@ -113,6 +113,31 @@ struct JobThreadContext { message_queue: Arc>, } +/// Canonicalizes as much of an event path as still exists. +/// +/// Filesystem events can become stale while they wait to be processed. For +/// example, recursively removing a directory can remove a child's parent +/// before the child's event is handled. Canonicalizing the nearest existing +/// ancestor preserves the normalized prefix used by the tree while allowing +/// the missing suffix to still drive reconciliation. +fn canonicalize_event_path(vfs: &Vfs, path: &Path) -> io::Result { + let mut ancestor = path; + + loop { + match vfs.canonicalize(ancestor) { + Ok(canonical_ancestor) => { + let missing_suffix = path.strip_prefix(ancestor).map_err(io::Error::other)?; + return Ok(canonical_ancestor.join(missing_suffix)); + } + Err(err) if err.kind() == io::ErrorKind::NotFound => match ancestor.parent() { + Some(parent) => ancestor = parent, + None => return Err(err), + }, + Err(err) => return Err(err), + } + } +} + impl JobThreadContext { /// Computes and applies patches to the DOM for a given file path. /// @@ -168,16 +193,18 @@ impl JobThreadContext { // For a given VFS event, we might have many changes to different parts // of the tree. Calculate and apply all of these changes. let applied_patches = match event { - VfsEvent::Create(path) | VfsEvent::Write(path) => { - self.apply_patches(self.vfs.canonicalize(&path).unwrap()) - } - VfsEvent::Remove(path) => { - // MemoFS does not track parent removals yet, so we can canonicalize - // the parent path safely and then append the removed path's file name. - let parent = path.parent().unwrap(); - let file_name = path.file_name().unwrap(); - let parent_normalized = self.vfs.canonicalize(parent).unwrap(); - self.apply_patches(parent_normalized.join(file_name)) + VfsEvent::Create(path) | VfsEvent::Write(path) | VfsEvent::Remove(path) => { + match canonicalize_event_path(&self.vfs, &path) { + Ok(path) => self.apply_patches(path), + Err(err) => { + log::error!( + "Could not canonicalize filesystem event path {}: {}", + path.display(), + err + ); + Vec::new() + } + } } _ => { log::warn!("Unhandled VFS event: {:?}", event); @@ -383,3 +410,42 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option< Some(applied_patch_set) } + +#[cfg(test)] +mod test { + use super::*; + + use memofs::{InMemoryFs, VfsSnapshot}; + + use crate::snapshot::InstanceSnapshot; + + fn stale_event_context() -> JobThreadContext { + let mut backend = InMemoryFs::new(); + backend + .load_snapshot("/project", VfsSnapshot::empty_dir()) + .unwrap(); + + JobThreadContext { + tree: Arc::new(Mutex::new(RojoTree::new(InstanceSnapshot::new()))), + vfs: Arc::new(Vfs::new(backend)), + message_queue: Arc::new(MessageQueue::new()), + } + } + + #[test] + fn stale_remove_event_does_not_panic_when_parent_was_removed() { + let context = stale_event_context(); + let path = PathBuf::from("/project/out/server/deep/file.luau"); + + assert_eq!(canonicalize_event_path(&context.vfs, &path).unwrap(), path); + + context.handle_vfs_event(VfsEvent::Remove(path)); + } + + #[test] + fn stale_write_event_does_not_panic_when_path_was_removed() { + let context = stale_event_context(); + + context.handle_vfs_event(VfsEvent::Write("/project/out/server/deep/file.luau".into())); + } +}