diff --git a/crates/memofs/src/lib.rs b/crates/memofs/src/lib.rs index 12ecd08e..48be421f 100644 --- a/crates/memofs/src/lib.rs +++ b/crates/memofs/src/lib.rs @@ -44,6 +44,12 @@ pub struct PrefetchCache { pub is_file: HashMap, /// Directory path -> sorted child paths. Consumed once per directory. pub children: HashMap>, + /// Pre-resolved init-file info for each directory. The value is `None` + /// when the directory has no init file (plain `Folder`), or + /// `Some((init_file_name, init_path))` for the winning init entry. + /// Populated during prefetch so that `get_dir_middleware` can skip + /// per-file VFS metadata probes entirely. + pub dir_init: HashMap>, } mod sealed { @@ -282,6 +288,10 @@ impl VfsInner { if let Some(&is_file) = cache.is_file.get(path) { return Ok(Metadata { is_file }); } + return Err(io::Error::new( + io::ErrorKind::NotFound, + "not in prefetch cache", + )); } self.backend.metadata(path) @@ -384,6 +394,17 @@ impl Vfs { inner.prefetch_cache = None; } + /// Look up pre-resolved init-file info for a directory from the + /// prefetch cache. Returns `Some(Some((name, path)))` when the + /// directory was found in the cache with an init file, `Some(None)` + /// when the directory was found but has no init file, and `None` + /// when there is no prefetch cache or the directory is not in it. + pub fn prefetch_dir_init(&self, dir: &Path) -> Option> { + let inner = self.inner.lock().unwrap(); + let cache = inner.prefetch_cache.as_ref()?; + cache.dir_init.get(dir).cloned() + } + /// Manually lock the Vfs, useful for large batches of operations. pub fn lock(&self) -> VfsLock<'_> { VfsLock { @@ -732,6 +753,7 @@ mod test { .collect(), is_file: HashMap::new(), children: HashMap::new(), + dir_init: HashMap::new(), } } @@ -868,6 +890,7 @@ mod test { files: cache_files, is_file: HashMap::new(), children: HashMap::new(), + dir_init: HashMap::new(), }); let result = vfs.read(&file_path).unwrap(); @@ -913,6 +936,7 @@ mod test { files: cache_files, is_file: HashMap::new(), children: HashMap::new(), + dir_init: HashMap::new(), }); let handles: Vec<_> = (0..100) @@ -953,6 +977,7 @@ mod test { files: cache_files, is_file: HashMap::new(), children: HashMap::new(), + dir_init: HashMap::new(), }); for i in 0..50 { diff --git a/src/serve_session.rs b/src/serve_session.rs index 60306ad0..e7956371 100644 --- a/src/serve_session.rs +++ b/src/serve_session.rs @@ -19,7 +19,7 @@ use crate::{ apply_patch_set, compute_patch_set, AppliedPatchSet, InstanceContext, InstanceSnapshot, PatchSet, RojoTree, }, - snapshot_middleware::{is_script_relevant_path, snapshot_from_vfs}, + snapshot_middleware::{is_script_relevant_path, snapshot_from_vfs, INIT_FILE_PRIORITY}, }; /// Set to `true` to validate on plugin connect (useful for testing, do not enable on production). @@ -155,31 +155,35 @@ fn prefetch_project_files(project: &Project, sync_scripts_only: bool) -> io::Res files: HashMap::new(), is_file: HashMap::new(), children: HashMap::new(), + dir_init: HashMap::new(), }); } let walk_start = Instant::now(); let mut entries: Vec = Vec::new(); - for root in &roots { - if !root.exists() { - continue; - } - for e in WalkDir::new(root).follow_links(true).into_iter().flatten() { + + // Walk the entire project folder so that every file under it + // (nested project files, etc.) is covered by the is_file map. + // P1 returns NotFound for cache misses when prefetch is active. + if folder.exists() { + for e in WalkDir::new(folder) + .follow_links(true) + .into_iter() + .flatten() + { entries.push(e); } } - // Also include the project file itself (read by project middleware). - if let Ok(meta) = std::fs::metadata(&project.file_location) { - if meta.is_file() { - entries.push( - WalkDir::new(&project.file_location) - .into_iter() - .next() - .unwrap() - .unwrap(), - ); + // Also walk $path roots that may be OUTSIDE the project folder + // (e.g. $path: "../module"). These wouldn't be covered above. + for root in &roots { + if !root.exists() || root.starts_with(folder) { + continue; + } + for e in WalkDir::new(root).follow_links(true).into_iter().flatten() { + entries.push(e); } } @@ -215,8 +219,9 @@ fn prefetch_project_files(project: &Project, sync_scripts_only: bool) -> io::Res read_elapsed, ); - let mut is_file_map: HashMap = HashMap::new(); - let mut children_map: HashMap> = HashMap::new(); + let mut is_file_map: HashMap = HashMap::with_capacity(entries.len()); + let mut children_map: HashMap> = + HashMap::with_capacity(dir_count); for entry in &entries { let path = entry.path().to_path_buf(); @@ -242,10 +247,28 @@ fn prefetch_project_files(project: &Project, sync_scripts_only: bool) -> io::Res children_map.len(), ); + let mut dir_init_map: HashMap> = + HashMap::with_capacity(children_map.len()); + for (dir_path, child_paths) in &children_map { + let child_names: std::collections::HashSet<&str> = child_paths + .iter() + .filter_map(|p| p.file_name().and_then(|n| n.to_str())) + .collect(); + let mut found = None; + for &(_, init_name) in INIT_FILE_PRIORITY { + if child_names.contains(init_name) { + found = Some((init_name.to_string(), dir_path.join(init_name))); + break; + } + } + dir_init_map.insert(dir_path.clone(), found); + } + Ok(PrefetchCache { files: file_data.into_iter().collect::>(), is_file: is_file_map, children: children_map, + dir_init: dir_init_map, }) } @@ -273,12 +296,15 @@ impl ServeSession { let prefetch_start = Instant::now(); match prefetch_project_files(&root_project, sync_scripts_only) { Ok(cache) => { + let count = cache.files.len(); log::debug!( "Prefetch total: {} files in {:.1?}", - cache.files.len(), + count, prefetch_start.elapsed() ); - vfs.set_prefetch_cache(cache); + if !cache.is_file.is_empty() { + vfs.set_prefetch_cache(cache); + } } Err(err) => { log::warn!("Prefetch failed, falling back to sequential reads: {err}"); @@ -291,37 +317,45 @@ impl ServeSession { let mut instance_context = InstanceContext::new(); instance_context.sync_scripts_only = sync_scripts_only; - let watch_start = Instant::now(); let mut path_roots = Vec::new(); collect_path_roots( &root_project.tree, root_project.folder_location(), &mut path_roots, ); - let _ = vfs.watch(root_project.folder_location()); - for root in &path_roots { - let _ = vfs.watch(root); - } - log::debug!( - "Set up {} recursive watches in {:.1?}", - path_roots.len() + 1, - watch_start.elapsed(), - ); let snap_start = Instant::now(); log::trace!("Generating snapshot of instances from VFS"); + vfs.set_watch_enabled(false); let snapshot = snapshot_from_vfs(&instance_context, vfs, start_path)?; + vfs.set_watch_enabled(true); log::debug!("Snapshot built in {:.1?}", snap_start.elapsed()); vfs.clear_prefetch_cache(); let patch_start = Instant::now(); - log::trace!("Computing initial patch set"); - let patch_set = compute_patch_set(snapshot, &tree, root_id); + let project_folder = root_project.folder_location().to_path_buf(); + std::thread::scope(|s| { + s.spawn(|| { + let watch_start = Instant::now(); + let _ = vfs.watch(&project_folder); + for root in &path_roots { + let _ = vfs.watch(root); + } + log::debug!( + "Set up {} recursive watches in {:.1?}", + path_roots.len() + 1, + watch_start.elapsed(), + ); + }); - log::trace!("Applying initial patch set"); - apply_patch_set(&mut tree, patch_set); - log::debug!("Patch computed + applied in {:.1?}", patch_start.elapsed()); + log::trace!("Computing initial patch set"); + let patch_set = compute_patch_set(snapshot, &tree, root_id); + + log::trace!("Applying initial patch set"); + apply_patch_set(&mut tree, patch_set); + }); + log::debug!("Watch + patch in {:.1?}", patch_start.elapsed()); Ok((root_project, tree)) } diff --git a/src/snapshot/patch_compute.rs b/src/snapshot/patch_compute.rs index ec585784..1bc10f5b 100644 --- a/src/snapshot/patch_compute.rs +++ b/src/snapshot/patch_compute.rs @@ -274,9 +274,20 @@ fn compute_children_patches( .get_instance(id) .expect("Instance did not exist in tree"); - let instance_children = instance.children().to_vec(); + let instance_children = instance.children(); let snapshot_children = take(&mut snapshot.children); + if instance_children.is_empty() { + for snapshot_child in snapshot_children { + patch_set.added_instances.push(PatchAdd { + parent_id: id, + instance: snapshot_child, + }); + } + return; + } + + let instance_children = instance_children.to_vec(); let match_result = match_forward(snapshot_children, &instance_children, tree, session); // Matched pairs: recursively compute patches. diff --git a/src/snapshot_middleware/mod.rs b/src/snapshot_middleware/mod.rs index 077e7f12..8595c866 100644 --- a/src/snapshot_middleware/mod.rs +++ b/src/snapshot_middleware/mod.rs @@ -30,7 +30,6 @@ use serde::{Deserialize, Serialize}; use crate::{ glob::Glob, - project::DEFAULT_PROJECT_NAMES, syncback::{dedup_suffix::strip_dedup_suffix, SyncbackReturn, SyncbackSnapshot}, }; use crate::{ @@ -110,6 +109,27 @@ pub fn snapshot_from_vfs( } } +/// Single source of truth for init-file resolution priority. +/// Project files are checked first, then init files in this order. +/// Used by both `get_dir_middleware` (live) and `prefetch_project_files` (cache). +pub static INIT_FILE_PRIORITY: &[(Middleware, &str)] = &[ + (Middleware::Project, "default.project.json5"), + (Middleware::Project, "default.project.json"), + (Middleware::ModuleScriptDir, "init.luau"), + (Middleware::ServerScriptDir, "init.server.luau"), + (Middleware::ClientScriptDir, "init.client.luau"), + (Middleware::PluginScriptDir, "init.plugin.luau"), + (Middleware::LocalScriptDir, "init.local.luau"), + (Middleware::LegacyScriptDir, "init.legacy.luau"), + (Middleware::CsvDir, "init.csv"), + // Legacy extensions (for backwards compatibility) + // init.server.lua → Script with RunContext.Legacy (old emitLegacyScripts behavior) + // init.client.lua → LocalScript (old emitLegacyScripts behavior) + (Middleware::ModuleScriptDir, "init.lua"), + (Middleware::LegacyScriptDir, "init.server.lua"), + (Middleware::LocalScriptDir, "init.client.lua"), +]; + /// Gets the appropriate middleware for a directory by checking for `init` /// files. This uses an intrinsic priority list and for compatibility, /// that order should be left unchanged. @@ -127,37 +147,24 @@ fn get_dir_middleware<'path>( .ok_or_else(|| anyhow::anyhow!("File name was not valid UTF-8: {}", dir_path.display()))?; let dir_name = strip_dedup_suffix(dir_name); - static INIT_PATHS: OnceLock> = OnceLock::new(); - let order = INIT_PATHS.get_or_init(|| { - vec![ - // Modern extensions (preferred) - (Middleware::ModuleScriptDir, "init.luau"), - (Middleware::ServerScriptDir, "init.server.luau"), - (Middleware::ClientScriptDir, "init.client.luau"), - (Middleware::PluginScriptDir, "init.plugin.luau"), - (Middleware::LocalScriptDir, "init.local.luau"), - (Middleware::LegacyScriptDir, "init.legacy.luau"), - (Middleware::CsvDir, "init.csv"), - // Legacy extensions (for backwards compatibility) - // init.server.lua → Script with RunContext.Legacy (old emitLegacyScripts behavior) - // init.client.lua → LocalScript (old emitLegacyScripts behavior) - (Middleware::ModuleScriptDir, "init.lua"), - (Middleware::LegacyScriptDir, "init.server.lua"), - (Middleware::LocalScriptDir, "init.client.lua"), - ] - }); - - for default_project_name in DEFAULT_PROJECT_NAMES { - let project_path = dir_path.join(default_project_name); - if vfs.metadata(&project_path).with_not_found()?.is_some() { - return Ok((Middleware::Project, dir_name, project_path)); - } + if let Some(cached) = vfs.prefetch_dir_init(dir_path) { + return match cached { + Some((init_name, init_path)) => { + let middleware = INIT_FILE_PRIORITY + .iter() + .find(|(_, name)| *name == init_name) + .map(|(m, _)| *m) + .unwrap_or(Middleware::Dir); + Ok((middleware, dir_name, init_path)) + } + None => Ok((Middleware::Dir, dir_name, dir_path.to_path_buf())), + }; } - for (middleware, name) in order { + for &(middleware, name) in INIT_FILE_PRIORITY { let test_path = dir_path.join(name); if vfs.metadata(&test_path).with_not_found()?.is_some() { - return Ok((*middleware, dir_name, test_path)); + return Ok((middleware, dir_name, test_path)); } } diff --git a/tests/tests/build.rs b/tests/tests/build.rs index 9b61d116..cf01557f 100644 --- a/tests/tests/build.rs +++ b/tests/tests/build.rs @@ -241,19 +241,34 @@ fn parallel_snapshot_with_prefetch_cache() { use std::collections::HashMap; let mut files = HashMap::new(); + let mut is_file = HashMap::new(); + let mut children_map: HashMap> = HashMap::new(); for entry in walkdir::WalkDir::new(root).follow_links(true) { let entry = entry.unwrap(); let path = entry.path().to_path_buf(); + is_file.insert(path.clone(), entry.file_type().is_file()); if entry.file_type().is_file() { files.insert(path.clone(), fs::read(&path).unwrap()); } + if entry.depth() > 0 { + if let Some(parent) = entry.path().parent() { + children_map + .entry(parent.to_path_buf()) + .or_default() + .push(path); + } + } + } + for children in children_map.values_mut() { + children.sort_by(|a, b| a.file_name().cmp(&b.file_name())); } let vfs_cached = memofs::Vfs::new_default(); vfs_cached.set_prefetch_cache(memofs::PrefetchCache { files, - is_file: std::collections::HashMap::new(), - children: std::collections::HashMap::new(), + is_file, + children: children_map, + dir_init: std::collections::HashMap::new(), }); let snap_cached = librojo::snapshot_from_vfs(&ctx, &vfs_cached, &project_path) @@ -359,6 +374,7 @@ fn overlapping_path_roots_no_duplicate_children() { files, is_file, children: children_map, + dir_init: std::collections::HashMap::new(), }); let snap = librojo::snapshot_from_vfs(&ctx, &vfs, &project_path)