fix: optimize serve/sourcemap startup from >180s to ~8s

Root cause: notify-debouncer-full with macos_kqueue caused 25K+
directory watch registrations to take >170s on large projects.

Changes:
- Replace notify-debouncer-full with notify-debouncer-mini to avoid
  FSEvents Create+Remove event cancellation and heavy per-watch overhead
- Remove macos_kqueue feature; use FSEvents (matches upstream Rojo)
- Fix VFS read guard: watch_enabled && watch_recursive -> watch_enabled
  so recording mechanism works when watch_recursive is false
- Build RefPathIndex from patch_apply data instead of redundant 4.8s
  WalkDir + 5,854 file re-reads (populate_from_dir)
- Use ServeSession::new_oneshot() for sourcemap without --watch
- Restore simple synchronous watch setup (implicit during reads)

Serve: >180s (never completed) -> ~8s to Listening
Sourcemap: 14.3s -> ~9s
All 489 tests passing.
This commit is contained in:
Joe 2026-03-12 18:02:45 -05:00
parent 4ddc4a3e31
commit 9cf4cea936
10 changed files with 236 additions and 210 deletions

View file

@ -1,6 +1,6 @@
//! Defines the data structures used for describing instance patches.
use std::collections::HashSet;
use std::{collections::HashSet, path::PathBuf};
use rbx_dom_weak::{
types::{Ref, Variant},
@ -81,6 +81,11 @@ pub struct AppliedPatchSet {
pub removed: Vec<Ref>,
pub added: Vec<Ref>,
pub updated: Vec<AppliedPatchUpdate>,
/// Collected during patch application: `(resolved_absolute_ref_path, meta_or_model_file_path)`.
/// Used to build `RefPathIndex` without re-walking the directory tree.
#[serde(skip)]
pub ref_path_index_entries: Vec<(String, PathBuf)>,
}
impl AppliedPatchSet {
@ -89,6 +94,7 @@ impl AppliedPatchSet {
removed: Vec::new(),
added: Vec::new(),
updated: Vec::new(),
ref_path_index_entries: Vec::new(),
}
}

View file

@ -3,6 +3,8 @@
use std::{
collections::{HashMap, HashSet},
mem::take,
path::PathBuf,
time::Instant,
};
use rbx_dom_weak::{
@ -24,14 +26,20 @@ use crate::{
/// tree in sync with Rojo's.
#[profiling::function]
pub fn apply_patch_set(tree: &mut RojoTree, patch_set: PatchSet) -> AppliedPatchSet {
let t0 = Instant::now();
let mut context = PatchApplyContext::default();
let removal_count = patch_set.removed_instances.len();
let addition_count = patch_set.added_instances.len();
let update_count = patch_set.updated_instances.len();
{
profiling::scope!("removals");
for removed_id in patch_set.removed_instances {
apply_remove_instance(&mut context, tree, removed_id);
}
}
let t1 = Instant::now();
{
profiling::scope!("additions");
@ -39,6 +47,7 @@ pub fn apply_patch_set(tree: &mut RojoTree, patch_set: PatchSet) -> AppliedPatch
apply_add_child(&mut context, tree, add_patch.parent_id, add_patch.instance);
}
}
let t2 = Instant::now();
{
profiling::scope!("updates");
@ -46,6 +55,22 @@ pub fn apply_patch_set(tree: &mut RojoTree, patch_set: PatchSet) -> AppliedPatch
apply_update_child(&mut context, tree, update_patch);
}
}
let t3 = Instant::now();
log::debug!(
"[PERF] patch_apply phases: removals={} in {:.1?}, additions={} in {:.1?}, updates={} in {:.1?}",
removal_count, t1 - t0,
addition_count, t2 - t1,
update_count, t3 - t2,
);
log::debug!(
"[PERF] patch_apply context: snapshot_id_map={}, has_refs={}, attr_refs={}, path_refs={}, cleanup={}",
context.snapshot_id_to_instance_id.len(),
context.has_refs_to_rewrite.len(),
context.attribute_refs_to_rewrite.keys().count(),
context.path_refs_to_rewrite.keys().count(),
context.instances_needing_attr_cleanup.len(),
);
finalize_patch_application(context, tree)
}
@ -90,6 +115,10 @@ struct PatchApplyContext {
/// format and should not persist in the live game.
instances_needing_attr_cleanup: HashSet<Ref>,
/// Collected `(resolved_absolute_ref_path, meta_or_model_file_path)` entries
/// for building `RefPathIndex` without a redundant directory walk.
ref_path_index_entries: Vec<(String, PathBuf)>,
/// The current applied patch result, describing changes made to the tree.
applied_patch_set: AppliedPatchSet,
}
@ -105,7 +134,12 @@ struct PatchApplyContext {
/// where we build up a map of snapshot IDs to instance IDs as they're created,
/// then apply properties all at once at the end.
#[profiling::function]
fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -> AppliedPatchSet {
fn finalize_patch_application(
mut context: PatchApplyContext,
tree: &mut RojoTree,
) -> AppliedPatchSet {
let finalize_start = Instant::now();
for id in context.has_refs_to_rewrite {
let mut instance = tree
.get_instance_mut(id)
@ -119,11 +153,13 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
}
}
}
let t_refs_rewrite = Instant::now();
// IMPORTANT: Rojo_Target_* (legacy) must be resolved BEFORE Rojo_Ref_*
// (path-based) so that path-based results overwrite legacy results when
// both exist for the same property. Do not reorder these two blocks.
let mut real_rewrites = Vec::new();
let attr_ref_count = context.attribute_refs_to_rewrite.keys().count();
for (id, map) in context.attribute_refs_to_rewrite {
for (prop_name, prop_value) in map {
if let Some(target) = tree.get_specified_id(&RojoRef::new(prop_value)) {
@ -135,11 +171,29 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
.expect("Invalid instance ID in deferred attribute ref map");
instance.properties_mut().extend(real_rewrites.drain(..));
}
let t_attr_refs = Instant::now();
// Handle path-based references (Rojo_Ref_)
let mut path_rewrites = Vec::new();
let _path_ref_count = context.path_refs_to_rewrite.keys().count();
let mut path_ref_total = 0usize;
let mut path_ref_resolved = 0usize;
let mut path_ref_failed = 0usize;
for (id, map) in context.path_refs_to_rewrite {
let source_abs = crate::ref_target_path_from_tree(tree, id);
let meta_file = find_meta_or_model_path(tree, id);
for (prop_name, path) in map {
path_ref_total += 1;
if let Some(meta_path) = &meta_file {
let resolved = crate::resolve_ref_path_to_absolute(&path, &source_abs)
.unwrap_or_else(|| path.clone());
context
.ref_path_index_entries
.push((resolved, meta_path.clone()));
}
if let Some(target) = tree.resolve_ref_path(&path, id) {
log::trace!(
"Resolved path reference {} -> {} (path: {})",
@ -147,8 +201,10 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
target,
path
);
path_ref_resolved += 1;
path_rewrites.push((prop_name, Variant::Ref(target)));
} else {
path_ref_failed += 1;
log::debug!(
"Could not resolve path reference {} at path '{}' - instance not found in tree",
prop_name,
@ -163,9 +219,11 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
instance.properties_mut().extend(path_rewrites.drain(..));
}
}
let t_path_refs = Instant::now();
// Clean up Rojo ref attributes - they're only used for transport in the file
// format and should not persist in the live game
let cleanup_count = context.instances_needing_attr_cleanup.len();
for id in context.instances_needing_attr_cleanup {
let mut instance = match tree.get_instance_mut(id) {
Some(inst) => inst,
@ -195,8 +253,25 @@ fn finalize_patch_application(context: PatchApplyContext, tree: &mut RojoTree) -
attributes.remove(key);
}
}
let t_cleanup = Instant::now();
context.applied_patch_set
log::debug!(
"[PERF] finalize_patch_application: refs_rewrite={:.1?}, attr_refs({})={:.1?}, path_refs({}, ok={}, fail={})={:.1?}, cleanup({})={:.1?}, total={:.1?}",
t_refs_rewrite - finalize_start,
attr_ref_count,
t_attr_refs - t_refs_rewrite,
path_ref_total,
path_ref_resolved,
path_ref_failed,
t_path_refs - t_attr_refs,
cleanup_count,
t_cleanup - t_path_refs,
t_cleanup - finalize_start,
);
let mut result = context.applied_patch_set;
result.ref_path_index_entries = context.ref_path_index_entries;
result
}
fn apply_remove_instance(context: &mut PatchApplyContext, tree: &mut RojoTree, removed_id: Ref) {
@ -319,6 +394,27 @@ fn apply_update_child(context: &mut PatchApplyContext, tree: &mut RojoTree, patc
context.applied_patch_set.updated.push(applied_patch)
}
fn is_meta_or_model(name: &str) -> bool {
name.ends_with(".meta.json5")
|| name.ends_with(".model.json5")
|| name.ends_with(".meta.json")
|| name.ends_with(".model.json")
}
fn find_meta_or_model_path(tree: &RojoTree, id: Ref) -> Option<PathBuf> {
let instance = tree.get_instance(id)?;
instance
.metadata()
.relevant_paths
.iter()
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(is_meta_or_model)
})
.cloned()
}
/// Calculates manually-specified Ref properties and marks them in the provided
/// `PatchApplyContext` to be rewritten at the end of the patch application
/// process.