mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-21 12:01:38 +00:00
Fix Windows symlink watching (AI-assisted)
On Windows, StdBackend only watched the logical paths Rojo read, so edits made through the physical target of a file symlink, directory symlink, or junction outside the watched tree never reached `rojo serve` until it was restarted. The Windows backend now tracks each watched logical path's resolved target, registers native watches for targets outside existing watches, and maps target lifecycle events back to their logical aliases through a new VfsEvent::Rescan. Watches are refreshed when a link is removed, recreated, or repointed, and shared targets stay watched until their last alias goes away. The change processor handles Rescan and logs, rather than panics on, events for paths that no longer exist. Non-Windows behavior is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
95262a02e9
commit
66930ed7a7
7 changed files with 1100 additions and 27 deletions
|
|
@ -157,28 +157,79 @@ impl JobThreadContext {
|
|||
applied_patches
|
||||
}
|
||||
|
||||
fn apply_existing_path(&self, path: &std::path::Path) -> Vec<AppliedPatchSet> {
|
||||
match self.vfs.canonicalize(path) {
|
||||
Ok(path) => self.apply_patches(path),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => self.apply_missing_path(path),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Could not canonicalize filesystem event path {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_missing_path(&self, path: &std::path::Path) -> Vec<AppliedPatchSet> {
|
||||
let (Some(parent), Some(file_name)) = (path.parent(), path.file_name()) else {
|
||||
log::warn!(
|
||||
"Could not determine the parent of filesystem event path {}",
|
||||
path.display()
|
||||
);
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match self.vfs.canonicalize(parent) {
|
||||
Ok(parent) => self.apply_patches(parent.join(file_name)),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Could not canonicalize parent of filesystem event path {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_rescan(&self, path: &std::path::Path) -> Vec<AppliedPatchSet> {
|
||||
let Some(parent) = path.parent() else {
|
||||
log::warn!(
|
||||
"Could not determine the parent of filesystem rescan path {}",
|
||||
path.display()
|
||||
);
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match self.vfs.canonicalize(parent) {
|
||||
Ok(parent) => self.apply_patches(parent),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"Could not canonicalize parent of filesystem rescan path {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_vfs_event(&self, event: VfsEvent) {
|
||||
log::trace!("Vfs event: {:?}", event);
|
||||
|
||||
// Update the VFS immediately with the event.
|
||||
self.vfs
|
||||
.commit_event(&event)
|
||||
.expect("Error applying VFS change");
|
||||
if let Err(err) = self.vfs.commit_event(&event) {
|
||||
log::warn!("Error applying VFS change {:?}: {}", event, err);
|
||||
}
|
||||
|
||||
// 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) => self.apply_existing_path(&path),
|
||||
VfsEvent::Remove(path) => self.apply_missing_path(&path),
|
||||
VfsEvent::Rescan(path) => self.apply_rescan(&path),
|
||||
_ => {
|
||||
log::warn!("Unhandled VFS event: {:?}", event);
|
||||
Vec::new()
|
||||
|
|
|
|||
|
|
@ -276,4 +276,252 @@ mod test {
|
|||
canonical.display(),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn source_for_descendant(session: &ServeSession, name: &str) -> Option<String> {
|
||||
use rbx_dom_weak::{types::Variant, ustr};
|
||||
|
||||
let tree = session.tree();
|
||||
tree.descendants(tree.get_root_id())
|
||||
.find(|instance| instance.name() == name)
|
||||
.and_then(
|
||||
|instance| match instance.properties().get(&ustr("Source")) {
|
||||
Some(Variant::String(source)) => Some(source.clone()),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn wait_for_tree(
|
||||
session: &ServeSession,
|
||||
description: &str,
|
||||
mut predicate: impl FnMut(&ServeSession) -> bool,
|
||||
) {
|
||||
use std::{
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if predicate(session) {
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
panic!("timed out waiting for serve session to {description}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn applies_windows_symlink_updates_without_restarting() {
|
||||
use std::os::windows::fs::{symlink_dir, symlink_file};
|
||||
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let source = project.join("src");
|
||||
let directory_target_a = temp.path().join("directory-target-a");
|
||||
let directory_target_b = temp.path().join("directory-target-b");
|
||||
let directory_alias = source.join("linked");
|
||||
let file_target = temp.path().join("file-target.lua");
|
||||
let file_alias = source.join("file-alias.lua");
|
||||
let second_file_alias = source.join("second-file-alias.lua");
|
||||
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
std::fs::create_dir(&directory_target_a).unwrap();
|
||||
std::fs::create_dir(&directory_target_b).unwrap();
|
||||
std::fs::write(directory_target_a.join("module.lua"), "return \"initial\"").unwrap();
|
||||
std::fs::write(
|
||||
directory_target_b.join("replacement.lua"),
|
||||
"return \"replacement\"",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(&file_target, "return \"file initial\"").unwrap();
|
||||
symlink_dir(&directory_target_a, &directory_alias)
|
||||
.expect("this Windows regression test requires permission to create symlinks");
|
||||
symlink_file(&file_target, &file_alias).unwrap();
|
||||
symlink_file(&file_target, &second_file_alias).unwrap();
|
||||
std::fs::write(
|
||||
project.join("default.project.json"),
|
||||
r#"{ "name": "test", "tree": { "$path": "src" } }"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let session = ServeSession::new(Vfs::new(StdBackend::new().unwrap()), &project).unwrap();
|
||||
let initial_cursor = session.message_queue().cursor();
|
||||
assert_eq!(
|
||||
source_for_descendant(&session, "module").as_deref(),
|
||||
Some("return \"initial\"")
|
||||
);
|
||||
assert_eq!(
|
||||
source_for_descendant(&session, "file-alias").as_deref(),
|
||||
Some("return \"file initial\"")
|
||||
);
|
||||
assert_eq!(
|
||||
source_for_descendant(&session, "second-file-alias").as_deref(),
|
||||
Some("return \"file initial\"")
|
||||
);
|
||||
|
||||
std::fs::write(directory_target_a.join("module.lua"), "return \"physical\"").unwrap();
|
||||
wait_for_tree(&session, "apply a physical-target write", |session| {
|
||||
source_for_descendant(session, "module").as_deref() == Some("return \"physical\"")
|
||||
});
|
||||
|
||||
std::fs::write(directory_alias.join("module.lua"), "return \"alias\"").unwrap();
|
||||
wait_for_tree(&session, "apply an alias-path write", |session| {
|
||||
source_for_descendant(session, "module").as_deref() == Some("return \"alias\"")
|
||||
});
|
||||
|
||||
std::fs::write(directory_alias.join("created.lua"), "return \"created\"").unwrap();
|
||||
wait_for_tree(&session, "add a linked child", |session| {
|
||||
source_for_descendant(session, "created").as_deref() == Some("return \"created\"")
|
||||
});
|
||||
|
||||
std::fs::rename(
|
||||
directory_alias.join("created.lua"),
|
||||
directory_alias.join("renamed.lua"),
|
||||
)
|
||||
.unwrap();
|
||||
wait_for_tree(&session, "rename a linked child", |session| {
|
||||
source_for_descendant(session, "created").is_none()
|
||||
&& source_for_descendant(session, "renamed").as_deref()
|
||||
== Some("return \"created\"")
|
||||
});
|
||||
|
||||
std::fs::remove_file(directory_alias.join("renamed.lua")).unwrap();
|
||||
wait_for_tree(&session, "remove a linked child", |session| {
|
||||
source_for_descendant(session, "renamed").is_none()
|
||||
});
|
||||
|
||||
std::fs::write(&file_target, "return \"file physical\"").unwrap();
|
||||
wait_for_tree(&session, "apply a file-symlink target write", |session| {
|
||||
source_for_descendant(session, "file-alias").as_deref()
|
||||
== Some("return \"file physical\"")
|
||||
&& source_for_descendant(session, "second-file-alias").as_deref()
|
||||
== Some("return \"file physical\"")
|
||||
});
|
||||
|
||||
std::fs::remove_file(&file_alias).unwrap();
|
||||
wait_for_tree(&session, "remove one of two aliases", |session| {
|
||||
source_for_descendant(session, "file-alias").is_none()
|
||||
&& source_for_descendant(session, "second-file-alias").is_some()
|
||||
});
|
||||
std::fs::write(&file_target, "return \"file still watched\"").unwrap();
|
||||
wait_for_tree(
|
||||
&session,
|
||||
"keep a shared target watched for its remaining alias",
|
||||
|session| {
|
||||
source_for_descendant(session, "second-file-alias").as_deref()
|
||||
== Some("return \"file still watched\"")
|
||||
},
|
||||
);
|
||||
|
||||
let replacement_target = temp.path().join("file-target-replacement.lua");
|
||||
std::fs::write(&replacement_target, "return \"file replaced\"").unwrap();
|
||||
std::fs::remove_file(&file_target).unwrap();
|
||||
std::fs::rename(&replacement_target, &file_target).unwrap();
|
||||
wait_for_tree(
|
||||
&session,
|
||||
"apply an atomic-style file target replacement",
|
||||
|session| {
|
||||
source_for_descendant(session, "second-file-alias").as_deref()
|
||||
== Some("return \"file replaced\"")
|
||||
},
|
||||
);
|
||||
|
||||
std::fs::remove_file(&file_target).unwrap();
|
||||
wait_for_tree(
|
||||
&session,
|
||||
"remove a file symlink whose target vanished",
|
||||
|session| source_for_descendant(session, "second-file-alias").is_none(),
|
||||
);
|
||||
std::fs::write(&file_target, "return \"file restored\"").unwrap();
|
||||
wait_for_tree(&session, "restore a recreated file target", |session| {
|
||||
source_for_descendant(session, "second-file-alias").as_deref()
|
||||
== Some("return \"file restored\"")
|
||||
});
|
||||
|
||||
std::fs::remove_dir(&directory_alias).unwrap();
|
||||
wait_for_tree(&session, "remove a directory symlink", |session| {
|
||||
source_for_descendant(session, "module").is_none()
|
||||
});
|
||||
|
||||
symlink_dir(&directory_target_a, &directory_alias).unwrap();
|
||||
wait_for_tree(&session, "recreate a directory symlink", |session| {
|
||||
source_for_descendant(session, "module").as_deref() == Some("return \"alias\"")
|
||||
});
|
||||
|
||||
std::fs::remove_dir(&directory_alias).unwrap();
|
||||
wait_for_tree(
|
||||
&session,
|
||||
"remove a directory symlink before repointing",
|
||||
|session| source_for_descendant(session, "module").is_none(),
|
||||
);
|
||||
symlink_dir(&directory_target_b, &directory_alias).unwrap();
|
||||
wait_for_tree(&session, "repoint a directory symlink", |session| {
|
||||
source_for_descendant(session, "replacement").as_deref()
|
||||
== Some("return \"replacement\"")
|
||||
});
|
||||
|
||||
assert!(
|
||||
session.message_queue().cursor() > initial_cursor,
|
||||
"serve session should publish applied symlink updates"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn applies_windows_junction_updates_without_restarting() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let project = temp.path().join("project");
|
||||
let source = project.join("src");
|
||||
let target = temp.path().join("junction-target");
|
||||
let junction = source.join("junctioned");
|
||||
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::write(target.join("module.lua"), "return \"initial\"").unwrap();
|
||||
// std has no API for junctions; `mklink /J` needs no elevation.
|
||||
let status = std::process::Command::new("cmd")
|
||||
.arg("/C")
|
||||
.arg("mklink")
|
||||
.arg("/J")
|
||||
.arg(&junction)
|
||||
.arg(&target)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success(), "mklink /J failed: {status}");
|
||||
std::fs::write(
|
||||
project.join("default.project.json"),
|
||||
r#"{ "name": "test", "tree": { "$path": "src" } }"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let session = ServeSession::new(Vfs::new(StdBackend::new().unwrap()), &project).unwrap();
|
||||
let initial_cursor = session.message_queue().cursor();
|
||||
assert_eq!(
|
||||
source_for_descendant(&session, "module").as_deref(),
|
||||
Some("return \"initial\"")
|
||||
);
|
||||
|
||||
std::fs::write(target.join("module.lua"), "return \"physical\"").unwrap();
|
||||
wait_for_tree(&session, "apply a junction-target write", |session| {
|
||||
source_for_descendant(session, "module").as_deref() == Some("return \"physical\"")
|
||||
});
|
||||
|
||||
std::fs::write(target.join("created.lua"), "return \"created\"").unwrap();
|
||||
wait_for_tree(&session, "add a child through a junction", |session| {
|
||||
source_for_descendant(session, "created").as_deref() == Some("return \"created\"")
|
||||
});
|
||||
|
||||
assert!(
|
||||
session.message_queue().cursor() > initial_cursor,
|
||||
"serve session should publish applied junction updates"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue