diff --git a/.cursor/plans/vfs_staleness_test_fixtures_dcde048f.plan.md b/.cursor/plans/vfs_staleness_test_fixtures_dcde048f.plan.md index 750fb2a1..6dd2bddc 100644 --- a/.cursor/plans/vfs_staleness_test_fixtures_dcde048f.plan.md +++ b/.cursor/plans/vfs_staleness_test_fixtures_dcde048f.plan.md @@ -4,28 +4,28 @@ overview: Create test fixtures that replicate VFS tree staleness, add a tree-fre todos: - id: api-endpoint content: Add GET /api/validate-tree endpoint that re-snapshots and returns drift counts without correcting the tree - status: pending + status: completed - id: session-method content: Extract check_tree_freshness() in ServeSession (no age gate, no apply, returns counts) and set VALIDATE_TREE_ON_CONNECT = false - status: pending + status: completed - id: test-helper content: Add assert_tree_fresh() helper and ValidateTreeResponse type to tests/rojo_test/serve_util.rs - status: pending + status: completed - id: fixture content: Create rojo-test/serve-tests/stale_tree/ fixture with project file and multiple scripts - status: pending + status: completed - id: staleness-tests content: "Write 4 new staleness tests: bulk changes, post-API external edit, rapid delete/recreate, directory restructure" - status: pending + status: completed - id: append-freshness content: Append assert_tree_fresh() to all watcher_* and echo_suppression_* tests in two_way_sync.rs - status: pending + status: completed - id: tighten-echo content: Tighten echo suppression cursor_delta assertion from < 10 to <= 3 - status: pending + status: completed - id: chaos-fuzzer content: Write chaos fuzzer test that slams the filesystem with random operations for 10 seconds, then asserts tree freshness - status: pending + status: completed isProject: false --- diff --git a/Cargo.lock b/Cargo.lock index 7303e695..c2d39fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,13 +179,13 @@ dependencies = [ "bincode", "blake3", "bytes", - "clap 4.5.57", + "clap 4.5.58", "criterion", "crossbeam-channel", "csv", "data-encoding", "embed-resource", - "env_logger 0.11.8", + "env_logger 0.11.9", "float-cmp", "fs-err", "futures", @@ -208,6 +208,7 @@ dependencies = [ "pathdiff", "pretty_assertions", "profiling", + "rand 0.10.0", "rayon", "rbx_binary", "rbx_cookie", @@ -273,9 +274,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ "cc", "cmake", @@ -370,7 +371,7 @@ dependencies = [ "cc", "cfg-if 1.0.4", "constant_time_eq 0.4.2", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -480,6 +481,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "ciborium" version = "0.2.2" @@ -524,9 +536,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -534,9 +546,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -558,9 +570,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cmake" @@ -670,6 +682,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -689,7 +710,7 @@ dependencies = [ "anes", "cast", "ciborium", - "clap 4.5.57", + "clap 4.5.58", "criterion-plot", "itertools", "num-traits", @@ -929,9 +950,9 @@ dependencies = [ [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" dependencies = [ "log", "regex", @@ -952,9 +973,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ "anstream", "anstyle", @@ -1225,6 +1246,7 @@ dependencies = [ "cfg-if 1.0.4", "libc", "r-efi", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -2323,7 +2345,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.2", "ring", "rustc-hash", "rustls", @@ -2380,7 +2402,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.0", ] [[package]] @@ -2390,7 +2423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -2402,6 +2435,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rayon" version = "1.11.0" @@ -2428,7 +2467,7 @@ version = "2.0.1" dependencies = [ "ahash", "criterion", - "env_logger 0.11.8", + "env_logger 0.11.9", "heck", "insta", "log", @@ -2500,7 +2539,7 @@ dependencies = [ "blake3", "insta", "lazy_static", - "rand", + "rand 0.9.2", "serde", "serde_json", "thiserror 2.0.18", @@ -2512,7 +2551,7 @@ version = "2.0.1" dependencies = [ "ahash", "base64 0.22.1", - "env_logger 0.11.8", + "env_logger 0.11.9", "heck", "insta", "log", @@ -3022,7 +3061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if 1.0.4", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3039,7 +3078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if 1.0.4", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3667,7 +3706,7 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.9.2", "sha1 0.10.6", "thiserror 2.0.18", "utf-8", diff --git a/Cargo.toml b/Cargo.toml index 22c81276..892d2202 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,3 +133,4 @@ regex = "1" serde_yaml = "0.9" tempfile = "3" walkdir = "2.5.0" +rand = "0.10.0" diff --git a/rojo-test/serve-tests/connected_models/src/DirModel/init.meta.json5 b/rojo-test/serve-tests/connected_models/src/DirModel/init.meta.json5 index 15700d79..517c1eb4 100644 --- a/rojo-test/serve-tests/connected_models/src/DirModel/init.meta.json5 +++ b/rojo-test/serve-tests/connected_models/src/DirModel/init.meta.json5 @@ -1,3 +1,3 @@ { - "$className": "Folder" + "className": "Folder" } diff --git a/rojo-test/serve-tests/stale_tree/default.project.json5 b/rojo-test/serve-tests/stale_tree/default.project.json5 new file mode 100644 index 00000000..285a1502 --- /dev/null +++ b/rojo-test/serve-tests/stale_tree/default.project.json5 @@ -0,0 +1,10 @@ +{ + "name": "stale_tree", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "$path": "src" + } + } +} diff --git a/rojo-test/serve-tests/stale_tree/src/a.luau b/rojo-test/serve-tests/stale_tree/src/a.luau new file mode 100644 index 00000000..9ee7f15f --- /dev/null +++ b/rojo-test/serve-tests/stale_tree/src/a.luau @@ -0,0 +1 @@ +return "a" diff --git a/rojo-test/serve-tests/stale_tree/src/b.luau b/rojo-test/serve-tests/stale_tree/src/b.luau new file mode 100644 index 00000000..5c06085a --- /dev/null +++ b/rojo-test/serve-tests/stale_tree/src/b.luau @@ -0,0 +1 @@ +return "b" diff --git a/rojo-test/serve-tests/stale_tree/src/c.luau b/rojo-test/serve-tests/stale_tree/src/c.luau new file mode 100644 index 00000000..dab11433 --- /dev/null +++ b/rojo-test/serve-tests/stale_tree/src/c.luau @@ -0,0 +1 @@ +return "c" diff --git a/rojo-test/serve-tests/stale_tree/src/d.luau b/rojo-test/serve-tests/stale_tree/src/d.luau new file mode 100644 index 00000000..c1687ed5 --- /dev/null +++ b/rojo-test/serve-tests/stale_tree/src/d.luau @@ -0,0 +1 @@ +return "d" diff --git a/rojo-test/serve-tests/stale_tree/src/e.luau b/rojo-test/serve-tests/stale_tree/src/e.luau new file mode 100644 index 00000000..f0bf7b6d --- /dev/null +++ b/rojo-test/serve-tests/stale_tree/src/e.luau @@ -0,0 +1 @@ +return "e" diff --git a/rojo-test/serve-tests/stale_tree/src/sub/nested.luau b/rojo-test/serve-tests/stale_tree/src/sub/nested.luau new file mode 100644 index 00000000..6f0a95c0 --- /dev/null +++ b/rojo-test/serve-tests/stale_tree/src/sub/nested.luau @@ -0,0 +1 @@ +return "nested" diff --git a/src/lib.rs b/src/lib.rs index 549fa1d7..8a878581 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,6 +29,7 @@ mod web; pub use project::*; pub use rojo_ref::*; +pub use serve_session::TreeFreshnessReport; pub use session_id::SessionId; pub use snapshot::{ InstanceContext, InstanceMetadata, InstanceSnapshot, InstanceWithMeta, InstanceWithMetaMut, diff --git a/src/serve_session.rs b/src/serve_session.rs index 1ea3bec7..8b33b251 100644 --- a/src/serve_session.rs +++ b/src/serve_session.rs @@ -26,6 +26,18 @@ use crate::{ /// Set to `true` to validate on plugin connect (useful for testing, do not enable on production). const VALIDATE_TREE_ON_CONNECT: bool = false; +/// Result of a read-only tree freshness check. Reports how many instances +/// differ between the in-memory tree and the real filesystem. +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TreeFreshnessReport { + pub is_fresh: bool, + pub added: usize, + pub removed: usize, + pub updated: usize, + pub elapsed_ms: f64, +} + /// Contains all of the state for a Rojo serve session. A serve session is used /// when we need to build a Rojo tree and possibly rebuild it when input files /// change. @@ -264,6 +276,45 @@ impl ServeSession { .unwrap_or(true) } + /// Read-only check: re-snapshots from disk and returns how many + /// instances differ between the in-memory tree and the real filesystem. + /// Does NOT apply corrections — the tree is left unchanged. + pub fn check_tree_freshness(&self) -> TreeFreshnessReport { + let start = Instant::now(); + let start_path = self.root_project.folder_location(); + let instance_context = InstanceContext::new(); + + let snapshot = match snapshot_from_vfs(&instance_context, &self.vfs, start_path) { + Ok(s) => s, + Err(e) => { + log::error!("Tree freshness check snapshot error: {:?}", e); + return TreeFreshnessReport { + is_fresh: false, + added: 0, + removed: 0, + updated: 0, + elapsed_ms: start.elapsed().as_secs_f64() * 1000.0, + }; + } + }; + + let tree = self.tree.lock().unwrap(); + let root_id = tree.get_root_id(); + let patch_set = compute_patch_set(snapshot, &tree, root_id); + + let added = patch_set.added_instances.len(); + let removed = patch_set.removed_instances.len(); + let updated = patch_set.updated_instances.len(); + + TreeFreshnessReport { + is_fresh: added == 0 && removed == 0 && updated == 0, + added, + removed, + updated, + elapsed_ms: start.elapsed().as_secs_f64() * 1000.0, + } + } + /// Re-snapshots the project tree from the real filesystem and patches /// the in-memory tree to correct any drift caused by missed VFS watcher /// events. Called on plugin connect to guarantee the tree is fresh. diff --git a/src/web/api.rs b/src/web/api.rs index dec90d28..75e15ca9 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -151,6 +151,7 @@ pub async fn call( service.handle_api_open(request).await } (&Method::POST, "/api/write") => service.handle_api_write(request).await, + (&Method::GET, "/api/validate-tree") => service.handle_api_validate_tree().await, (_method, path) => msgpack( ErrorResponse::not_found(format!("Route not found: {}", path)), @@ -285,6 +286,13 @@ impl ApiService { }) } + /// Read-only tree freshness check for test infrastructure. + /// Re-snapshots from disk and returns drift counts without applying corrections. + async fn handle_api_validate_tree(&self) -> Response> { + let report = self.serve_session.check_tree_freshness(); + msgpack_ok(&report) + } + /// Handle WebSocket upgrade for real-time message streaming async fn handle_api_socket(&self, request: &mut Request) -> Response> { let argument = &request.uri().path()["/api/socket/".len()..]; diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs index ab8a6515..ae43cceb 100644 --- a/tests/rojo_test/serve_util.rs +++ b/tests/rojo_test/serve_util.rs @@ -278,6 +278,27 @@ impl TestServeSession { Ok(deserialize_msgpack(&body).expect("Server returned malformed response")) } + /// Assert the in-memory tree exactly matches the filesystem. + /// Calls the read-only `/api/validate-tree` endpoint which re-snapshots + /// from disk and diffs against the current tree without applying corrections. + /// Panics with drift counts if any discrepancy is found. + pub fn assert_tree_fresh(&self) { + let url = format!("http://localhost:{}/api/validate-tree", self.port); + let body = reqwest::blocking::get(url) + .expect("Failed to call /api/validate-tree") + .bytes() + .expect("Failed to read validate-tree response body"); + let report: librojo::TreeFreshnessReport = + deserialize_msgpack(&body).expect("Failed to deserialize TreeFreshnessReport"); + + assert!( + report.is_fresh, + "Tree does not match filesystem: {} added, {} removed, {} updated \ + (validated in {:.1}ms)", + report.added, report.removed, report.updated, report.elapsed_ms + ); + } + /// Post to /api/write to simulate plugin syncback operations. /// Uses the library's WriteRequest type for proper serialization. /// Uses human-readable msgpack format to match server expectations. diff --git a/tests/tests/two_way_sync.rs b/tests/tests/two_way_sync.rs index ba6ce19c..0a8132d4 100644 --- a/tests/tests/two_way_sync.rs +++ b/tests/tests/two_way_sync.rs @@ -21,7 +21,7 @@ //! (read back IDs, send updates) must use instances from the initial snapshot. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use std::{fs, thread}; @@ -721,13 +721,17 @@ fn echo_suppression_prevents_redundant_patches() { let read_after = session.get_api_read(root_id).unwrap(); let cursor_delta = read_after.message_cursor - initial_cursor; - // With proper echo suppression, the cursor should not advance excessively + // With proper echo suppression, the cursor should not advance excessively. + // For a single add: 0–3 messages (the suppressed echo plus potential + // metadata/re-snapshot). Anything higher means echo events are leaking. assert!( - cursor_delta < 10, - "Message cursor should not advance excessively (delta={}), \ - indicating echo suppression is working", + cursor_delta <= 3, + "Echo suppression failure: cursor advanced by {} (expected <= 3). \ + VFS echo events are leaking through suppression.", cursor_delta ); + + session.assert_tree_fresh(); }); } @@ -2359,6 +2363,8 @@ fn echo_suppression_rapid_adds_10x() { "Cursor delta {} should be reasonable with echo suppression", cursor_delta ); + + session.assert_tree_fresh(); }); } @@ -2425,6 +2431,8 @@ fn echo_suppression_mixed_operations() { // Verify removal assert_not_exists(&src.join("Echo.luau"), "Echo.luau after removal"); + + session.assert_tree_fresh(); }); } @@ -2858,6 +2866,8 @@ fn watcher_rapid_source_edits_on_disk_10x() { } poll_tree_source(&session, rs_id, "existing", "-- disk v10", 3000); + + session.assert_tree_fresh(); }); } @@ -2873,6 +2883,8 @@ fn watcher_burst_writes_100x() { fs::write(&file_path, "-- burst final\nreturn {}").unwrap(); poll_tree_source(&session, rs_id, "existing", "-- burst final", 3000); + + session.assert_tree_fresh(); }); } @@ -2903,6 +2915,8 @@ fn watcher_filesystem_rename_chain_10x() { original_content, content, "Content preserved through renames" ); + + session.assert_tree_fresh(); }); } @@ -2924,6 +2938,8 @@ fn watcher_rename_with_content_change() { "-- atomic overwrite content", 3000, ); + + session.assert_tree_fresh(); }); } @@ -2947,6 +2963,8 @@ fn watcher_delete_recreate_immediate() { "-- recreated immediately", 3000, ); + + session.assert_tree_fresh(); }); } @@ -2970,6 +2988,8 @@ fn watcher_delete_recreate_cycle_5x() { 5000, ); } + + session.assert_tree_fresh(); }); } @@ -2990,6 +3010,8 @@ fn watcher_delete_recreate_different_content() { .unwrap(); poll_tree_source(&session, rs_id, "existing", "-- completely different", 5000); + + session.assert_tree_fresh(); }); } @@ -3021,6 +3043,8 @@ fn watcher_edit_init_file() { child_read.instances.values().any(|i| i.name == "ChildA"), "ChildA should still exist after init edit" ); + + session.assert_tree_fresh(); }); } @@ -3067,6 +3091,8 @@ fn watcher_init_type_cycling_10x() { child_read.instances.values().any(|i| i.name == "ChildB"), "ChildB should survive init type cycling" ); + + session.assert_tree_fresh(); }); } @@ -3081,6 +3107,8 @@ fn watcher_delete_init_file() { // Without init file, directory becomes a Folder poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Folder", 3000); + + session.assert_tree_fresh(); }); } @@ -3098,6 +3126,8 @@ fn watcher_replace_init_file_type() { fs::write(&new_init, &content).unwrap(); poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Script", 3000); + + session.assert_tree_fresh(); }); } @@ -3121,6 +3151,8 @@ fn watcher_standalone_to_directory_conversion() { fs::write(dir.join("ChildNew.luau"), "-- new child\nreturn {}").unwrap(); poll_tree_has_child(&session, rs_id, "StandaloneModule", "ChildNew", 5000); + + session.assert_tree_fresh(); }); } @@ -3151,6 +3183,8 @@ fn watcher_directory_to_standalone_conversion() { "Standalone should have no children, found {}", child_count ); + + session.assert_tree_fresh(); }); } @@ -3199,6 +3233,8 @@ fn watcher_format_flip_flop_5x() { 5000, ); } + + session.assert_tree_fresh(); }); } @@ -3227,6 +3263,8 @@ fn watcher_atomic_save_pattern() { 3000, ); } + + session.assert_tree_fresh(); }); } @@ -3254,6 +3292,8 @@ fn watcher_backup_rename_write_pattern() { if backup.exists() { fs::remove_file(&backup).unwrap(); } + + session.assert_tree_fresh(); }); } @@ -3281,6 +3321,8 @@ fn watcher_parent_directory_rename() { child_read.instances.values().any(|i| i.name == "ChildA"), "ChildA should be in renamed directory" ); + + session.assert_tree_fresh(); }); } @@ -3293,6 +3335,8 @@ fn watcher_parent_directory_delete_all() { fs::remove_dir_all(src.join("DirModuleWithChildren")).unwrap(); poll_tree_no_instance(&session, rs_id, "DirModuleWithChildren", 5000); + + session.assert_tree_fresh(); }); } @@ -3335,6 +3379,8 @@ fn watcher_filesystem_and_api_concurrent() { "Script should be updated via API, got: {}", script_content ); + + session.assert_tree_fresh(); }); } @@ -3364,6 +3410,8 @@ fn watcher_multi_file_simultaneous_edits() { 5000, ); } + + session.assert_tree_fresh(); }); } @@ -3832,6 +3880,8 @@ fn watcher_standalone_to_dir_then_rename() { poll_tree_has_instance(&session, rs_id, "RenamedAfterConversion", 5000); poll_tree_no_instance(&session, rs_id, "StandaloneModule", 3000); + + session.assert_tree_fresh(); }); } @@ -3858,6 +3908,8 @@ fn watcher_dir_to_standalone_then_rename() { poll_tree_has_instance(&session, rs_id, "CollapsedAndRenamed", 5000); poll_tree_no_instance(&session, rs_id, "DirModuleWithChildren", 3000); + + session.assert_tree_fresh(); }); } @@ -3882,6 +3934,8 @@ fn watcher_standalone_to_dir_then_change_init_type() { fs::rename(dir.join("init.luau"), dir.join("init.server.luau")).unwrap(); poll_tree_class(&session, rs_id, "StandaloneModule", "Script", 5000); + + session.assert_tree_fresh(); }); } @@ -3915,6 +3969,8 @@ fn watcher_init_delete_recreate_different_type() { child_read.instances.values().any(|i| i.name == "ChildA"), "ChildA should survive init type transition" ); + + session.assert_tree_fresh(); }); } @@ -3942,6 +3998,8 @@ fn watcher_init_delete_children_survive() { child_read.instances.values().any(|i| i.name == "ChildB"), "ChildB should exist after init deletion" ); + + session.assert_tree_fresh(); }); } @@ -3974,6 +4032,8 @@ fn watcher_format_flip_flop_with_rename() { fs::write(src.join("FlipRenamed.luau"), "-- collapsed back\nreturn {}").unwrap(); poll_tree_source(&session, rs_id, "FlipRenamed", "-- collapsed back", 5000); + + session.assert_tree_fresh(); }); } @@ -4009,6 +4069,8 @@ fn watcher_rename_5_files_simultaneously() { for old in &names { poll_tree_no_instance(&session, rs_id, old, 3000); } + + session.assert_tree_fresh(); }); } @@ -4036,6 +4098,8 @@ fn watcher_delete_recreate_5_files_simultaneously() { for (i, name) in names.iter().enumerate() { poll_tree_source(&session, rs_id, name, &format!("-- recreated {}", i), 5000); } + + session.assert_tree_fresh(); }); } @@ -4063,6 +4127,8 @@ fn watcher_mixed_rename_edit_delete_5_files() { poll_tree_source(&session, rs_id, "Charlie", "-- charlie fresh", 5000); poll_tree_has_instance(&session, rs_id, "DeltaMoved", 5000); poll_tree_source(&session, rs_id, "Echo", "-- echo edited", 5000); + + session.assert_tree_fresh(); }); } @@ -4476,6 +4542,8 @@ fn watcher_directory_rename_then_delete_init() { child_read.instances.values().any(|i| i.name == "ChildA"), "ChildA should survive rename + init deletion" ); + + session.assert_tree_fresh(); }); } @@ -4496,6 +4564,8 @@ fn watcher_directory_init_swap_and_rename() { poll_tree_has_instance(&session, rs_id, "SwappedAndRenamed", 5000); poll_tree_class(&session, rs_id, "SwappedAndRenamed", "Script", 5000); + + session.assert_tree_fresh(); }); } @@ -4529,6 +4599,8 @@ fn watcher_directory_rapid_init_cycling_and_rename() { // Children intact assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA in FinalCycled"); assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB in FinalCycled"); + + session.assert_tree_fresh(); }); } @@ -5593,3 +5665,293 @@ fn add_case_insensitive_colliding_instances() { ); }); } + +// =========================================================================== +// VFS Staleness Tests +// +// These tests verify the in-memory tree stays in sync with the real filesystem +// after various operation patterns that are known to stress the file watcher. +// Every test ends with `session.assert_tree_fresh()` which re-snapshots from +// disk and asserts zero drift. +// =========================================================================== + +/// Simulate a git-checkout-like bulk change: overwrite 20+ files simultaneously. +#[test] +fn stale_tree_bulk_filesystem_changes() { + run_serve_test("stale_tree", |session, _redactions| { + let src = session.path().join("src"); + + // Let the watcher fully start + thread::sleep(Duration::from_secs(1)); + + // Overwrite all initial files and add 15 more — no sleep between writes + for i in 0..20 { + let name = format!("bulk_{}.luau", i); + fs::write(src.join(&name), format!("return \"bulk {}\"", i)).unwrap(); + } + + // Wait for tree to settle by polling for the last written file + poll_tree_has_instance(&session, get_rs_id(&session), "bulk_19", 5000); + + session.assert_tree_fresh(); + }); +} + +/// After an API write (which triggers echo suppression), an external +/// overwrite of the SAME file must still be picked up. +#[test] +fn stale_tree_after_api_write_then_external_edit() { + run_serve_test("stale_tree", |session, _redactions| { + let info = session.get_api_rojo().unwrap(); + let root_read = session.get_api_read(info.root_instance_id).unwrap(); + let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage"); + let rs_read = session.get_api_read(rs_id).unwrap(); + let (a_id, _) = find_by_name(&rs_read.instances, "a"); + + // Step 1: API write updates Source (triggers suppression for the file) + let mut props = UstrMap::default(); + props.insert( + ustr("Source"), + Some(Variant::String("return \"api wrote this\"".to_string())), + ); + let update = InstanceUpdate { + id: a_id, + changed_name: None, + changed_class_name: None, + changed_properties: props, + changed_metadata: None, + }; + send_update(&session, &info.session_id, update); + + // Step 2: External overwrite with DIFFERENT content + let file_path = session.path().join("src").join("a.luau"); + fs::write(&file_path, "return \"external override\"").unwrap(); + + // Step 3: Wait for the external change to propagate + poll_tree_source(&session, rs_id, "a", "external override", 5000); + + // Step 4: Full tree must match filesystem exactly + session.assert_tree_fresh(); + }); +} + +/// Delete all initial files and immediately create new ones with different names. +/// Tests the debouncer coalescing edge case (Remove + Create in quick succession). +#[test] +fn stale_tree_rapid_delete_recreate_different_names() { + run_serve_test("stale_tree", |session, _redactions| { + let src = session.path().join("src"); + let rs_id = get_rs_id(&session); + + thread::sleep(Duration::from_secs(1)); + + // Delete all 5 initial files + for name in &["a.luau", "b.luau", "c.luau", "d.luau", "e.luau"] { + let _ = fs::remove_file(src.join(name)); + } + // Immediately create 5 new files with different names + for i in 0..5 { + fs::write( + src.join(format!("new_{}.luau", i)), + format!("return \"new {}\"", i), + ) + .unwrap(); + } + + // Wait for the last new file to appear in the tree + poll_tree_has_instance(&session, rs_id, "new_4", STRESS_POLL_TIMEOUT_MS); + + // Old names must be gone + for name in &["a", "b", "c", "d", "e"] { + poll_tree_no_instance(&session, rs_id, name, STRESS_POLL_TIMEOUT_MS); + } + + session.assert_tree_fresh(); + }); +} + +/// Bulk restructure: delete a subdirectory, rename a file, change another file's content. +/// Simulates the kind of multi-operation batch a VCS does. +#[test] +fn stale_tree_directory_restructure() { + run_serve_test("stale_tree", |session, _redactions| { + let src = session.path().join("src"); + let rs_id = get_rs_id(&session); + + thread::sleep(Duration::from_secs(1)); + + // Delete the sub/ directory + let _ = fs::remove_dir_all(src.join("sub")); + + // Rename a.luau → x.luau + let _ = fs::rename(src.join("a.luau"), src.join("x.luau")); + + // Change content of b.luau + fs::write(src.join("b.luau"), "return \"restructured b\"").unwrap(); + + // Wait for changes to settle + poll_tree_has_instance(&session, rs_id, "x", STRESS_POLL_TIMEOUT_MS); + poll_tree_no_instance(&session, rs_id, "a", STRESS_POLL_TIMEOUT_MS); + poll_tree_source(&session, rs_id, "b", "restructured b", STRESS_POLL_TIMEOUT_MS); + + session.assert_tree_fresh(); + }); +} + +// =========================================================================== +// Chaos Fuzzer +// +// Slams the filesystem with random operations for 10 seconds straight, then +// waits for the watcher to settle and asserts zero tree drift. This is the +// nuclear option for finding watcher desync bugs. +// =========================================================================== + +#[test] +fn fuzz_filesystem_chaos() { + run_serve_test("stale_tree", |session, _redactions| { + let src = session.path().join("src"); + + // Let the watcher fully start + thread::sleep(Duration::from_secs(1)); + + // Track our own inventory + let initial_files: Vec = ["a.luau", "b.luau", "c.luau", "d.luau", "e.luau"] + .iter() + .map(|f| src.join(f)) + .collect(); + let mut known_files: Vec = initial_files; + let mut known_dirs: Vec = vec![src.join("sub")]; + let mut file_counter = 0u64; + let mut dir_counter = 0u64; + + let start = Instant::now(); + let duration = Duration::from_secs(10); + let mut op_count = 0u64; + + while start.elapsed() < duration { + let op = rand::random_range(0..9u32); + match op { + // 0: Create a new file + 0 => { + file_counter += 1; + let name = format!("fuzz_{}.luau", file_counter); + let path = src.join(&name); + if fs::write(&path, format!("return \"fuzz {}\"", file_counter)).is_ok() { + known_files.push(path); + } + } + // 1: Delete a random existing file + 1 => { + if !known_files.is_empty() { + let idx = rand::random_range(0..known_files.len()); + let path = known_files.swap_remove(idx); + let _ = fs::remove_file(&path); + } + } + // 2: Overwrite a random existing file + 2 => { + if !known_files.is_empty() { + let idx = rand::random_range(0..known_files.len()); + let path = &known_files[idx]; + let _ = fs::write(path, format!("return \"overwrite {}\"", op_count)); + } + } + // 3: Rename a random file + 3 => { + if !known_files.is_empty() { + file_counter += 1; + let idx = rand::random_range(0..known_files.len()); + let old_path = known_files.swap_remove(idx); + let new_name = format!("renamed_{}.luau", file_counter); + let new_path = src.join(&new_name); + if fs::rename(&old_path, &new_path).is_ok() { + known_files.push(new_path); + } + } + } + // 4: Create a subdirectory + file inside it + 4 => { + dir_counter += 1; + let dir_name = format!("dir_{}", dir_counter); + let dir_path = src.join(&dir_name); + if fs::create_dir_all(&dir_path).is_ok() { + known_dirs.push(dir_path.clone()); + file_counter += 1; + let file_path = dir_path.join(format!("child_{}.luau", file_counter)); + if fs::write(&file_path, format!("return \"child {}\"", file_counter)) + .is_ok() + { + known_files.push(file_path); + } + } + } + // 5: Delete a random subdirectory (recursive) + 5 => { + if !known_dirs.is_empty() { + let idx = rand::random_range(0..known_dirs.len()); + let dir_path = known_dirs.swap_remove(idx); + // Remove tracked files inside this dir + known_files.retain(|f| !f.starts_with(&dir_path)); + let _ = fs::remove_dir_all(&dir_path); + } + } + // 6: Rename a random directory + 6 => { + if !known_dirs.is_empty() { + dir_counter += 1; + let idx = rand::random_range(0..known_dirs.len()); + let old_dir = known_dirs.swap_remove(idx); + let new_name = format!("rdir_{}", dir_counter); + let new_dir = src.join(&new_name); + if fs::rename(&old_dir, &new_dir).is_ok() { + // Update tracked file paths inside the renamed dir + for f in &mut known_files { + if f.starts_with(&old_dir) { + let rel = f.strip_prefix(&old_dir).unwrap().to_path_buf(); + *f = new_dir.join(rel); + } + } + known_dirs.push(new_dir); + } + } + } + // 7: Rapid delete+recreate same file (known edge case) + 7 => { + if !known_files.is_empty() { + let idx = rand::random_range(0..known_files.len()); + let path = known_files[idx].clone(); + let _ = fs::remove_file(&path); + let _ = fs::write(&path, format!("return \"recreated {}\"", op_count)); + } + } + // 8: Double-write same file + 8 => { + if !known_files.is_empty() { + let idx = rand::random_range(0..known_files.len()); + let path = &known_files[idx]; + let _ = fs::write(path, format!("return \"write1 {}\"", op_count)); + let _ = fs::write(path, format!("return \"write2 {}\"", op_count)); + } + } + _ => unreachable!(), + } + + op_count += 1; + + // Tiny random delay (0-10ms) to vary timing pressure on the debouncer + thread::sleep(Duration::from_millis(rand::random_range(0..10u64))); + } + + eprintln!( + "Chaos fuzzer completed {} operations in {:?}", + op_count, + start.elapsed() + ); + + // Wait for VFS watcher + ChangeProcessor to fully settle + thread::sleep(Duration::from_secs(3)); + + // THE CRITICAL ASSERTION: tree must match filesystem after all that chaos + session.assert_tree_fresh(); + }); +}