diff --git a/Cargo.lock b/Cargo.lock index b98e75f0..1b4815ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,9 +160,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "async-compression" -version = "0.4.39" +version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +checksum = "7d67d43201f4d20c78bcda740c142ca52482d81da80681533d33bf3f0596c8e2" dependencies = [ "compression-codecs", "compression-core", @@ -265,9 +265,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "zeroize", @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" [[package]] name = "byteorder" @@ -602,9 +602,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "brotli", "compression-core", diff --git a/plugin/src/Reconciler/matching.lua b/plugin/src/Reconciler/matching.lua index 4828e148..12e3de05 100644 --- a/plugin/src/Reconciler/matching.lua +++ b/plugin/src/Reconciler/matching.lua @@ -621,7 +621,14 @@ matchChildren = function( if precomputed ~= nil then totalCost += precomputed else - totalCost += computePairCost(session, pair.virtualId, pair.studioInstance, virtualInstances, math.huge, depth) + totalCost += computePairCost( + session, + pair.virtualId, + pair.studioInstance, + virtualInstances, + math.huge, + depth + ) end end totalCost += (#unmatchedVirtual + #unmatchedStudio) * UNMATCHED_PENALTY diff --git a/plugin/src/Reconciler/trueEquals.lua b/plugin/src/Reconciler/trueEquals.lua index f9f117a7..ce781a7d 100644 --- a/plugin/src/Reconciler/trueEquals.lua +++ b/plugin/src/Reconciler/trueEquals.lua @@ -132,7 +132,13 @@ local function trueEquals(a: any, b: any): boolean return false end for i = 1, #aKp do - if not (fuzzyEq(aKp[i].Time, bKp[i].Time) and fuzzyEq(aKp[i].Value, bKp[i].Value) and fuzzyEq(aKp[i].Envelope, bKp[i].Envelope)) then + if + not ( + fuzzyEq(aKp[i].Time, bKp[i].Time) + and fuzzyEq(aKp[i].Value, bKp[i].Value) + and fuzzyEq(aKp[i].Envelope, bKp[i].Envelope) + ) + then return false end end diff --git a/plugin/src/Reconciler/trueEquals.spec.lua b/plugin/src/Reconciler/trueEquals.spec.lua index 0e3181eb..fbe3aacf 100644 --- a/plugin/src/Reconciler/trueEquals.spec.lua +++ b/plugin/src/Reconciler/trueEquals.spec.lua @@ -452,40 +452,54 @@ return function() end) it("from Vector2 identical", function() - expect(trueEquals( - Rect.new(Vector2.new(10, 20), Vector2.new(30, 40)), - Rect.new(Vector2.new(10, 20), Vector2.new(30, 40)) - )).to.equal(true) + expect( + trueEquals( + Rect.new(Vector2.new(10, 20), Vector2.new(30, 40)), + Rect.new(Vector2.new(10, 20), Vector2.new(30, 40)) + ) + ).to.equal(true) end) end) describe("NumberSequence", function() it("identical two-keypoint", function() - expect(trueEquals( - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }) - )).to.equal(true) + expect( + trueEquals( + NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), + NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }) + ) + ).to.equal(true) end) it("value within epsilon", function() - expect(trueEquals( - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1.00005) }) - )).to.equal(true) + expect( + trueEquals( + NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), + NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1.00005) }) + ) + ).to.equal(true) end) it("value beyond epsilon", function() - expect(trueEquals( - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1.002) }) - )).to.equal(false) + expect( + trueEquals( + NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), + NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1.002) }) + ) + ).to.equal(false) end) it("different keypoint count", function() - expect(trueEquals( - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), - NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(0.5, 0.5), NumberSequenceKeypoint.new(1, 1) }) - )).to.equal(false) + expect( + trueEquals( + NumberSequence.new({ NumberSequenceKeypoint.new(0, 0), NumberSequenceKeypoint.new(1, 1) }), + NumberSequence.new({ + NumberSequenceKeypoint.new(0, 0), + NumberSequenceKeypoint.new(0.5, 0.5), + NumberSequenceKeypoint.new(1, 1), + }) + ) + ).to.equal(false) end) it("simple constructor identical", function() @@ -493,51 +507,99 @@ return function() end) it("envelope within epsilon", function() - expect(trueEquals( - NumberSequence.new({ NumberSequenceKeypoint.new(0, 1, 0.5), NumberSequenceKeypoint.new(1, 1, 0.5) }), - NumberSequence.new({ NumberSequenceKeypoint.new(0, 1, 0.50005), NumberSequenceKeypoint.new(1, 1, 0.5) }) - )).to.equal(true) + expect( + trueEquals( + NumberSequence.new({ NumberSequenceKeypoint.new(0, 1, 0.5), NumberSequenceKeypoint.new(1, 1, 0.5) }), + NumberSequence.new({ + NumberSequenceKeypoint.new(0, 1, 0.50005), + NumberSequenceKeypoint.new(1, 1, 0.5), + }) + ) + ).to.equal(true) end) end) describe("ColorSequence", function() it("identical two-keypoint", function() - expect(trueEquals( - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }), - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }) - )).to.equal(true) + expect( + trueEquals( + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }), + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }) + ) + ).to.equal(true) end) it("color within epsilon", function() - expect(trueEquals( - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(0.5, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }), - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(0.50005, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }) - )).to.equal(true) + expect( + trueEquals( + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(0.5, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }), + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(0.50005, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }) + ) + ).to.equal(true) end) it("color beyond epsilon", function() - expect(trueEquals( - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(0.5, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }), - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(0.6, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }) - )).to.equal(false) + expect( + trueEquals( + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(0.5, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }), + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(0.6, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }) + ) + ).to.equal(false) end) it("different keypoint count", function() - expect(trueEquals( - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }), - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(0.5, Color3.new(0, 1, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }) - )).to.equal(false) + expect( + trueEquals( + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }), + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), + ColorSequenceKeypoint.new(0.5, Color3.new(0, 1, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }) + ) + ).to.equal(false) end) it("simple constructor identical", function() - expect(trueEquals(ColorSequence.new(Color3.new(1, 0, 0)), ColorSequence.new(Color3.new(1, 0, 0)))).to.equal(true) + expect(trueEquals(ColorSequence.new(Color3.new(1, 0, 0)), ColorSequence.new(Color3.new(1, 0, 0)))).to.equal( + true + ) end) it("time within epsilon", function() - expect(trueEquals( - ColorSequence.new({ ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }), - ColorSequence.new({ ColorSequenceKeypoint.new(0.00005, Color3.new(1, 0, 0)), ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)) }) - )).to.equal(true) + expect( + trueEquals( + ColorSequence.new({ + ColorSequenceKeypoint.new(0, Color3.new(1, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }), + ColorSequence.new({ + ColorSequenceKeypoint.new(0.00005, Color3.new(1, 0, 0)), + ColorSequenceKeypoint.new(1, Color3.new(0, 0, 1)), + }) + ) + ).to.equal(true) end) end) diff --git a/rbx-dom b/rbx-dom index 1166e5a6..a98c70c8 160000 --- a/rbx-dom +++ b/rbx-dom @@ -1 +1 @@ -Subproject commit 1166e5a628fa7cba9c85cd660ae8958bb6e9f458 +Subproject commit a98c70c8752071b59b852d2f7e55376b1bbd2370 diff --git a/src/cli/serve.rs b/src/cli/serve.rs index 8a5c2428..e08cb389 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,7 +1,6 @@ use std::{ collections::HashMap, io::Cursor, - mem::forget, net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, sync::Arc, @@ -58,7 +57,7 @@ impl ServeCommand { .port .or_else(|| session.project_port()) .unwrap_or(DEFAULT_PORT); - forget(session); + drop(session); (ip, port) }; @@ -83,8 +82,12 @@ impl ServeCommand { match server.start(addr) { ServerExitReason::SyncbackRequested(payload) => { log::info!("Live syncback requested, running..."); - run_live_syncback(&project_path, payload)?; - log::info!("Syncback complete, restarting serve..."); + match run_live_syncback(&project_path, payload) { + Ok(()) => log::info!("Syncback complete, restarting serve..."), + Err(err) => { + log::error!("Live syncback failed: {err:#}. Restarting serve...") + } + } continue; } } @@ -132,7 +135,7 @@ fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::R crate::git::refresh_git_index(base_path); - forget(session_old); + drop(session_old); Ok(()) } @@ -184,7 +187,15 @@ fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result { let child_count = chunk.child_count as usize; let ref_count = chunk.ref_target_count as usize; let total = child_count + ref_count; - let end = (cursor + total).min(cloned_children.len()); + let end = cursor + total; + anyhow::ensure!( + end <= cloned_children.len(), + "Service '{}' claims {} children + {} carriers but only {} instances remain in blob", + chunk.class_name, + child_count, + ref_count, + cloned_children.len().saturating_sub(cursor) + ); let service_range: Vec = cloned_children[cursor..end].to_vec(); for &child_ref in &service_range[..child_count.min(service_range.len())] { @@ -210,6 +221,13 @@ fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result { cursor = end; } + anyhow::ensure!( + cursor == cloned_children.len(), + "Service chunks account for {} instances but rbxm blob contains {}", + cursor, + cloned_children.len() + ); + for &service_name in VISIBLE_SERVICES { if !created_services.contains(service_name) { dom.insert(root_ref, InstanceBuilder::new(service_name)); diff --git a/src/snapshot/matching.rs b/src/snapshot/matching.rs index a9532175..c8f1c484 100644 --- a/src/snapshot/matching.rs +++ b/src/snapshot/matching.rs @@ -1528,16 +1528,10 @@ mod tests { .insert(ustr("Transparency"), Variant::Float32(0.0)); s }; - let tree_model_grip_snap = make_snapshot_with_children( - "Weapon", - "Model", - vec![tree_child_grip], - ); - let tree_model_handle_snap = make_snapshot_with_children( - "Weapon", - "Model", - vec![tree_child_handle], - ); + let tree_model_grip_snap = + make_snapshot_with_children("Weapon", "Model", vec![tree_child_grip]); + let tree_model_handle_snap = + make_snapshot_with_children("Weapon", "Model", vec![tree_child_handle]); let (tree2, tree_refs2) = make_tree_from_snapshots(vec![tree_model_grip_snap, tree_model_handle_snap]); @@ -1559,11 +1553,8 @@ mod tests { s }; let snap_grip2 = { - let mut s = make_snapshot_with_children( - "Weapon", - "Model", - vec![make_snapshot("Grip", "Part")], - ); + let mut s = + make_snapshot_with_children("Weapon", "Model", vec![make_snapshot("Grip", "Part")]); s.properties .insert(ustr("PrimaryPart"), Variant::Ref(tree2_grip_ref)); s diff --git a/src/syncback/matching.rs b/src/syncback/matching.rs index 55f0b14a..eb292aa1 100644 --- a/src/syncback/matching.rs +++ b/src/syncback/matching.rs @@ -880,10 +880,8 @@ mod tests { let mut new_dom = WeakDom::new(InstanceBuilder::new("DataModel")); let new_root = new_dom.root_ref(); - let new_model_a = new_dom.insert( - new_root, - InstanceBuilder::new("Model").with_name("Weapon"), - ); + let new_model_a = + new_dom.insert(new_root, InstanceBuilder::new("Model").with_name("Weapon")); let new_handle = new_dom.insert( new_model_a, InstanceBuilder::new("Part").with_name("Handle"), @@ -894,14 +892,9 @@ mod tests { .properties .insert("PrimaryPart".into(), Variant::Ref(new_handle)); - let new_model_b = new_dom.insert( - new_root, - InstanceBuilder::new("Model").with_name("Weapon"), - ); - let new_grip = new_dom.insert( - new_model_b, - InstanceBuilder::new("Part").with_name("Grip"), - ); + let new_model_b = + new_dom.insert(new_root, InstanceBuilder::new("Model").with_name("Weapon")); + let new_grip = new_dom.insert(new_model_b, InstanceBuilder::new("Part").with_name("Grip")); new_dom .get_by_ref_mut(new_model_b) .unwrap() @@ -912,24 +905,17 @@ mod tests { let mut old_dom = WeakDom::new(InstanceBuilder::new("DataModel")); let old_root = old_dom.root_ref(); - let old_model_b = old_dom.insert( - old_root, - InstanceBuilder::new("Model").with_name("Weapon"), - ); - let old_grip = old_dom.insert( - old_model_b, - InstanceBuilder::new("Part").with_name("Grip"), - ); + let old_model_b = + old_dom.insert(old_root, InstanceBuilder::new("Model").with_name("Weapon")); + let old_grip = old_dom.insert(old_model_b, InstanceBuilder::new("Part").with_name("Grip")); old_dom .get_by_ref_mut(old_model_b) .unwrap() .properties .insert("PrimaryPart".into(), Variant::Ref(old_grip)); - let old_model_a = old_dom.insert( - old_root, - InstanceBuilder::new("Model").with_name("Weapon"), - ); + let old_model_a = + old_dom.insert(old_root, InstanceBuilder::new("Model").with_name("Weapon")); let old_handle = old_dom.insert( old_model_a, InstanceBuilder::new("Part").with_name("Handle"), @@ -961,14 +947,8 @@ mod tests { let new_inst = new_dom.get_by_ref(*new_ref).unwrap(); let old_inst = old_dom.get_by_ref(*old_ref).unwrap(); - let new_child_name = &new_dom - .get_by_ref(new_inst.children()[0]) - .unwrap() - .name; - let old_child_name = &old_dom - .get_by_ref(old_inst.children()[0]) - .unwrap() - .name; + let new_child_name = &new_dom.get_by_ref(new_inst.children()[0]).unwrap().name; + let old_child_name = &old_dom.get_by_ref(old_inst.children()[0]).unwrap().name; assert_eq!( new_child_name, old_child_name, "Ref scoring failed: new model with child '{}' matched old model with child '{}'", diff --git a/src/web/api.rs b/src/web/api.rs index 0393240a..8a9f2f4d 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -256,7 +256,12 @@ async fn handle_api_syncback( data: syncback_request.data, services: syncback_request.services, }; - syncback_signal.fire(payload); + if !syncback_signal.fire(payload) { + return msgpack( + ErrorResponse::bad_request("A syncback is already in progress"), + StatusCode::CONFLICT, + ); + } msgpack_ok(serde_json::json!({"status": "syncback_initiated"})) } diff --git a/src/web/interface.rs b/src/web/interface.rs index d1d1b640..756e6ea8 100644 --- a/src/web/interface.rs +++ b/src/web/interface.rs @@ -29,7 +29,7 @@ pub const PROTOCOL_VERSION: u64 = 6; /// Metadata for a single service in the live syncback request. /// -/// Children are NOT stored here — they live in the top-level `data` blob +/// Children are NOT stored here -- they live in the top-level `data` blob /// (a single rbxm containing all children from all services). /// /// Each service's range in the blob is `child_count + ref_target_count` @@ -37,10 +37,9 @@ pub const PROTOCOL_VERSION: u64 = 6; /// to the service). The next `ref_target_count` are ObjectValue carriers /// whose `Value` property holds a Ref to the actual target. /// -/// `refs` maps property names to 1-based indices into the carrier slice -/// (the ObjectValue entries after the children). Index 1 is the first -/// carrier, 2 the second, etc. The server reads carriers via -/// `carriers[idx - 1]`. +/// `refs` maps property names to 1-based indices into the carrier subrange +/// (NOT the full service range). Index 1 = first carrier, index 2 = second, +/// etc. Index 0 or out-of-range values are ignored (treated as nil). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ServiceChunk { diff --git a/src/web/mod.rs b/src/web/mod.rs index 672a5ab6..23716da1 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -40,9 +40,14 @@ impl SyncbackSignal { } } - pub fn fire(&self, payload: SyncbackPayload) { - *self.payload.lock().unwrap_or_else(|e| e.into_inner()) = Some(payload); + pub fn fire(&self, payload: SyncbackPayload) -> bool { + let mut guard = self.payload.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_some() { + return false; + } + *guard = Some(payload); self.notify.notify_one(); + true } pub fn take_payload(&self) -> Option { diff --git a/tests/tests/live_syncback.rs b/tests/tests/live_syncback.rs index 5f2a1826..eeeb9141 100644 --- a/tests/tests/live_syncback.rs +++ b/tests/tests/live_syncback.rs @@ -786,3 +786,200 @@ fn roundtrip_build_syncback_rebuild() { compare_trees(&dom_a, dom_a.root_ref(), &dom_b, dom_b.root_ref(), "root"); }); } + +// ── Additional edge-case parity tests ──────────────────────────── + +#[test] +fn parity_children_with_attributes_and_tags() { + use rbx_dom_weak::types::{Attributes, Tags, Variant}; + + let mut attrs = Attributes::new(); + attrs.insert("CustomFloat".to_string(), Variant::Float32(1.5)); + attrs.insert("CustomBool".to_string(), Variant::Bool(true)); + + let mut tags = Tags::new(); + tags.push("TestTag"); + tags.push("AnotherTag"); + + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![ + InstanceBuilder::new("Folder") + .with_name("TaggedFolder") + .with_property("Attributes", Variant::Attributes(attrs)) + .with_property("Tags", Variant::Tags(tags)), + InstanceBuilder::new("ModuleScript") + .with_name("Utils") + .with_property("Source", Variant::String("return {}".into())), + ], + )]; + assert_live_matches_cli("live_syncback", chunks, None); +} + +#[test] +fn parity_multiple_cross_service_refs() { + use rbx_dom_weak::types::Variant; + + let target_part = InstanceBuilder::new("Part") + .with_name("Target1") + .with_property("Anchored", Variant::Bool(true)); + + let target_folder = InstanceBuilder::new("Folder").with_name("Target2"); + + let obj_value_a = InstanceBuilder::new("ObjectValue") + .with_name("PointerA") + .with_property("Value", Variant::Ref(target_part.referent())); + + let obj_value_b = InstanceBuilder::new("ObjectValue") + .with_name("PointerB") + .with_property("Value", Variant::Ref(target_folder.referent())); + + let chunks = vec![ + make_service_chunk("Workspace", vec![target_part, target_folder]), + make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder") + .with_name("Refs") + .with_child(obj_value_a) + .with_child(obj_value_b)], + ), + ]; + assert_live_matches_cli("live_syncback", chunks, None); +} + +#[test] +fn parity_properties_only_no_children() { + use rbx_dom_weak::types::{Color3, Variant}; + + let chunks = vec![ + make_service_chunk_full( + "Lighting", + vec![ + ("Ambient", Variant::Color3(Color3::new(0.1, 0.1, 0.1))), + ("Brightness", Variant::Float32(1.5)), + ], + vec![], + vec![], + vec![], + ), + make_service_chunk_full( + "SoundService", + vec![("DistanceFactor", Variant::Float32(8.0))], + vec![], + vec![], + vec![], + ), + make_service_chunk_full( + "TextChatService", + vec![( + "ChatVersion", + Variant::Enum(rbx_dom_weak::types::Enum::from_u32(2)), + )], + vec![], + vec![], + vec![], + ), + ]; + assert_live_matches_cli("live_syncback", chunks, None); +} + +// ── Resilience tests ───────────────────────────────────────────── + +#[test] +fn rapid_sequential_syncbacks() { + let (data_a, chunks_a) = build_syncback_request(vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder").with_name("RapidA")], + )]); + let (data_b, chunks_b) = build_syncback_request(vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder").with_name("RapidB")], + )]); + + run_serve_test("live_syncback", |session, _| { + session.post_api_syncback(None, data_a, chunks_a); + session.wait_to_come_back_online(); + + session.post_api_syncback(None, data_b, chunks_b); + session.wait_to_come_back_online(); + + assert!( + !session.path().join("src/shared/RapidA").exists(), + "RapidA should be gone after second syncback (clean mode)" + ); + assert!( + session.path().join("src/shared/RapidB").exists(), + "Second syncback should create RapidB" + ); + }); +} + +#[test] +fn recovers_from_malformed_chunks() { + run_serve_test("live_syncback", |session, _| { + let initial_info = session.get_api_rojo().unwrap(); + + let bad_chunk = librojo::web_api::ServiceChunk { + class_name: "ReplicatedStorage".to_string(), + child_count: 999, + ref_target_count: 0, + properties: std::collections::HashMap::new(), + refs: std::collections::HashMap::new(), + }; + + let body = build_raw_syncback_body(None, &[], &[bad_chunk]); + let response = session.post_api_syncback_raw(body); + assert!( + response.status().is_success(), + "Server should accept the request (validation happens async), got {}", + response.status() + ); + + let new_info = session.wait_to_come_back_online(); + assert_ne!( + initial_info.session_id, new_info.session_id, + "Server should have restarted (new session ID)" + ); + + assert!( + session.path().join("src").exists(), + "Filesystem should be intact (malformed syncback should not have written)" + ); + }); +} + +fn build_raw_syncback_body( + place_id: Option, + data: &[u8], + services: &[librojo::web_api::ServiceChunk], +) -> Vec { + use serde::Serialize; + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct Req<'a> { + protocol_version: u64, + server_version: String, + place_id: Option, + #[serde(with = "serde_bytes")] + data: &'a [u8], + services: &'a [librojo::web_api::ServiceChunk], + } + + let request = Req { + protocol_version: PROTOCOL_VERSION, + server_version: env!("CARGO_PKG_VERSION").to_string(), + place_id, + data, + services, + }; + + let mut body = Vec::new(); + let mut serializer = rmp_serde::Serializer::new(&mut body) + .with_human_readable() + .with_struct_map(); + request + .serialize(&mut serializer) + .expect("Failed to serialize syncback request"); + body +}