rojo/src/change_processor.rs
Joe 78143bae4d Add path suppression for VFS events in JobThreadContext
- Implemented a new `suppress_path` method to prevent re-processing of file-watcher events triggered by the ChangeProcessor, enhancing event handling during renames and writes.
- Updated various sections of the `change_processor.rs` file to call `suppress_path` for relevant paths during rename operations, ensuring accurate metadata synchronization and preventing unnecessary processing.
- Enhanced the logic for updating instance metadata to maintain consistency after filesystem path changes, improving the robustness of the change processing mechanism.
2026-02-06 09:55:54 -06:00

1194 lines
57 KiB
Rust

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::{Path, PathBuf};
use std::time::{Duration, Instant};
use std::{
fs,
sync::{Arc, Mutex},
};
use crate::{
message_queue::MessageQueue,
path_encoding::encode_path_name,
snapshot::{
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
},
snapshot_middleware::{snapshot_from_vfs, snapshot_project_node},
};
/// Processes file change events, updates the DOM, and sends those updates
/// through a channel for other stuff to consume.
///
/// Owns the connection between Rojo's VFS and its DOM by holding onto another
/// thread that processes messages.
///
/// Consumers of ChangeProcessor, like ServeSession, are intended to communicate
/// with this object via channels.
///
/// ChangeProcessor expects to be the only writer to the RojoTree and Vfs
/// objects passed to it.
pub struct ChangeProcessor {
/// Controls the runtime of the processor thread. When signaled, the job
/// thread will finish its current work and terminate.
///
/// This channel should be signaled before dropping ChangeProcessor or we'll
/// hang forever waiting for the message processing loop to terminate.
shutdown_sender: Sender<()>,
/// A handle to the message processing thread. When dropped, we'll block
/// until it's done.
///
/// Allowed to be unused because dropping this value has side effects.
#[allow(unused)]
job_thread: JoinHandle<Result<(), RecvError>>,
}
impl ChangeProcessor {
/// Spin up the ChangeProcessor, connecting it to the given tree, VFS, and
/// outbound message queue.
pub fn start(
tree: Arc<Mutex<RojoTree>>,
vfs: Arc<Vfs>,
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
tree_mutation_receiver: Receiver<PatchSet>,
suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, usize>>>,
) -> Self {
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
let vfs_receiver = vfs.event_receiver();
let task = JobThreadContext {
tree,
vfs,
message_queue,
pending_recovery: Mutex::new(Vec::new()),
suppressed_paths,
};
let job_thread = jod_thread::Builder::new()
.name("ChangeProcessor thread".to_owned())
.spawn(move || {
log::trace!("ChangeProcessor thread started");
loop {
select! {
recv(vfs_receiver) -> event => {
task.handle_vfs_event(event?);
task.process_pending_recoveries();
},
recv(tree_mutation_receiver) -> patch_set => {
task.handle_tree_event(patch_set?);
},
recv(shutdown_receiver) -> _ => {
log::trace!("ChangeProcessor shutdown signal received...");
return Ok(());
},
default(Duration::from_millis(500)) => {
// Periodic sweep even when no events arrive —
// catches files recreated after their Remove was processed.
task.process_pending_recoveries();
},
}
}
})
.expect("Could not start ChangeProcessor thread");
Self {
shutdown_sender,
job_thread,
}
}
}
impl Drop for ChangeProcessor {
fn drop(&mut self) {
// Signal the job thread to start spinning down. Without this we'll hang
// forever waiting for the thread to finish its infinite loop.
let _ = self.shutdown_sender.send(());
// After this function ends, the job thread will be joined. It might
// block for a small period of time while it processes its last work.
}
}
/// Contains all of the state needed to synchronize the DOM and VFS.
struct JobThreadContext {
/// A handle to the DOM we're managing.
tree: Arc<Mutex<RojoTree>>,
/// A handle to the VFS we're managing.
vfs: Arc<Vfs>,
/// Whenever changes are applied to the DOM, we should push those changes
/// into this message queue to inform any connected clients.
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
/// Paths recently removed from the tree that should be re-checked after a
/// delay. On Windows, rapid delete+recreate (e.g., editor undo) can cause
/// the Remove event to arrive but the Create event to be lost. We record
/// removed paths here and periodically verify they are still gone.
pending_recovery: Mutex<Vec<(PathBuf, Instant)>>,
/// Paths recently written by the API's syncback. Events for these paths
/// are suppressed to avoid redundant re-snapshots. Values are counts.
suppressed_paths: Arc<Mutex<std::collections::HashMap<PathBuf, usize>>>,
}
impl JobThreadContext {
/// Find the init file inside a directory-format script folder.
/// Returns the path to the first `init.*.luau` or `init.*.lua` found.
fn find_init_file(dir: &Path) -> Option<PathBuf> {
// Check known init file names in priority order
let candidates = [
"init.luau",
"init.server.luau",
"init.client.luau",
"init.local.luau",
"init.plugin.luau",
"init.legacy.luau",
"init.lua",
"init.server.lua",
"init.client.lua",
"init.local.lua",
];
for name in &candidates {
let candidate = dir.join(name);
if candidate.exists() {
return Some(candidate);
}
}
None
}
/// Suppress a single VFS event for the given path. Prevents the
/// ChangeProcessor from re-processing a file-watcher event that we
/// triggered ourselves (renames, writes).
fn suppress_path(&self, path: &Path) {
let mut suppressed = self.suppressed_paths.lock().unwrap();
let key = if let Ok(canonical) = std::fs::canonicalize(path) {
canonical
} else if let Some(parent) = path.parent() {
if let Ok(canonical_parent) = std::fs::canonicalize(parent) {
if let Some(file_name) = path.file_name() {
canonical_parent.join(file_name)
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
*suppressed.entry(key).or_insert(0) += 1;
}
/// Computes and applies patches to the DOM for a given file path.
///
/// This function finds the nearest ancestor to the given path that has associated instances
/// in the tree.
/// It then computes and applies changes for each affected instance ID and
/// returns a vector of applied patch sets.
fn apply_patches(&self, path: PathBuf) -> Vec<AppliedPatchSet> {
let mut tree = self.tree.lock().unwrap();
let mut applied_patches = Vec::new();
// Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we
// receive events for descendants of a large tree being
// created all at once.
let mut current_path = path.as_path();
let affected_ids = loop {
let ids = tree.get_ids_at_path(current_path);
log::info!(
"apply_patches: path {} affects IDs {:?}",
current_path.display(),
ids
);
if !ids.is_empty() {
break ids.to_vec();
}
log::info!(
"apply_patches: no IDs at {}, trying parent...",
current_path.display()
);
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
if affected_ids.is_empty() {
log::info!(
"apply_patches: no affected instances found for path {}",
path.display()
);
}
for id in affected_ids {
if let Some(result) = compute_and_apply_changes(&mut tree, &self.vfs, id) {
// If an instance was removed, schedule a recovery check
// in case the path is recreated momentarily.
if let Some(removed_path) = result.removed_path {
let mut pending = self.pending_recovery.lock().unwrap();
pending.push((removed_path, Instant::now()));
}
if !result.applied.is_empty() {
applied_patches.push(result.applied);
}
}
}
applied_patches
}
fn handle_vfs_event(&self, event: VfsEvent) {
// Log EVERY VFS event at INFO level for diagnostics.
// This is intentionally verbose — it is critical for debugging
// file watcher desync issues (e.g., rapid delete+recreate).
match &event {
VfsEvent::Create(path) => log::info!("VFS event: CREATE {}", path.display()),
VfsEvent::Write(path) => log::info!("VFS event: WRITE {}", path.display()),
VfsEvent::Remove(path) => log::info!("VFS event: REMOVE {}", path.display()),
_ => log::info!("VFS event: OTHER {:?}", event),
}
// Check if this event should be suppressed (one-shot, from API syncback).
// Try both the raw event path and its canonical form since suppress_path
// stores whichever form it could resolve at insertion time.
let event_path = match &event {
VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => Some(p.clone()),
_ => None,
};
if let Some(ref path) = event_path {
let canonical = std::fs::canonicalize(path).ok();
let mut suppressed = self.suppressed_paths.lock().unwrap();
// Determine which key matches: try canonical first (most likely
// to match what suppress_path stored), then fall back to raw.
let matched_key = canonical
.as_ref()
.filter(|c| suppressed.contains_key(c.as_path()))
.cloned()
.or_else(|| {
if suppressed.contains_key(path) {
Some(path.clone())
} else {
None
}
});
if let Some(key) = matched_key {
if let Some(count) = suppressed.get_mut(&key) {
*count = count.saturating_sub(1);
if *count == 0 {
suppressed.remove(&key);
}
}
drop(suppressed);
// Still commit so VFS stays consistent, but skip patching.
self.vfs
.commit_event(&event)
.expect("Error applying VFS change");
log::info!(
"VFS event SUPPRESSED (API syncback echo): {}",
path.display()
);
return;
}
}
// Update the VFS immediately with the event.
self.vfs
.commit_event(&event)
.expect("Error applying VFS change");
// 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) => {
// The path might have been deleted before we could process this event
// (e.g., during syncback when directories are being removed).
// If canonicalize fails, retry once after a brief delay — on Windows,
// the file may still be locked by the editor process.
match self.vfs.canonicalize(&path) {
Ok(canonical_path) => {
log::info!(
"VFS: canonicalize OK for {} -> {}",
path.display(),
canonical_path.display()
);
self.apply_patches(canonical_path)
}
Err(err) => {
log::info!(
"VFS: canonicalize FAILED for {} ({}), retrying after 50ms...",
path.display(),
err
);
std::thread::sleep(Duration::from_millis(50));
match self.vfs.canonicalize(&path) {
Ok(canonical_path) => {
log::info!(
"VFS: retry canonicalize OK for {} -> {}",
path.display(),
canonical_path.display()
);
self.apply_patches(canonical_path)
}
Err(err2) => {
log::warn!(
"VFS: Create/Write event DROPPED for {} after retry — \
canonicalize failed: {}. File may be desynchronized!",
path.display(),
err2
);
Vec::new()
}
}
}
}
}
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.
// However, if the parent was also deleted (e.g., when deleting a directory
// tree), canonicalize will fail - in that case, just skip this event.
let parent = path.parent().unwrap();
let file_name = path.file_name().unwrap();
match self.vfs.canonicalize(parent) {
Ok(parent_normalized) => {
let resolved = parent_normalized.join(file_name);
log::info!("VFS: Remove resolved to {}", resolved.display());
self.apply_patches(resolved)
}
Err(err) => {
log::info!(
"VFS: Skipping remove event for {} — parent no longer exists: {}",
path.display(),
err
);
Vec::new()
}
}
}
_ => {
log::warn!("Unhandled VFS event: {:?}", event);
Vec::new()
}
};
// Log patch application summary at INFO level
if !applied_patches.is_empty() {
let total_added: usize = applied_patches.iter().map(|p| p.added.len()).sum();
let total_removed: usize = applied_patches.iter().map(|p| p.removed.len()).sum();
let total_updated: usize = applied_patches.iter().map(|p| p.updated.len()).sum();
if total_added > 0 || total_removed > 0 || total_updated > 0 {
log::info!(
"VFS event applied: {} added, {} removed, {} updated",
total_added,
total_removed,
total_updated
);
}
} else {
log::info!("VFS event applied: no changes");
}
// Notify anyone listening to the message queue about the changes we
// just made.
self.message_queue.push_messages(&applied_patches);
}
/// Processes any pending recovery checks for paths that were recently
/// removed. If a path has reappeared on the real filesystem after the
/// recovery delay, we trigger a re-snapshot to bring the tree back in sync.
fn process_pending_recoveries(&self) {
const RECOVERY_DELAY: Duration = Duration::from_millis(200);
let ready: Vec<PathBuf> = {
let mut pending = self.pending_recovery.lock().unwrap();
let now = Instant::now();
// Drain entries that are old enough to check
let mut ready = Vec::new();
pending.retain(|(path, recorded_at)| {
if now.duration_since(*recorded_at) >= RECOVERY_DELAY {
ready.push(path.clone());
false // remove from pending
} else {
true // keep in pending
}
});
ready
};
for path in ready {
if std::fs::metadata(&path).is_ok() {
log::info!(
"VFS recovery: path {} was removed but has reappeared on disk. Re-snapshotting.",
path.display()
);
let patches = self.apply_patches(path);
if !patches.is_empty() {
let total_added: usize = patches.iter().map(|p| p.added.len()).sum();
let total_removed: usize = patches.iter().map(|p| p.removed.len()).sum();
let total_updated: usize = patches.iter().map(|p| p.updated.len()).sum();
log::info!(
"VFS recovery applied: {} added, {} removed, {} updated",
total_added,
total_removed,
total_updated
);
self.message_queue.push_messages(&patches);
}
} else {
log::info!(
"VFS recovery: path {} confirmed removed from disk.",
path.display()
);
}
}
}
fn handle_tree_event(&self, patch_set: PatchSet) {
// Log incoming patch summary at debug level
log::debug!(
"Processing client patch: {} removed, {} added, {} updated",
patch_set.removed_instances.len(),
patch_set.added_instances.len(),
patch_set.updated_instances.len()
);
let applied_patch = {
let mut tree = self.tree.lock().unwrap();
for &id in &patch_set.removed_instances {
if let Some(instance) = tree.get_instance(id) {
if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source {
InstigatingSource::Path(path) => {
// Guard: file may already be deleted by the API's
// syncback_removed_instance before this PatchSet arrives.
if path.exists() {
if path.is_dir() {
log::info!(
"Two-way sync: Removing directory {}",
path.display()
);
if let Err(err) = fs::remove_dir_all(path) {
log::error!(
"Failed to remove directory {:?} for instance {:?}: {}",
path,
id,
err
);
}
} else {
log::info!(
"Two-way sync: Removing file {}",
path.display()
);
if let Err(err) = fs::remove_file(path) {
log::error!(
"Failed to remove file {:?} for instance {:?}: {}",
path,
id,
err
);
}
}
} else {
log::info!(
"Two-way sync: File already removed: {}",
path.display()
);
}
}
InstigatingSource::ProjectNode { .. } => {
log::warn!(
"Cannot remove instance {:?}, it's from a project file",
id
);
}
}
} else {
log::warn!(
"Cannot remove instance {:?}, it is not an instigating source.",
id
);
}
} else {
log::warn!("Cannot remove instance {:?}, it does not exist.", id);
}
}
// Collect (Ref, new_instigating_source) for instances whose
// filesystem path changed (rename / ClassName transition).
// Applied after the PatchSet to keep metadata in sync.
let mut metadata_updates: Vec<(Ref, PathBuf)> = Vec::new();
for update in &patch_set.updated_instances {
let id = update.id;
if let Some(instance) = tree.get_instance(id) {
// Track the current source file path — rename and ClassName
// handlers may move the file, so the Source write must target
// the new location instead of the stale instigating_source.
let mut overridden_source_path: Option<PathBuf> = None;
// Handle instance rename on disk
if let Some(ref new_name) = update.changed_name {
if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source {
InstigatingSource::Path(path) => {
if path.exists() {
let file_name =
path.file_name().and_then(|f| f.to_str()).unwrap_or("");
// For directory-format scripts, the instigating_source
// is the init file (e.g., src/MyModule/init.luau).
// The instance name corresponds to the parent directory,
// not the init file, so we must rename the directory.
let is_init_file = file_name.starts_with("init.");
if is_init_file {
let dir_path = path.parent().unwrap();
if let Some(grandparent) = dir_path.parent() {
// Use the encoded dir name from the filesystem
// for the old meta lookup, and encode the new
// name for the new path. Using instance.name()
// (decoded) would fail for encoded names.
let dir_name = dir_path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("");
let encoded_new_name = encode_path_name(new_name);
let new_dir_path =
grandparent.join(&encoded_new_name);
if new_dir_path != dir_path {
log::info!(
"Two-way sync: Renaming directory {} -> {}",
dir_path.display(),
new_dir_path.display()
);
self.suppress_path(dir_path);
self.suppress_path(&new_dir_path);
if let Err(err) =
fs::rename(dir_path, &new_dir_path)
{
log::error!(
"Failed to rename directory {:?} to {:?}: {}",
dir_path,
new_dir_path,
err
);
} else {
// The init file moved with the directory.
overridden_source_path =
Some(new_dir_path.join(file_name));
let old_meta = grandparent.join(format!(
"{}.meta.json5",
dir_name
));
if old_meta.exists() {
let new_meta =
grandparent.join(format!(
"{}.meta.json5",
encoded_new_name
));
self.suppress_path(&old_meta);
self.suppress_path(&new_meta);
let _ =
fs::rename(&old_meta, &new_meta);
}
}
}
}
} else if let Some(parent) = path.parent() {
// Derive the suffix from the existing filename
// rather than using replacen with the decoded
// instance name, which fails for encoded names
// (e.g., What%QUESTION%.server.luau).
let extension = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let known_suffixes = [
".server", ".client", ".plugin", ".local",
".legacy",
];
let script_suffix = known_suffixes
.iter()
.find(|s| stem.ends_with(*s))
.copied()
.unwrap_or("");
let old_base = if script_suffix.is_empty() {
stem
} else {
&stem[..stem.len() - script_suffix.len()]
};
let encoded_new_name = encode_path_name(new_name);
let new_file_name = if extension.is_empty() {
format!("{}{}", encoded_new_name, script_suffix)
} else {
format!(
"{}{}.{}",
encoded_new_name, script_suffix, extension
)
};
let new_path = parent.join(&new_file_name);
if new_path != *path {
log::info!(
"Two-way sync: Renaming {} -> {}",
path.display(),
new_path.display()
);
self.suppress_path(path);
self.suppress_path(&new_path);
if let Err(err) = fs::rename(path, &new_path) {
log::error!(
"Failed to rename {:?} to {:?}: {}",
path,
new_path,
err
);
} else {
overridden_source_path = Some(new_path.clone());
let old_meta = parent
.join(format!("{}.meta.json5", old_base));
if old_meta.exists() {
let new_meta = parent.join(format!(
"{}.meta.json5",
encoded_new_name
));
self.suppress_path(&old_meta);
self.suppress_path(&new_meta);
let _ = fs::rename(&old_meta, &new_meta);
}
}
}
}
}
}
InstigatingSource::ProjectNode { .. } => {
log::warn!(
"Cannot rename instance {:?} — defined in project file",
id
);
}
}
} else {
log::warn!("Cannot rename instance {:?} — no instigating source", id);
}
}
// Handle ClassName changes (script class transitions)
if let Some(ref new_class) = update.changed_class_name {
if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source {
InstigatingSource::Path(path) => {
let old_class = instance.class_name();
let old_is_script = matches!(
old_class.as_str(),
"ModuleScript" | "Script" | "LocalScript"
);
let new_is_script = matches!(
new_class.as_str(),
"ModuleScript" | "Script" | "LocalScript"
);
// If a rename handler already moved the file,
// use the new path instead of the stale
// instigating_source path.
let effective_path =
overridden_source_path.as_deref().unwrap_or(path.as_path());
if old_is_script && new_is_script && effective_path.exists() {
// Script-to-script transition: rename the file extension.
// For directory-format scripts, the path is the directory
// (e.g., src/MyModule/), not the init file inside. We must
// find and rename the init file, not the directory.
// Resolve the actual file to rename. For directories,
// find the init file inside; for files, use directly.
let init_result = if effective_path.is_dir() {
Self::find_init_file(effective_path)
.map(|f| (f.clone(), effective_path.to_path_buf()))
} else {
Some((
effective_path.to_path_buf(),
effective_path
.parent()
.unwrap_or(effective_path)
.to_path_buf(),
))
};
if let Some((actual_file, file_parent)) = init_result {
let new_suffix = match new_class.as_str() {
"ModuleScript" => "",
"Script" => ".server",
"LocalScript" => ".local",
_ => "",
};
let is_init = actual_file
.file_name()
.and_then(|f| f.to_str())
.map(|f| f.starts_with("init."))
.unwrap_or(false);
let new_file_name = if is_init {
if new_suffix.is_empty() {
"init.luau".to_string()
} else {
format!("init{}.luau", new_suffix)
}
} else {
// Derive the encoded base name from the
// filesystem path, not instance.name()
// (which is decoded). This preserves
// encoding like What%QUESTION% on disk.
let stem = actual_file
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let known_suffixes = [
".server", ".client", ".plugin", ".local",
".legacy",
];
let base = known_suffixes
.iter()
.find_map(|s| stem.strip_suffix(s))
.unwrap_or(stem);
if new_suffix.is_empty() {
format!("{}.luau", base)
} else {
format!("{}{}.luau", base, new_suffix)
}
};
let new_path = file_parent.join(&new_file_name);
if new_path != actual_file {
log::info!(
"Two-way sync: Changing class {} -> {}, \
renaming {} -> {}",
old_class,
new_class,
actual_file.display(),
new_path.display()
);
self.suppress_path(&actual_file);
self.suppress_path(&new_path);
if let Err(err) =
fs::rename(&actual_file, &new_path)
{
log::error!(
"Failed to rename {:?} to {:?}: {}",
actual_file,
new_path,
err
);
} else {
overridden_source_path = Some(new_path.clone());
}
}
} else {
log::warn!(
"Cannot change ClassName for directory {} \
— no init file found inside",
path.display()
);
}
} else if old_is_script != new_is_script {
log::warn!(
"Cannot change ClassName from {} to {} — \
cross-category changes (script <-> non-script) \
are not yet supported",
old_class,
new_class
);
}
}
InstigatingSource::ProjectNode { .. } => {
log::warn!(
"Cannot change ClassName for {:?} — defined in project file",
id
);
}
}
}
}
if update.changed_metadata.is_some() {
log::warn!("Cannot change metadata yet.");
}
for (key, changed_value) in &update.changed_properties {
if key == "Source" {
// If a rename or ClassName change moved the file
// earlier in this update, write to the new location
// instead of the stale instigating_source path.
let source_path = if let Some(ref overridden) = overridden_source_path {
Some(overridden.clone())
} else if let Some(instigating_source) =
&instance.metadata().instigating_source
{
match instigating_source {
InstigatingSource::Path(path) => Some(path.clone()),
InstigatingSource::ProjectNode { .. } => {
log::warn!(
"Cannot update instance {:?}, it's from a project file",
id
);
None
}
}
} else {
log::warn!(
"Cannot update instance {:?}, it is not an instigating source.",
id
);
None
};
if let Some(ref write_path) = source_path {
if let Some(Variant::String(value)) = changed_value {
log::info!(
"Two-way sync: Writing Source to {}",
write_path.display()
);
self.suppress_path(write_path);
if let Err(err) = fs::write(write_path, value) {
log::error!(
"Failed to write Source to {:?} for instance {:?}: {}",
write_path,
id,
err
);
}
} else {
log::warn!("Cannot change Source to non-string value.");
}
}
} else {
log::trace!("Skipping non-Source property change: {}", key);
}
}
// Record metadata update so instigating_source and
// relevant_paths stay in sync after rename / class change.
if let Some(ref new_path) = overridden_source_path {
metadata_updates.push((id, new_path.clone()));
}
} else {
log::warn!("Cannot update instance {:?}, it does not exist.", id);
}
}
let applied = apply_patch_set(&mut tree, patch_set);
// Update metadata for instances whose filesystem path changed.
// This keeps instigating_source and path_to_ids in sync so that
// subsequent VFS events (and future renames) target the correct
// instance and path.
for (id, new_instigating_source) in metadata_updates {
if let Some(old_metadata) = tree.get_metadata(id).cloned() {
let mut new_metadata = old_metadata;
new_metadata.instigating_source =
Some(InstigatingSource::Path(new_instigating_source.clone()));
new_metadata.relevant_paths =
rebuild_relevant_paths(&new_instigating_source);
tree.update_metadata(id, new_metadata);
}
}
applied
};
if !applied_patch.is_empty() {
self.message_queue.push_messages(&[applied_patch]);
}
}
}
/// Result of computing and applying changes to a single instance.
/// Includes the path that was removed (if any) for pending recovery tracking.
struct ComputeResult {
applied: AppliedPatchSet,
/// If the instance was removed because its path no longer exists,
/// record the path here so the caller can schedule a recovery check.
removed_path: Option<PathBuf>,
}
fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<ComputeResult> {
let metadata = tree
.get_metadata(id)
.expect("metadata missing for instance present in tree");
let instigating_source = match &metadata.instigating_source {
Some(path) => path,
None => {
log::error!(
"Instance {:?} did not have an instigating source, but was considered for an update.",
id
);
log::error!("This is a bug. Please file an issue!");
return None;
}
};
// How we process a file change event depends on what created this
// file/folder in the first place.
match instigating_source {
InstigatingSource::Path(path) => {
// For directory-format scripts, the instigating_source is the
// init file (e.g., init.luau) so that two-way sync writes to the
// correct file. However, snapshot_from_vfs returns None for init
// files because they are handled as part of the parent directory
// snapshot. Detect this case and snapshot the parent directory.
let is_init_file = path
.file_name()
.and_then(|f| f.to_str())
.map(|f| f.starts_with("init."))
.unwrap_or(false);
let snapshot_path = if is_init_file {
path.parent().unwrap_or(path.as_path())
} else {
path.as_path()
};
log::info!(
"compute_and_apply_changes: checking path {} for instance {:?}{}",
path.display(),
id,
if is_init_file {
format!(
" (init file, will snapshot parent dir {})",
snapshot_path.display()
)
} else {
String::new()
}
);
match vfs.metadata(path).with_not_found() {
Ok(Some(_)) => {
// Our instance was previously created from a path and that
// path still exists. We can generate a snapshot starting at
// that path and use it as the source for our patch.
log::info!(
"compute_and_apply_changes: path EXISTS via VFS, re-snapshotting {}",
snapshot_path.display()
);
let snapshot = match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!("Snapshot error: {:?}", err);
return None;
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: None,
})
}
Ok(None) => {
// Path not found via VFS. Before removing, verify it's truly
// gone from the REAL filesystem. On Windows, rapid
// delete+recreate (e.g., editor undo) can cause Remove events
// where the file has already been recreated by the time we
// process the event.
if std::fs::metadata(path).is_ok() {
log::info!(
"compute_and_apply_changes: VFS says path removed but REAL \
filesystem confirms it EXISTS: {}. Re-snapshotting instead \
of removing.",
path.display()
);
let snapshot =
match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!(
"Recovery snapshot error for {}: {:?}",
snapshot_path.display(),
err
);
return None;
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: None,
})
} else if is_init_file && std::fs::metadata(snapshot_path).is_ok() {
// Init file was deleted but the parent directory still
// exists. Re-snapshot the directory — it will become a
// Folder (or whatever the directory middleware produces
// without an init file).
log::info!(
"compute_and_apply_changes: init file {} deleted but parent \
directory {} still exists. Re-snapshotting directory.",
path.display(),
snapshot_path.display()
);
let snapshot =
match snapshot_from_vfs(&metadata.context, vfs, snapshot_path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!(
"Directory re-snapshot error for {}: {:?}",
snapshot_path.display(),
err
);
return None;
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: None,
})
} else {
// Path is genuinely gone from both VFS and real filesystem.
// Remove the instance, but record the path for recovery
// checking — the file might be recreated momentarily.
log::info!(
"compute_and_apply_changes: path NOT FOUND on disk, removing \
instance {:?} for {}. Scheduling recovery check.",
id,
path.display()
);
let removed_path = path.to_path_buf();
let mut patch_set = PatchSet::new();
patch_set.removed_instances.push(id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: Some(removed_path),
})
}
}
Err(err) => {
log::error!(
"Error processing filesystem change for {}: {:?}",
path.display(),
err
);
None
}
}
}
InstigatingSource::ProjectNode {
path,
name,
node,
parent_class,
} => {
// This instance is the direct subject of a project node. Since
// there might be information associated with our instance from
// the project file, we snapshot the entire project node again.
log::info!(
"compute_and_apply_changes: re-snapshotting project node '{}' at {}",
name,
path.display()
);
let snapshot_result = snapshot_project_node(
&metadata.context,
path,
name,
node,
vfs,
parent_class.as_ref().map(|name| name.as_str()),
);
let snapshot = match snapshot_result {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!("{:?}", err);
return None;
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: None,
})
}
}
}
/// Rebuild the `relevant_paths` list for an instance given its new
/// `instigating_source` path. This mirrors the logic in the snapshot
/// middleware so that `path_to_ids` stays correct after a rename.
fn rebuild_relevant_paths(new_path: &Path) -> Vec<PathBuf> {
let file_name = new_path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("");
let is_init = file_name.starts_with("init.");
if is_init {
// Directory-format script: relevant paths are the directory itself
// plus every possible init file variant inside it.
let dir_path = new_path.parent().unwrap().to_path_buf();
vec![
dir_path.clone(),
dir_path.join("init.luau"),
dir_path.join("init.server.luau"),
dir_path.join("init.client.luau"),
dir_path.join("init.local.luau"),
dir_path.join("init.legacy.luau"),
dir_path.join("init.csv"),
dir_path.join("init.lua"),
dir_path.join("init.server.lua"),
dir_path.join("init.client.lua"),
dir_path.join("init.meta.json5"),
]
} else {
// Standalone file: relevant paths are the file itself and its
// adjacent meta file.
let stem = new_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let base_name = stem
.strip_suffix(".server")
.or_else(|| stem.strip_suffix(".client"))
.or_else(|| stem.strip_suffix(".plugin"))
.or_else(|| stem.strip_suffix(".local"))
.or_else(|| stem.strip_suffix(".legacy"))
.unwrap_or(stem);
vec![
new_path.to_path_buf(),
new_path.with_file_name(format!("{base_name}.meta.json5")),
]
}
}