Enhance VFS watch functionality and introduce recording mode

- Updated the `watch` method in `VfsBackend` to accept a `recursive` parameter, allowing for more flexible directory watching.
- Implemented a recording mode in the `Vfs` struct to track accessed paths without immediately watching them, improving performance during read operations.
- Added tests to verify the behavior of the new recording mode and ensure correct path tracking.
- Enhanced the `StdBackend` and `NoopBackend` implementations to support the updated watch functionality.

These changes aim to improve the VFS's ability to manage file system events and enhance user control over watching behavior.
This commit is contained in:
Joe 2026-03-06 17:25:38 -06:00
parent bfb5815f5e
commit 2ea1d49316
8 changed files with 486 additions and 56 deletions

View file

@ -16,7 +16,7 @@ use crate::{
snapshot::{
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
},
snapshot_middleware::{snapshot_from_vfs, snapshot_project_node},
snapshot_middleware::{is_script_relevant_path, snapshot_from_vfs, snapshot_project_node},
syncback::{
dedup_suffix::{compute_cleanup_action, parse_dedup_suffix, DedupCleanupAction},
deduplicate_name, name_needs_slugify, slugify_name, strip_script_suffix,
@ -92,6 +92,7 @@ impl ChangeProcessor {
project_file_path: PathBuf,
critical_error_receiver: Option<Receiver<memofs::WatcherCriticalError>>,
git_repo_root: Option<PathBuf>,
sync_scripts_only: bool,
) -> Self {
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
let vfs_receiver = vfs.event_receiver();
@ -109,6 +110,7 @@ impl ChangeProcessor {
project_file_path,
ref_path_index,
git_repo_root,
sync_scripts_only,
};
let job_thread = jod_thread::Builder::new()
@ -268,6 +270,9 @@ struct JobThreadContext {
/// Git repository root, if the project is in a git repo.
/// Used for auto-staging Source writes.
git_repo_root: Option<PathBuf>,
/// When true, only script-related VFS events are processed.
sync_scripts_only: bool,
}
impl JobThreadContext {
@ -723,6 +728,24 @@ impl JobThreadContext {
}
}
if self.sync_scripts_only {
if let Some(ref path) = event_path {
if path.is_file() {
let not_in_tree = {
let tree = self.tree.lock().unwrap();
tree.get_ids_at_path(path).is_empty()
};
if not_in_tree && !is_script_relevant_path(path) {
log::trace!(
"VFS event SKIPPED (scripts-only, non-script file): {}",
self.display_path(path)
);
return Vec::new();
}
}
}
}
// 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 {
@ -890,7 +913,8 @@ impl JobThreadContext {
use crate::snapshot::InstanceContext;
let start = Instant::now();
let instance_context = InstanceContext::new();
let mut instance_context = InstanceContext::new();
instance_context.sync_scripts_only = self.sync_scripts_only;
let snapshot =
match snapshot_from_vfs(&instance_context, &self.vfs, &self.project_file_path) {

View file

@ -19,7 +19,7 @@ use crate::{
apply_patch_set, compute_patch_set, AppliedPatchSet, InstanceContext, InstanceSnapshot,
PatchSet, RojoTree,
},
snapshot_middleware::snapshot_from_vfs,
snapshot_middleware::{is_script_relevant_path, snapshot_from_vfs},
};
/// Set to `true` to validate on plugin connect (useful for testing, do not enable on production).
@ -131,7 +131,7 @@ pub struct ServeSession {
/// Collect all filesystem paths reachable from the project tree's `$path`
/// entries, then read file contents in parallel.
fn prefetch_project_files(project: &Project) -> io::Result<PrefetchCache> {
fn prefetch_project_files(project: &Project, sync_scripts_only: bool) -> io::Result<PrefetchCache> {
use rayon::prelude::*;
use std::collections::HashMap;
use walkdir::WalkDir;
@ -200,6 +200,7 @@ fn prefetch_project_files(project: &Project) -> io::Result<PrefetchCache> {
let file_data: Vec<_> = entries
.par_iter()
.filter(|e| e.file_type().is_file())
.filter(|e| !sync_scripts_only || is_script_relevant_path(e.path()))
.filter_map(|e| {
let path = e.path().to_path_buf();
std::fs::read(&path).ok().map(|c| (path, c))
@ -266,10 +267,11 @@ impl ServeSession {
log::trace!("Starting new ServeSession at path {}", start_path.display());
let root_project = Project::load_initial_project(vfs, start_path)?;
let sync_scripts_only = root_project.sync_scripts_only.unwrap_or(false);
if std::env::var("ATLAS_SEQUENTIAL").is_err() {
let prefetch_start = Instant::now();
match prefetch_project_files(&root_project) {
match prefetch_project_files(&root_project, sync_scripts_only) {
Ok(cache) => {
log::debug!(
"Prefetch total: {} files in {:.1?}",
@ -286,7 +288,11 @@ impl ServeSession {
let mut tree = RojoTree::new(InstanceSnapshot::new());
let root_id = tree.get_root_id();
let instance_context = InstanceContext::new();
let mut instance_context = InstanceContext::new();
instance_context.sync_scripts_only = sync_scripts_only;
vfs.set_watch_recursive(false);
vfs.start_watch_recording();
let snap_start = Instant::now();
log::trace!("Generating snapshot of instances from VFS");
@ -303,6 +309,28 @@ impl ServeSession {
apply_patch_set(&mut tree, patch_set);
log::debug!("Patch computed + applied in {:.1?}", patch_start.elapsed());
if let Some(recorded_paths) = vfs.take_recorded_paths() {
let dir_count = recorded_paths.iter().filter(|p| p.is_dir()).count();
for path in &recorded_paths {
if path.is_dir() {
let _ = vfs.watch(path);
}
}
let mut path_roots = Vec::new();
collect_path_roots(
&root_project.tree,
root_project.folder_location(),
&mut path_roots,
);
for root in &path_roots {
let _ = vfs.watch(root);
}
log::debug!(
"Selective watching: {} directories watched (non-recursive)",
dir_count + path_roots.len(),
);
}
Ok((root_project, tree))
}
@ -356,6 +384,7 @@ impl ServeSession {
root_project.file_location.clone(),
critical_error_receiver,
git_repo_root.clone(),
root_project.sync_scripts_only.unwrap_or(false),
);
Ok(Self {

View file

@ -155,6 +155,8 @@ pub struct InstanceContext {
pub path_ignore_rules: Arc<Vec<PathIgnoreRule>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub sync_rules: Vec<SyncRule>,
#[serde(skip)]
pub sync_scripts_only: bool,
}
impl InstanceContext {
@ -162,6 +164,7 @@ impl InstanceContext {
Self {
path_ignore_rules: Arc::new(Vec::new()),
sync_rules: Vec::new(),
sync_scripts_only: false,
}
}

View file

@ -79,6 +79,13 @@ pub fn snapshot_from_vfs(
// If and when we do, make sure to go support it in
// `Project::set_file_name`, as right now it special-cases
// `default.project.json5` as an `init` path.
if context.sync_scripts_only
&& !middleware.is_script()
&& middleware != Middleware::Dir
&& middleware != Middleware::Project
{
return Ok(None);
}
match middleware {
Middleware::Dir => middleware.snapshot(context, vfs, path, dir_name),
_ => middleware.snapshot(context, vfs, &init_path, dir_name),
@ -168,11 +175,23 @@ fn snapshot_from_path(
// name is needed (e.g. for names with special chars), it comes from the
// `name` field in adjacent .meta.json / .model.json files.
if let Some(rule) = context.get_user_sync_rule(path) {
if context.sync_scripts_only
&& !rule.middleware.is_script()
&& rule.middleware != Middleware::Project
{
return Ok(None);
}
let name = rule.file_name_for_path(path)?;
return rule.middleware.snapshot(context, vfs, path, name);
} else {
for rule in default_sync_rules() {
if rule.matches(path) {
if context.sync_scripts_only
&& !rule.middleware.is_script()
&& rule.middleware != Middleware::Project
{
return Ok(None);
}
let name = rule.file_name_for_path(path)?;
return rule.middleware.snapshot(context, vfs, path, name);
}
@ -319,6 +338,27 @@ impl Middleware {
}
}
/// Returns whether this middleware produces a script instance
/// (Script, LocalScript, or ModuleScript).
#[inline]
pub fn is_script(&self) -> bool {
matches!(
self,
Self::ServerScript
| Self::ClientScript
| Self::ModuleScript
| Self::PluginScript
| Self::LocalScript
| Self::LegacyScript
| Self::ServerScriptDir
| Self::ClientScriptDir
| Self::ModuleScriptDir
| Self::PluginScriptDir
| Self::LocalScriptDir
| Self::LegacyScriptDir
)
}
/// Returns whether this particular middleware would become a directory.
#[inline]
pub fn is_dir(&self) -> bool {
@ -454,3 +494,231 @@ pub fn default_sync_rules() -> &'static [SyncRule] {
]
})
}
/// Returns whether a filesystem path is relevant in scripts-only mode.
///
/// Matches script files (`.luau`, `.lua`), meta files (`.meta.json5`,
/// `.meta.json`), and project files (`.project.json5`, `.project.json`).
pub fn is_script_relevant_path(path: &Path) -> bool {
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_lowercase(),
None => return false,
};
name.ends_with(".luau")
|| name.ends_with(".lua")
|| name.ends_with(".meta.json5")
|| name.ends_with(".meta.json")
|| name.ends_with(".project.json5")
|| name.ends_with(".project.json")
}
#[cfg(test)]
mod test {
use super::*;
use std::collections::HashMap;
use memofs::{InMemoryFs, VfsSnapshot};
#[test]
fn is_script_covers_all_script_types() {
assert!(Middleware::ServerScript.is_script());
assert!(Middleware::ClientScript.is_script());
assert!(Middleware::ModuleScript.is_script());
assert!(Middleware::PluginScript.is_script());
assert!(Middleware::LocalScript.is_script());
assert!(Middleware::LegacyScript.is_script());
assert!(Middleware::ServerScriptDir.is_script());
assert!(Middleware::ClientScriptDir.is_script());
assert!(Middleware::ModuleScriptDir.is_script());
assert!(Middleware::PluginScriptDir.is_script());
assert!(Middleware::LocalScriptDir.is_script());
assert!(Middleware::LegacyScriptDir.is_script());
}
#[test]
fn is_script_excludes_non_scripts() {
assert!(!Middleware::Dir.is_script());
assert!(!Middleware::Project.is_script());
assert!(!Middleware::Csv.is_script());
assert!(!Middleware::JsonModel.is_script());
assert!(!Middleware::Json.is_script());
assert!(!Middleware::Rbxm.is_script());
assert!(!Middleware::Rbxmx.is_script());
assert!(!Middleware::Toml.is_script());
assert!(!Middleware::Text.is_script());
assert!(!Middleware::Yaml.is_script());
assert!(!Middleware::Ignore.is_script());
assert!(!Middleware::CsvDir.is_script());
}
#[test]
fn scripts_only_skips_non_script_file() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/project",
VfsSnapshot::dir(HashMap::from([
("data.json5", VfsSnapshot::file("{}")),
("script.server.luau", VfsSnapshot::file("print('hi')")),
])),
)
.unwrap();
let vfs = Vfs::new(imfs);
let mut context = InstanceContext::new();
context.sync_scripts_only = true;
let json_result =
snapshot_from_vfs(&context, &vfs, Path::new("/project/data.json5")).unwrap();
assert!(json_result.is_none());
let script_result =
snapshot_from_vfs(&context, &vfs, Path::new("/project/script.server.luau")).unwrap();
assert!(script_result.is_some());
assert_eq!(script_result.unwrap().class_name.as_str(), "Script");
}
#[test]
fn scripts_only_preserves_directories() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/project",
VfsSnapshot::dir(HashMap::from([(
"models",
VfsSnapshot::dir(HashMap::from([(
"part.rbxm",
VfsSnapshot::file(b"\x00".as_ref()),
)])),
)])),
)
.unwrap();
let vfs = Vfs::new(imfs);
let mut context = InstanceContext::new();
context.sync_scripts_only = true;
let result = snapshot_from_vfs(&context, &vfs, Path::new("/project/models")).unwrap();
assert!(result.is_some());
let snapshot = result.unwrap();
assert_eq!(snapshot.class_name.as_str(), "Folder");
assert!(snapshot.children.is_empty());
}
#[test]
fn scripts_only_allows_project_files() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/nested.project.json5",
VfsSnapshot::file(r#"{"name": "Nested", "tree": {"$className": "Folder"}}"#),
)
.unwrap();
let vfs = Vfs::new(imfs);
let mut context = InstanceContext::new();
context.sync_scripts_only = true;
let result = snapshot_from_vfs(&context, &vfs, Path::new("/nested.project.json5")).unwrap();
assert!(result.is_some());
}
#[test]
fn normal_mode_includes_all_files() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/project",
VfsSnapshot::dir(HashMap::from([
("data.json5", VfsSnapshot::file("{}")),
("script.server.luau", VfsSnapshot::file("print('hi')")),
])),
)
.unwrap();
let vfs = Vfs::new(imfs);
let context = InstanceContext::new();
let json_result =
snapshot_from_vfs(&context, &vfs, Path::new("/project/data.json5")).unwrap();
assert!(json_result.is_some());
let script_result =
snapshot_from_vfs(&context, &vfs, Path::new("/project/script.server.luau")).unwrap();
assert!(script_result.is_some());
}
#[test]
fn is_script_relevant_path_accepts_scripts_and_meta() {
assert!(is_script_relevant_path(Path::new("/src/main.luau")));
assert!(is_script_relevant_path(Path::new("/src/init.server.luau")));
assert!(is_script_relevant_path(Path::new("/src/old.lua")));
assert!(is_script_relevant_path(Path::new("/src/old.server.lua")));
assert!(is_script_relevant_path(Path::new("/src/file.meta.json5")));
assert!(is_script_relevant_path(Path::new("/src/file.meta.json")));
assert!(is_script_relevant_path(Path::new("/nested.project.json5")));
assert!(is_script_relevant_path(Path::new("/nested.project.json")));
}
#[test]
fn is_script_relevant_path_rejects_non_scripts() {
assert!(!is_script_relevant_path(Path::new("/src/data.json5")));
assert!(!is_script_relevant_path(Path::new("/src/model.rbxm")));
assert!(!is_script_relevant_path(Path::new("/src/notes.txt")));
assert!(!is_script_relevant_path(Path::new("/src/table.csv")));
assert!(!is_script_relevant_path(Path::new("/src/config.toml")));
assert!(!is_script_relevant_path(Path::new("/src/data.yaml")));
assert!(!is_script_relevant_path(Path::new("/src/model.rbxmx")));
assert!(!is_script_relevant_path(Path::new("/src")));
assert!(!is_script_relevant_path(Path::new("/src/no_ext")));
}
#[test]
fn scripts_only_filters_csv_dir() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/project",
VfsSnapshot::dir(HashMap::from([(
"localization",
VfsSnapshot::dir(HashMap::from([(
"init.csv",
VfsSnapshot::file("Key,Source,Example\nkey1,Hello,Hello"),
)])),
)])),
)
.unwrap();
let vfs = Vfs::new(imfs);
let mut context = InstanceContext::new();
context.sync_scripts_only = true;
let result = snapshot_from_vfs(&context, &vfs, Path::new("/project/localization")).unwrap();
assert!(
result.is_none(),
"CsvDir should be filtered in scripts-only mode"
);
}
#[test]
fn scripts_only_preserves_script_dir() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/project",
VfsSnapshot::dir(HashMap::from([(
"MyScript",
VfsSnapshot::dir(HashMap::from([(
"init.server.luau",
VfsSnapshot::file("print('hello')"),
)])),
)])),
)
.unwrap();
let vfs = Vfs::new(imfs);
let mut context = InstanceContext::new();
context.sync_scripts_only = true;
let result = snapshot_from_vfs(&context, &vfs, Path::new("/project/MyScript")).unwrap();
assert!(
result.is_some(),
"Script dir should pass through in scripts-only mode"
);
assert_eq!(result.unwrap().class_name.as_str(), "Script");
}
}