Enhance audit documentation and address critical Ref property issues

- Updated the audit guide to clarify the workflow and prerequisites for conducting a production-grade sync feature audit.
- Introduced a new plan file detailing fixes for critical issues identified in the two-way sync audit, including missing instance maps and stale paths after renames.
- Enhanced `ServeSession.lua` to correctly pass the instance map in patch updates, ensuring accurate handling of unresolved Ref properties.
- Implemented various fixes in `change_processor.rs` and `rojo_ref.rs` to address critical bugs related to Ref path handling and instance renaming.
- Added tests to verify the correct behavior of Ref properties and ensure robustness against edge cases.

These changes improve the reliability of the synchronization system and enhance the clarity of the audit process.
This commit is contained in:
Joe 2026-02-14 12:53:28 -06:00
parent 8f0df692d3
commit ca2a938902
8 changed files with 811 additions and 90 deletions

View file

@ -413,8 +413,12 @@ impl JobThreadContext {
// The old and new tree path segments (last component of each path).
// Used to map stale InstigatingSource paths to their actual on-disk
// locations after a directory rename.
let old_segment = old_path.rsplit('/').next().unwrap_or(old_path);
let new_segment = new_path.rsplit('/').next().unwrap_or(new_path);
// Use split_ref_path for escape-aware splitting (handles instance
// names containing "/" which are escaped as "\/" in ref paths).
let old_segments = crate::split_ref_path(old_path);
let old_segment = old_segments.last().map(|s| s.as_str()).unwrap_or(old_path);
let new_segments = crate::split_ref_path(new_path);
let new_segment = new_segments.last().map(|s| s.as_str()).unwrap_or(new_path);
// Collect all filesystem paths from instances in the tree.
// We check each instance's instigating source for meta/model files.
@ -424,11 +428,31 @@ impl JobThreadContext {
// If the InstigatingSource path contains the old segment (stale
// after directory rename), map it to the new segment to find the
// actual file on disk.
// actual file on disk. Use targeted path component replacement
// to avoid false matches on substrings (e.g., replacing "Part"
// inside "PartContainer").
let inst_path = {
let p_str = inst_path.to_string_lossy();
if !inst_path.exists() && p_str.contains(old_segment) {
PathBuf::from(p_str.replace(old_segment, new_segment).as_str())
if !inst_path.exists() {
let mut components: Vec<_> = inst_path.components().collect();
let mut replaced = false;
for comp in &mut components {
if let std::path::Component::Normal(os_str) = comp {
if let Some(s) = os_str.to_str() {
if s == old_segment {
*comp = std::path::Component::Normal(
std::ffi::OsStr::new(new_segment),
);
replaced = true;
break; // Only replace the first exact match
}
}
}
}
if replaced {
components.iter().collect::<PathBuf>()
} else {
inst_path.to_path_buf()
}
} else {
inst_path.to_path_buf()
}
@ -973,7 +997,7 @@ impl JobThreadContext {
// Capture the old path BEFORE rename for Rojo_Ref_* path updates.
// Must be computed before the `tree.get_instance(id)` borrow.
let old_ref_path = if update.changed_name.is_some() {
Some(tree.inner().full_path_of(id, "/"))
Some(crate::ref_target_path(tree.inner(), id))
} else {
None
};
@ -1488,15 +1512,27 @@ impl JobThreadContext {
// NOTE: The tree name hasn't been updated yet (apply_patch_set
// runs after this loop), so we can't use full_path_of for the
// new path. Construct it by replacing the last path segment.
// Use split_ref_path for escape-aware splitting (handles
// instance names containing "/").
if let Some(ref old_ref_path) = old_ref_path {
if let Some(ref new_name) = update.changed_name {
let new_ref_path =
if let Some((parent, _old_name)) = old_ref_path.rsplit_once('/') {
format!("{}/{}", parent, crate::escape_ref_path_segment(new_name))
} else {
// Root-level instance (no parent in path)
crate::escape_ref_path_segment(new_name).into_owned()
};
let segments = crate::split_ref_path(old_ref_path);
let new_ref_path = if segments.len() > 1 {
// Rebuild parent path from all segments except the last,
// re-escaping each segment to preserve the original format.
let parent_parts: Vec<_> = segments[..segments.len() - 1]
.iter()
.map(|s| crate::escape_ref_path_segment(s).into_owned())
.collect();
format!(
"{}/{}",
parent_parts.join("/"),
crate::escape_ref_path_segment(new_name)
)
} else {
// Root-level instance (no parent in path)
crate::escape_ref_path_segment(new_name).into_owned()
};
if *old_ref_path != new_ref_path {
self.update_ref_paths_after_rename(
&mut tree,

View file

@ -288,25 +288,33 @@ fn compute_ref_properties(
};
for (attr_name, attr_value) in attributes.iter() {
// Handle legacy ID-based refs (Rojo_Target_) FIRST -- lower priority.
// If a Rojo_Ref_* attribute also exists for the same property, it will
// overwrite this entry below (path-based system has priority).
// Handle legacy ID-based refs (Rojo_Target_) -- lower priority.
// BTreeMap iterates alphabetically, so Rojo_Ref_* ('R') is visited
// BEFORE Rojo_Target_* ('T'). Without the contains_key guard,
// Rojo_Target_* would silently overwrite Rojo_Ref_*. The guard
// ensures path-based refs always win when both exist.
if let Some(prop_name) = attr_name.strip_prefix(REF_POINTER_ATTRIBUTE_PREFIX) {
let key = ustr(prop_name);
// Skip if Rojo_Ref_* already set a value for this property
if map.contains_key(&key) {
continue;
}
let Some(id_str) = crate::variant_as_str(attr_value, attr_name) else {
continue;
};
let rojo_ref = RojoRef::new(id_str.to_string());
if let Some(target_id) = tree.get_specified_id(&rojo_ref) {
map.insert(ustr(prop_name), Some(Variant::Ref(target_id)));
map.insert(key, Some(Variant::Ref(target_id)));
} else {
map.insert(ustr(prop_name), None);
map.insert(key, None);
}
continue;
}
// Handle path-based refs (Rojo_Ref_) SECOND -- higher priority.
// Overwrites any Rojo_Target_* entry for the same property since
// path-based is the preferred system.
// Handle path-based refs (Rojo_Ref_) -- higher priority.
// Always inserted unconditionally; if a Rojo_Target_* for the same
// property was already visited (shouldn't happen due to BTreeMap
// ordering, but defensive), this overwrites it.
if let Some(prop_name) = attr_name.strip_prefix(REF_PATH_ATTRIBUTE_PREFIX) {
let Some(path) = crate::variant_as_str(attr_value, attr_name) else {
continue;

View file

@ -230,7 +230,7 @@ fn ref_attr_resolves_primary_part() {
let has_primary_part_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
.is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
});
assert!(
has_primary_part_ref,
@ -280,7 +280,7 @@ fn ref_attr_resolves_value() {
let has_value_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("Value"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
.is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
});
assert!(
has_value_ref,
@ -343,11 +343,11 @@ fn ref_attr_multiple_on_same_instance() {
let has_pp = update
.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
.is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
let has_custom = update
.changed_properties
.get(&ustr("CustomRef"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
.is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()));
assert!(has_pp, "PrimaryPart ref should resolve");
assert!(has_custom, "CustomRef ref should resolve");
@ -393,7 +393,7 @@ fn ref_attr_nonexistent_path_does_not_produce_valid_ref() {
let has_valid_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
.is_some_and(|v| matches!(v, Some(Variant::Ref(r)) if r.is_some()))
});
assert!(
!has_valid_ref,
@ -448,7 +448,7 @@ fn ref_attr_with_regular_attributes_mixed() {
let has_pp = patch_set.updated_instances.iter().any(|u| {
u.changed_properties
.get(&ustr("PrimaryPart"))
.map_or(false, |v| matches!(v, Some(Variant::Ref(_))))
.is_some_and(|v| matches!(v, Some(Variant::Ref(_))))
});
assert!(has_pp, "PrimaryPart should be resolved from Rojo_Ref_*");
}
@ -486,10 +486,108 @@ fn ref_attr_empty_path_resolves_to_root() {
// Empty path should resolve to root (RojoTree.get_instance_by_path("") returns root)
let has_ref = patch_set.updated_instances.iter().any(|u| {
u.changed_properties.get(&ustr("PrimaryPart")).map_or(
false,
u.changed_properties.get(&ustr("PrimaryPart")).is_some_and(
|v| matches!(v, Some(Variant::Ref(r)) if *r == root_id),
)
});
assert!(has_ref, "Empty path should resolve to root instance");
}
/// When both Rojo_Ref_PrimaryPart and Rojo_Target_PrimaryPart exist for the
/// same property, Rojo_Ref_* (path-based) must win because it is the preferred
/// system. BTreeMap iterates alphabetically ('R' < 'T'), so Rojo_Ref_* is
/// visited first. The Rojo_Target_* branch must skip insertion if Rojo_Ref_*
/// already set the property.
#[test]
fn ref_attr_priority_path_wins_over_target() {
// Build tree: ROOT > Workspace > Model > Target, OtherPart
let snapshot = InstanceSnapshot::new()
.name("ROOT")
.class_name("DataModel")
.children(vec![InstanceSnapshot::new()
.name("Workspace")
.class_name("Workspace")
.children(vec![InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.children(vec![
InstanceSnapshot::new().name("Target").class_name("Part"),
InstanceSnapshot::new().name("OtherPart").class_name("Part"),
])])]);
let mut tree = RojoTree::new(snapshot);
let root_id = tree.get_root_id();
let workspace_id = tree
.get_instance(root_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let model_id = tree
.get_instance(workspace_id)
.unwrap()
.children()
.first()
.copied()
.unwrap();
let model_children: Vec<_> = tree
.get_instance(model_id)
.unwrap()
.children()
.to_vec();
let target_id = model_children[0]; // Target
let other_id = model_children[1]; // OtherPart
// Give OtherPart a specified ID so Rojo_Target_* can resolve it
let other_rojo_id = crate::RojoRef::new("other-rojo-id".to_string());
tree.set_specified_id(other_id, other_rojo_id);
// Create snapshot with BOTH attributes for the same property.
// Rojo_Ref_PrimaryPart points to Target (via path).
// Rojo_Target_PrimaryPart points to OtherPart (via ID).
// Rojo_Ref_* should win.
let mut attrs = Attributes::new();
attrs.insert(
"Rojo_Ref_PrimaryPart".into(),
Variant::String("Workspace/Model/Target".into()),
);
attrs.insert(
"Rojo_Target_PrimaryPart".into(),
Variant::String("other-rojo-id".into()),
);
let snap = InstanceSnapshot::new()
.name("Model")
.class_name("Model")
.properties(UstrMap::from_iter([(
ustr("Attributes"),
Variant::Attributes(attrs),
)]))
.children(vec![
InstanceSnapshot::new().name("Target").class_name("Part"),
InstanceSnapshot::new().name("OtherPart").class_name("Part"),
]);
let patch_set = compute_patch_set(Some(snap), &tree, model_id);
// PrimaryPart should point to Target (from Rojo_Ref_*), NOT OtherPart (from Rojo_Target_*)
let pp_ref = patch_set
.updated_instances
.iter()
.find_map(|u| u.changed_properties.get(&ustr("PrimaryPart")))
.expect("PrimaryPart should be in the patch");
match pp_ref {
Some(Variant::Ref(r)) => {
assert_eq!(
*r, target_id,
"Rojo_Ref_* should win over Rojo_Target_* -- PrimaryPart should point to Target, not OtherPart"
);
}
other => panic!(
"PrimaryPart should be Some(Variant::Ref), got {:?}",
other
),
}
}

View file

@ -593,6 +593,32 @@ impl ApiService {
// Log summary of any syncback issues
stats.log_summary();
// Build a path map for instances added in this same request.
// This allows Ref properties in updates to reference just-added
// instances that aren't in the tree yet (VFS watcher is async).
let added_paths: HashMap<Ref, String> = {
let tree = self.serve_session.tree();
request
.added
.iter()
.filter_map(|(guid, added)| {
let parent_ref = added.parent?;
let parent_path = if tree.get_instance(parent_ref).is_some() {
crate::ref_target_path(tree.inner(), parent_ref)
} else {
return None;
};
let escaped_name = crate::escape_ref_path_segment(&added.name);
let path = if parent_path.is_empty() {
escaped_name.into_owned()
} else {
format!("{}/{}", parent_path, escaped_name)
};
Some((*guid, path))
})
.collect()
};
// Persist non-Source property changes to disk (meta/model files).
// Source property changes are handled by ChangeProcessor when it
// receives the PatchSet below.
@ -620,7 +646,9 @@ impl ApiService {
}
// Write non-Source properties to meta/model files
if let Err(err) = self.syncback_updated_properties(update, &tree) {
if let Err(err) =
self.syncback_updated_properties(update, &tree, &added_paths)
{
log::warn!(
"Failed to persist non-Source properties for instance {:?}: {}",
update.id,
@ -2547,6 +2575,7 @@ impl ApiService {
&self,
update: &crate::web::interface::InstanceUpdate,
tree: &crate::snapshot::RojoTree,
added_paths: &HashMap<Ref, String>,
) -> anyhow::Result<()> {
use anyhow::Context;
@ -2629,6 +2658,20 @@ impl ApiService {
}
ref_attributes.insert(attr_name, serde_json::Value::String(path));
} else if let Some(path) = added_paths.get(target_ref) {
// Target was added in the same /api/write request.
// It's not in the tree yet (VFS watcher hasn't picked it up),
// but we can compute the path from the AddedInstance data.
log::info!(
"Ref property '{}' for instance {:?}: target {:?} is a \
just-added instance, using pre-computed path '{}'",
key,
update.id,
target_ref,
path
);
ref_attributes
.insert(attr_name, serde_json::Value::String(path.clone()));
} else {
log::warn!(
"Cannot persist Ref property '{}' for instance {:?}: \