mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 14:01:37 +00:00
Update logging levels in Rojo plugin and enhance test plans
- Promoted sync-related logging from Debug/Trace to Info level across multiple files, ensuring visibility of changes in the Studio output console. - Added a new plan for expanding the connected mode test suite to cover multi-step scenarios and round-trip identity verification. - Introduced a plan to fix stale tree desynchronization by implementing a validation mechanism on plugin connection. - Created a plan to bump sync logging to Info level, detailing todos for various components in the plugin. This update improves the clarity of logs and enhances testing coverage for connected mode scenarios.
This commit is contained in:
parent
2cc1726975
commit
316d7ea70e
17 changed files with 2443 additions and 1553 deletions
119
.cursor/plans/bump_sync_logging_to_info_3e39ff81.plan.md
Normal file
119
.cursor/plans/bump_sync_logging_to_info_3e39ff81.plan.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
name: Bump sync logging to info
|
||||
overview: Promote all sync-related change logging in the Rojo Studio plugin from Debug/Trace to Info level, so that every change received, confirmed, sent, and applied is visible in the Studio output console at the default log level.
|
||||
todos:
|
||||
- id: log-padding
|
||||
content: Remove 15-space padding from log tags in plugin/log/init.lua (TRACE_TAG, INFO_TAG, DEBUG_TAG)
|
||||
status: completed
|
||||
- id: serve-session
|
||||
content: Bump Debug/Trace to Info in ServeSession.lua (received, confirmed, applied summaries + per-item decisions). Add combined patch summary after WebSocket message combining.
|
||||
status: completed
|
||||
- id: change-batcher
|
||||
content: Bump batch summary in ChangeBatcher/init.lua line 115 from Debug to Info
|
||||
status: completed
|
||||
- id: api-context
|
||||
content: Bump sending-to-server logs in ApiContext.lua from Debug/Trace to Info (summary + per-item operations)
|
||||
status: completed
|
||||
- id: apply-patch
|
||||
content: Bump patch application logs in Reconciler/applyPatch.lua from Debug to Info (summary + per-update details)
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Bump Sync Change Logging to Info
|
||||
|
||||
## Current State
|
||||
|
||||
The plugin's `Log` module (at `plugin/log/init.lua`) supports levels: Error(0), Warning(1), Info(2), Debug(3), Trace(4). Default log level is `"Info"`, meaning Debug and Trace messages are hidden unless the user manually changes the setting.
|
||||
|
||||
Most sync-flow messages are currently at Debug or Trace, so users can't see what changes are being received/sent/confirmed without digging into settings.
|
||||
|
||||
## Files to Change
|
||||
|
||||
### 0. [plugin/log/init.lua](plugin/log/init.lua) -- Fix excessive log padding
|
||||
|
||||
The log tags have 15 hardcoded spaces prepended for alignment with Studio's timestamp column, but it creates absurdly wide output -- especially for multi-line messages where `addTags` replaces every `\n` with `\n` + the full padded tag.
|
||||
|
||||
```lua
|
||||
-- Current (lines 15-16) -- repeats tag on every line of multi-line messages:
|
||||
local function addTags(tag, message)
|
||||
return tag .. message:gsub("\n", "\n" .. tag)
|
||||
end
|
||||
|
||||
-- Fix: just prepend the tag once
|
||||
local function addTags(tag, message)
|
||||
return tag .. message
|
||||
end
|
||||
```
|
||||
|
||||
```lua
|
||||
-- Current (lines 19-21) -- 15 spaces of padding before each tag:
|
||||
local TRACE_TAG = (" "):rep(15) .. "[Rojo-Trace] "
|
||||
local INFO_TAG = (" "):rep(15) .. "[Rojo-Info] "
|
||||
local DEBUG_TAG = (" "):rep(15) .. "[Rojo-Debug] "
|
||||
|
||||
-- Fix: remove the padding
|
||||
local TRACE_TAG = "[Rojo-Trace] "
|
||||
local INFO_TAG = "[Rojo-Info] "
|
||||
local DEBUG_TAG = "[Rojo-Debug] "
|
||||
```
|
||||
|
||||
Note: `WARN_TAG` on line 22 already has no padding (it goes through `warn()` which has its own formatting).
|
||||
|
||||
### 1. [plugin/src/ServeSession.lua](plugin/src/ServeSession.lua) -- Received / Confirmed / Applied
|
||||
|
||||
**Received from server (WebSocket):**
|
||||
|
||||
- Line 222: `Log.debug` -> `Log.info` -- "Received {} messages from Rojo server"
|
||||
- After line 228 (after combining messages into `combinedPatch`): add an `Log.info` summary of what's IN the combined patch (removals, additions, updates counts), similar to how `applyPatch.lua` does it. Currently the contents are only visible at Trace via the raw packet dump.
|
||||
|
||||
**Confirmation selections:**
|
||||
|
||||
- Line 699: `Log.debug` -> `Log.info` -- "User selections: {} push, {} pull, {} ignored"
|
||||
- Line 713: `Log.trace` -> `Log.info` -- "[Push] Update: {}"
|
||||
- Line 726: `Log.trace` -> `Log.info` -- "[Pull] Update: {}"
|
||||
- Line 757: `Log.trace` -> `Log.info` -- "[Push] Delete: {}"
|
||||
- Line 792: `Log.trace` -> `Log.info` -- "[Push] Add: {}.{}"
|
||||
- Lines 772, 797, 828 are already Info -- no change needed
|
||||
|
||||
**Applied to Studio (summary):**
|
||||
|
||||
- Line 813: `Log.debug` -> `Log.info` -- "Applying to Studio: {} additions, {} removals, {} updates"
|
||||
- Line 583: `Log.debug` -> `Log.info` -- unapplied patch summary (important for diagnosing sync issues)
|
||||
|
||||
### 2. [plugin/src/ChangeBatcher/init.lua](plugin/src/ChangeBatcher/init.lua) -- Outgoing batched changes
|
||||
|
||||
- Line 115: `Log.debug` -> `Log.info` -- "Two-way sync: {} updates, {} additions, {} removals"
|
||||
- Lines 70, 73 stay at Trace -- these fire per-property-per-frame and would flood the console if promoted
|
||||
|
||||
### 3. [plugin/src/ApiContext.lua](plugin/src/ApiContext.lua) -- Sent to server
|
||||
|
||||
- Line 202: `Log.debug` -> `Log.info` -- "Sending to server: {} removals, {} additions, {} updates"
|
||||
- Line 212: `Log.trace` -> `Log.info` -- "[Syncback] Remove ID: {}"
|
||||
- Line 218: `Log.trace` -> `Log.info` -- "[Syncback] Add {} ({})"
|
||||
- Line 228: `Log.trace` -> `Log.info` -- "[Syncback] Update ID {} ({} properties)"
|
||||
|
||||
### 4. [plugin/src/Reconciler/applyPatch.lua](plugin/src/Reconciler/applyPatch.lua) -- Applied to Studio (per-item)
|
||||
|
||||
- Line 36: `Log.debug` -> `Log.info` -- "Applying patch to Studio: {} removals, {} additions, {} updates"
|
||||
- Line 141: `Log.debug` -> `Log.info` -- "[Studio] Update: {} - {}"
|
||||
- Lines 56, 106 are already Info -- no change needed
|
||||
|
||||
## What stays at Debug/Trace (intentionally)
|
||||
|
||||
- ChangeBatcher per-property detection (lines 70, 73) -- fires every frame, too noisy
|
||||
- Raw WebSocket packet dump (ApiContext line 299) -- huge payloads, not useful for comparison
|
||||
- Precommit/postcommit hook registration (ServeSession lines 182, 189, 203, 210) -- lifecycle plumbing, not sync data
|
||||
- Internal fallback/replace logic (ServeSession lines 561, 566) -- internal recovery mechanism
|
||||
|
||||
## Summary of Sync Flow Visibility After Changes
|
||||
|
||||
```
|
||||
Server → Plugin: "Received N messages" + combined patch summary (counts)
|
||||
Confirmation: "User selections: X push, Y pull, Z ignored" + per-item decisions
|
||||
Apply to Studio: "Applying patch: N removals, M additions, K updates" + per-item details
|
||||
Studio → Server: "Two-way sync: X updates, Y additions, Z removals" (batch summary)
|
||||
Send to Server: "Sending to server: N removals, M additions, K updates" + per-item details
|
||||
```
|
||||
|
||||
All of these will now appear in the Studio output console at the default Info log level.
|
||||
489
.cursor/plans/connected_mode_test_expansion_5b0c39a3.plan.md
Normal file
489
.cursor/plans/connected_mode_test_expansion_5b0c39a3.plan.md
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
---
|
||||
name: Connected Mode Test Expansion
|
||||
overview: Expand the serve test suite to cover multi-step connected mode scenarios -- format transitions, init file operations, directory conversions, slugify/dedup forward-sync, meta name field lifecycle, and sequential change sequences -- with full WebSocket patch verification AND round-trip identity verification. 53 tests total across 8 phases.
|
||||
todos:
|
||||
- id: fixtures
|
||||
content: "Create 4 new test fixtures in rojo-test/serve-tests/: connected_scripts, connected_init, connected_models, connected_slugify"
|
||||
status: in_progress
|
||||
- id: harness
|
||||
content: "Add harness helpers to tests/rojo_test/serve_util.rs: get_message_cursor for chaining multi-step tests, fresh_rebuild_read for round-trip verification"
|
||||
status: pending
|
||||
- id: phase1-format-transitions
|
||||
content: "Implement Phase 1: Format transition tests (standalone<->directory, init type changes) with round-trip verification -- 8 tests"
|
||||
status: pending
|
||||
- id: phase2-multi-step
|
||||
content: "Implement Phase 2: Multi-step connected sequence tests (create/edit/rename/delete lifecycle) with round-trip verification -- 5 tests"
|
||||
status: pending
|
||||
- id: phase3-init
|
||||
content: "Implement Phase 3: Init file operation tests during connected mode with round-trip verification -- 4 tests"
|
||||
status: pending
|
||||
- id: phase4-meta
|
||||
content: "Implement Phase 4: Property/meta file change tests -- 4 tests"
|
||||
status: pending
|
||||
- id: phase5-models
|
||||
content: "Implement Phase 5: Model JSON file change tests -- 5 tests"
|
||||
status: pending
|
||||
- id: phase6-cursor
|
||||
content: "Implement Phase 6: Cursor tracking and reconnection tests -- 3 tests"
|
||||
status: pending
|
||||
- id: phase7-mixed
|
||||
content: "Implement Phase 7: Mixed API + filesystem verification tests -- 2 tests"
|
||||
status: pending
|
||||
- id: phase8-slugify-fixture
|
||||
content: Create connected_slugify fixture with slugified files, dedup ~1 files, clean files, and directory-format slugified instances
|
||||
status: pending
|
||||
- id: phase8-slugify-forward
|
||||
content: "Implement Phase 8a: Slugify forward-sync tests -- editing/creating/deleting slugified files and verifying patches show real names from meta -- 6 tests"
|
||||
status: pending
|
||||
- id: phase8-dedup-forward
|
||||
content: "Implement Phase 8b: Dedup (~N suffix) forward-sync tests -- collision handling, ~1 with and without meta, add/remove colliding files -- 5 tests"
|
||||
status: pending
|
||||
- id: phase8-slugdir
|
||||
content: "Implement Phase 8c: Slugified directory format tests -- init type changes, child adds inside slugified dirs -- 3 tests"
|
||||
status: pending
|
||||
- id: phase8-meta-name-lifecycle
|
||||
content: "Implement Phase 8d: Meta name field lifecycle tests -- create/update/delete meta name, model.json5 name field -- 5 tests"
|
||||
status: pending
|
||||
- id: phase8-slug-multistep
|
||||
content: "Implement Phase 8e: Multi-step slugify scenarios with round-trip verification -- slug rename chains, collision evolution -- 3 tests"
|
||||
status: pending
|
||||
- id: run-and-review
|
||||
content: Run cargo test, cargo insta review, accept snapshots, verify clean pass
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Connected Mode Test Expansion
|
||||
|
||||
## Quality Standard
|
||||
|
||||
This test expansion is held to the same standard as the [slugify branch audit](audit_slugify_branch_e913b7dc.plan.md):
|
||||
|
||||
> **Round-trip identity**: Syncback (or two-way sync) writes a directory tree. Building an rbxl from that directory tree and forward-syncing it back must produce a **bit-identical instance tree** -- same names, same classes, same properties, same hierarchy, same ref targets. Any deviation is a bug.
|
||||
|
||||
Every test in this plan must verify not just "did the WebSocket patch look right" but also "if I killed the server and rebuilt from the filesystem right now, would I get the same tree?" This is verified by starting a fresh `rojo serve` on the same project directory after changes and comparing the resulting tree.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
The current test suite has a major asymmetry:
|
||||
|
||||
- `**two_way_sync.rs**` (plugin -> filesystem via API): 119 tests, covering stress, format transitions, encoded names, concurrent ops, failure recovery
|
||||
- `**serve.rs**` (filesystem -> plugin via WebSocket patches): 22 tests, each making only ONE change. No multi-step sequences, no format transitions, no directory conversions, no round-trip verification
|
||||
|
||||
Connected mode means the plugin is connected and receiving patches over time as the filesystem evolves. The `serve.rs` tests only verify a single atomic change, never simulating a session where the tree evolves through multiple states. They also never verify the round-trip invariant -- that the filesystem state after changes would produce an identical tree if rebuilt from scratch.
|
||||
|
||||
---
|
||||
|
||||
## Architecture of a Connected Mode Test
|
||||
|
||||
Each test follows this pattern in [tests/tests/serve.rs](tests/tests/serve.rs):
|
||||
|
||||
```rust
|
||||
run_serve_test("fixture_name", |session, mut redactions| {
|
||||
// 1. Snapshot initial state
|
||||
let info = session.get_api_rojo().unwrap();
|
||||
let root_id = info.root_instance_id;
|
||||
let read = session.get_api_read(root_id).unwrap();
|
||||
assert_yaml_snapshot!("test_initial", read.intern_and_redact(&mut redactions, root_id));
|
||||
|
||||
// 2. Change 1 -> verify patch + state
|
||||
let packet = session.recv_socket_packet(SocketPacketType::Messages, 0, || {
|
||||
fs::write(&path, "new content").unwrap();
|
||||
}).unwrap();
|
||||
assert_yaml_snapshot!("test_patch1", packet.intern_and_redact(&mut redactions, ()));
|
||||
let read = session.get_api_read(root_id).unwrap();
|
||||
assert_yaml_snapshot!("test_state1", read.intern_and_redact(&mut redactions, root_id));
|
||||
|
||||
// 3. Change 2 -> verify patch + state (using cursor from patch 1)
|
||||
let cursor = get_message_cursor(&packet);
|
||||
let packet = session.recv_socket_packet(SocketPacketType::Messages, cursor, || {
|
||||
fs::rename(&old, &new).unwrap();
|
||||
}).unwrap();
|
||||
assert_yaml_snapshot!("test_patch2", packet.intern_and_redact(&mut redactions, ()));
|
||||
|
||||
// 4. Round-trip verification: fresh rebuild must match live tree
|
||||
let final_read = session.get_api_read(root_id).unwrap();
|
||||
let fresh_read = fresh_rebuild_read(session.path());
|
||||
assert_trees_match(&final_read, &fresh_read);
|
||||
});
|
||||
```
|
||||
|
||||
The critical addition over existing serve.rs tests:
|
||||
|
||||
- **Multi-step sequences** with cursor chaining
|
||||
- **Round-trip verification** at the end -- a fresh `rojo serve` on the same filesystem must produce the same instance tree
|
||||
|
||||
Key files involved:
|
||||
|
||||
- [tests/tests/serve.rs](tests/tests/serve.rs) -- where new tests go
|
||||
- [tests/rojo_test/serve_util.rs](tests/rojo_test/serve_util.rs) -- test harness
|
||||
- [rojo-test/serve-tests/](rojo-test/serve-tests/) -- test fixtures
|
||||
- [rojo-test/serve-test-snapshots/](rojo-test/serve-test-snapshots/) -- insta snapshots
|
||||
|
||||
---
|
||||
|
||||
## Harness Changes
|
||||
|
||||
### `get_message_cursor` -- cursor extraction for chaining
|
||||
|
||||
[tests/rojo_test/serve_util.rs](tests/rojo_test/serve_util.rs) needs a helper to extract `messageCursor` from a `SocketPacket` for chaining multi-step tests:
|
||||
|
||||
```rust
|
||||
/// Extract the message cursor from a SocketPacket for use in subsequent subscriptions.
|
||||
pub fn get_message_cursor(packet: &SocketPacket) -> u32 {
|
||||
match &packet.body {
|
||||
SocketPacketBody::Messages(msg) => msg.message_cursor,
|
||||
_ => panic!("Expected Messages packet"),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `fresh_rebuild_read` -- round-trip identity verification
|
||||
|
||||
This is the core addition for enforcing the round-trip standard. After all changes, start a **fresh** `rojo serve` on the same project directory and read the full tree. If the live DOM and the fresh rebuild disagree, the filesystem state is inconsistent.
|
||||
|
||||
```rust
|
||||
/// Start a fresh rojo serve on the given project path, read the full tree,
|
||||
/// and return a normalized representation for comparison.
|
||||
///
|
||||
/// This verifies the round-trip invariant: the filesystem state written by
|
||||
/// the live session, when read from scratch, must produce the same tree.
|
||||
pub fn fresh_rebuild_read(project_path: &Path) -> NormalizedTree {
|
||||
// Find the project file in the directory
|
||||
let project_file = find_project_file(project_path);
|
||||
|
||||
let port = get_port_number();
|
||||
let port_string = port.to_string();
|
||||
let working_dir = get_working_dir_path();
|
||||
|
||||
let mut process = Command::new(ROJO_PATH)
|
||||
.args(["serve", project_file.to_str().unwrap(), "--port", &port_string])
|
||||
.current_dir(working_dir)
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Couldn't start fresh Rojo for round-trip check");
|
||||
|
||||
let kill_guard = KillOnDrop(process);
|
||||
|
||||
// Wait for fresh server to come online (same backoff as TestServeSession)
|
||||
let info = wait_for_server(port);
|
||||
let read = get_read(port, info.root_instance_id);
|
||||
|
||||
// Normalize: strip Refs (non-deterministic), sort children, extract
|
||||
// (name, class, properties, hierarchy) tuples for structural comparison
|
||||
normalize_tree(&read)
|
||||
}
|
||||
```
|
||||
|
||||
### `NormalizedTree` -- structural comparison ignoring Refs
|
||||
|
||||
Since two separate `rojo serve` sessions produce different `Ref` values, we need structural comparison. A `NormalizedTree` is a recursive structure of `(name, class_name, sorted_properties, sorted_children)` that can be compared with `==` or snapshotted.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct NormalizedInstance {
|
||||
pub name: String,
|
||||
pub class_name: String,
|
||||
pub properties: BTreeMap<String, String>, // property name -> debug representation
|
||||
pub children: Vec<NormalizedInstance>, // sorted by (name, class_name)
|
||||
}
|
||||
```
|
||||
|
||||
### `assert_round_trip` -- convenience assertion
|
||||
|
||||
```rust
|
||||
/// Assert that the live session tree matches a fresh rebuild from the filesystem.
|
||||
pub fn assert_round_trip(session: &TestServeSession, root_id: Ref) {
|
||||
let live_read = session.get_api_read(root_id).unwrap();
|
||||
let live_tree = normalize_read_response(&live_read, root_id);
|
||||
|
||||
let fresh_tree = fresh_rebuild_read(session.path());
|
||||
|
||||
assert_eq!(
|
||||
live_tree, fresh_tree,
|
||||
"Round-trip identity violation: live tree and fresh rebuild differ. \
|
||||
The filesystem state does not faithfully represent the instance tree."
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Test Fixtures
|
||||
|
||||
### `rojo-test/serve-tests/connected_scripts/`
|
||||
|
||||
Fixture with both standalone and directory format scripts, covering all three script types:
|
||||
|
||||
```
|
||||
connected_scripts/
|
||||
default.project.json5 # name: "connected_scripts", tree: { ReplicatedStorage: { $path: "src" } }
|
||||
src/
|
||||
standalone.luau # ModuleScript (standalone format)
|
||||
server.server.luau # Script (standalone format)
|
||||
local.client.luau # LocalScript (standalone format)
|
||||
DirModule/
|
||||
init.luau # ModuleScript (directory format)
|
||||
ChildA.luau # child ModuleScript
|
||||
ChildB.luau # child ModuleScript
|
||||
DirScript/
|
||||
init.server.luau # Script (directory format)
|
||||
Handler.luau # child ModuleScript
|
||||
DirLocal/
|
||||
init.local.luau # LocalScript (directory format)
|
||||
Helper.luau # child ModuleScript
|
||||
```
|
||||
|
||||
### `rojo-test/serve-tests/connected_init/`
|
||||
|
||||
Focused init file fixture for init-specific tests:
|
||||
|
||||
```
|
||||
connected_init/
|
||||
default.project.json5 # name: "connected_init", tree: { ReplicatedStorage: { $path: "src" } }
|
||||
src/
|
||||
init.luau # Root is a ModuleScript
|
||||
Child.luau # A child module
|
||||
```
|
||||
|
||||
### `rojo-test/serve-tests/connected_models/`
|
||||
|
||||
Model file fixture:
|
||||
|
||||
```
|
||||
connected_models/
|
||||
default.project.json5 # name: "connected_models", tree: { ReplicatedStorage: { $path: "src" } }
|
||||
src/
|
||||
SimpleModel.model.json5 # { "className": "Configuration", "properties": { "Name": "SimpleModel" } }
|
||||
DirModel/
|
||||
init.meta.json5 # { "className": "Folder" }
|
||||
Child.luau # child ModuleScript
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New Tests -- Organized by Category
|
||||
|
||||
### Phase 1: Format Transitions (filesystem-driven, patch + round-trip verified)
|
||||
|
||||
Highest priority. Tests what the plugin receives when files transform between standalone and directory formats, AND verifies the filesystem is consistent after each transformation.
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| 1 | `standalone_to_directory_add_child` | Create `ChildNew.luau` next to `standalone.luau`. The snapshot system should convert `standalone.luau` -> `standalone/init.luau` + `standalone/ChildNew.luau`. Verify the patch shows the correct tree transformation. | Yes |
|
||||
| 2 | `directory_to_standalone_remove_children` | Remove `ChildA.luau` and `ChildB.luau` from `DirModule/`. With no children left, verify whether `DirModule/init.luau` collapses to `DirModule.luau`. Verify patch. | Yes |
|
||||
| 3 | `standalone_to_directory_and_back` | Multi-step: (a) add child next to standalone script (standalone->dir), (b) remove child (dir->standalone). Two patches verified, plus round-trip after each step. | Yes |
|
||||
| 4 | `init_type_change_module_to_server` | Rename `DirModule/init.luau` -> `DirModule/init.server.luau`. Verify patch contains ClassName change from ModuleScript to Script. | Yes |
|
||||
| 5 | `init_type_change_server_to_local` | Rename `DirScript/init.server.luau` -> `DirScript/init.local.luau`. Verify ClassName change from Script to LocalScript. | Yes |
|
||||
| 6 | `init_type_change_cycle` | Multi-step: `init.luau` -> `init.server.luau` -> `init.local.luau` -> `init.luau`. Three patches, three state snapshots, round-trip at each step. | Yes |
|
||||
| 7 | `init_delete_children_survive` | Delete `DirModule/init.luau` while `ChildA.luau` and `ChildB.luau` remain. Verify children survive and parent becomes Folder (or whatever the correct behavior is). | Yes |
|
||||
| 8 | `init_create_in_existing_folder` | Given a folder with only children (no init file), create `init.luau`. Verify the Folder instance becomes a ModuleScript. | Yes |
|
||||
|
||||
|
||||
**Important for tests 1-2**: The snapshot middleware may or may not automatically convert between standalone and directory format when children are added/removed via the filesystem. If it doesn't (i.e., the conversion only happens through the API/change processor), the test should document that and verify the actual behavior.
|
||||
|
||||
### Phase 2: Multi-Step Connected Sequences
|
||||
|
||||
Core connected mode pattern: the tree evolves through multiple states. Each intermediate state is verified.
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
|
||||
| 9 | `create_edit_rename_delete_sequence` | Create `src/NewScript.luau` -> edit its contents -> rename to `Renamed.luau` -> delete it. Four patches, four state snapshots. Verify each patch contains exactly the expected delta. | Yes (at end) |
|
||||
| 10 | `directory_lifecycle` | Create `src/NewDir/` -> add `init.luau` + `Child.luau` -> edit `init.luau` -> remove `Child.luau` -> remove directory. Full directory lifecycle with 5 patches. | Yes (at end) |
|
||||
| 11 | `multi_file_sequential_edits` | Edit `standalone.luau`, then `server.server.luau`, then `local.client.luau`. Verify each patch only contains the changed file (no cross-contamination). | No |
|
||||
| 12 | `add_multiple_files_sequentially` | Add 3 new `.luau` files one at a time. Verify each patch adds exactly one instance with correct name and class. | Yes (at end) |
|
||||
| 13 | `remove_multiple_files_sequentially` | Remove 3 files one at a time. Verify each patch removes exactly one instance. | No |
|
||||
|
||||
|
||||
### Phase 3: Init File Operations (connected mode)
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| 14 | `edit_init_then_edit_child` | Edit `DirModule/init.luau`, then edit `DirModule/ChildA.luau`. Two separate patches, each containing only the changed instance's Source property. | No |
|
||||
| 15 | `replace_init_file_type` | Delete `DirModule/init.luau`, then create `DirModule/init.server.luau`. Verify the ClassName changes from ModuleScript to Script, children preserved. | Yes |
|
||||
| 16 | `add_init_to_plain_folder` | Start with a directory containing only children (create by removing init from fixture). Add `init.luau`. Verify Folder -> ModuleScript. | Yes |
|
||||
| 17 | `delete_init_from_directory` | Delete init file from directory with children. Verify parent becomes Folder, children survive. | Yes |
|
||||
|
||||
|
||||
### Phase 4: Property/Meta Changes (connected mode)
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| 18 | `adjacent_meta_creation` | Create `standalone.meta.json5` with `{ "properties": { "Disabled": true } }` next to `standalone.luau`. Verify property appears in patch. | Yes |
|
||||
| 19 | `adjacent_meta_edit` | Edit existing `.meta.json5` to change a property value. Verify update patch. | No |
|
||||
| 20 | `adjacent_meta_delete` | Delete `.meta.json5`. Verify properties revert to defaults. | Yes |
|
||||
| 21 | `init_meta_creation` | Create `DirModule/init.meta.json5` with properties. Verify directory instance gains properties. | Yes |
|
||||
|
||||
|
||||
### Phase 5: Model File Changes (connected mode)
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ---------------------------- | ------------------------------------------------------------------------------------------ | ---------- |
|
||||
| 22 | `model_json_edit_properties` | Edit `SimpleModel.model.json5` to change/add properties. Verify property changes in patch. | Yes |
|
||||
| 23 | `model_json_add_children` | Edit model JSON to add child instances. Verify additions in patch. | Yes |
|
||||
| 24 | `model_json_remove_children` | Edit model JSON to remove child instances. Verify removals in patch. | No |
|
||||
| 25 | `model_json_create` | Create new `NewModel.model.json5`. Verify instance addition patch. | Yes |
|
||||
| 26 | `model_json_delete` | Delete `SimpleModel.model.json5`. Verify instance removal patch. | No |
|
||||
|
||||
|
||||
### Phase 6: Cursor Tracking and Reconnection
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| 27 | `cursor_advances_correctly` | Make 3 filesystem changes. After each, verify the cursor returned in the WebSocket packet increments monotonically. | No |
|
||||
| 28 | `reconnect_with_old_cursor` | Make 2 changes with cursor advancement. Reconnect with cursor 0. Verify ALL patches are received in the catch-up response. | No |
|
||||
| 29 | `reconnect_with_partial_cursor` | Make 3 changes. Reconnect with cursor from after change 1. Verify only patches 2+3 are received. | No |
|
||||
|
||||
|
||||
### Phase 7: Mixed API + Filesystem Verification
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
|
||||
| 30 | `api_write_then_filesystem_change` | POST `/api/write` to update Source, then edit a different file on disk. Verify only the filesystem change produces a WebSocket patch (echo suppression working). | Yes |
|
||||
| 31 | `filesystem_change_then_api_write` | Edit file on disk, receive patch. Then POST `/api/write` on a different instance. Verify the API write produces the correct filesystem change and does NOT echo back as a duplicate patch. | Yes |
|
||||
|
||||
|
||||
### Phase 8: Slugify / Dedup / Meta Name (connected mode, round-trip critical)
|
||||
|
||||
This is the most critical phase for round-trip identity. The slugify system replaces forbidden filesystem chars with `_`, deduplicates collisions with `~1`/`~2` suffixes, and stores the real instance name in `.meta.json5` / `.model.json5` `name` fields. If ANY part of this chain is broken in the forward-sync direction (filesystem -> plugin patches), instance names are silently corrupted.
|
||||
|
||||
The existing `two_way_sync.rs` has 17+ tests for slugify/dedup via the API write path (plugin -> filesystem). **None** of those verify the forward-sync direction: what WebSocket patch does the plugin receive when slugified files are changed on the filesystem? This phase fills that gap completely.
|
||||
|
||||
#### New Fixture: `rojo-test/serve-tests/connected_slugify/`
|
||||
|
||||
Richer than `syncback_encoded_names` -- includes dedup collisions and directory-format slugified instances:
|
||||
|
||||
```
|
||||
connected_slugify/
|
||||
default.project.json5 # name: "connected_slugify", tree: { ReplicatedStorage: { $path: "src" } }
|
||||
src/
|
||||
# Clean-named files (no meta needed)
|
||||
Normal.luau # ModuleScript "Normal"
|
||||
CleanScript.server.luau # Script "CleanScript"
|
||||
|
||||
# Slugified files (meta has real name)
|
||||
What_Module.luau # ModuleScript, meta: "What?Module"
|
||||
What_Module.meta.json5 # { "name": "What?Module" }
|
||||
Key_Script.server.luau # Script, meta: "Key:Script"
|
||||
Key_Script.meta.json5 # { "name": "Key:Script" }
|
||||
|
||||
# Dedup collision: two instances whose slugs both = "Hey_Bro"
|
||||
Hey_Bro.luau # ModuleScript "Hey_Bro" (natural name, no meta)
|
||||
Hey_Bro~1.luau # ModuleScript, meta: "Hey/Bro"
|
||||
Hey_Bro~1.meta.json5 # { "name": "Hey/Bro" }
|
||||
|
||||
# Model JSON with name field
|
||||
What_Model.model.json5 # { "name": "What?Model", "className": "Configuration" }
|
||||
|
||||
# Slugified directory format
|
||||
Slug_Dir/ # Directory, meta: "Slug:Dir"
|
||||
init.luau # ModuleScript init
|
||||
init.meta.json5 # { "name": "Slug:Dir" }
|
||||
DirChild.luau # child ModuleScript
|
||||
```
|
||||
|
||||
#### Phase 8a: Slugify Forward-Sync (filesystem -> plugin patches)
|
||||
|
||||
Tests that editing/creating/deleting slugified files produces patches with correct **real** instance names (from meta), not filesystem names (slugs).
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| 32 | `edit_slugified_script_source` | Edit `What_Module.luau` content. Verify WebSocket patch contains `changedProperties.Source` for instance named `What?Module` (NOT `What_Module`). The meta `name` field must be respected during forward-sync. | Yes |
|
||||
| 33 | `edit_slugified_server_script_source` | Edit `Key_Script.server.luau` content. Verify patch contains Source change for instance named `Key:Script`. Same principle, different script type. | No |
|
||||
| 34 | `create_new_slugified_file_with_meta` | Create `New_Slug.luau` + `New_Slug.meta.json5` with `{"name": "New/Slug"}`. Verify the patch adds an instance named `New/Slug` (not `New_Slug`). | Yes |
|
||||
| 35 | `delete_slugified_file_and_meta` | Delete both `What_Module.luau` and `What_Module.meta.json5`. Verify the patch removes exactly one instance (the one named `What?Module`). | No |
|
||||
| 36 | `edit_only_meta_name_field` | Edit `What_Module.meta.json5` to change `{"name": "What?Module"}` -> `{"name": "What*Module"}`. Verify the patch contains a name change (or re-snapshot). The slug stays the same but the real name changes. This is the "Foo/Bar -> Foo | Bar, both slugify to Foo_Bar" case from the audit. |
|
||||
| 37 | `delete_meta_name_reverts_to_stem` | Delete `What_Module.meta.json5` (but leave `What_Module.luau`). Verify the patch shows the instance name changing from `What?Module` to `What_Module` (the filename stem becomes the name when no meta exists). | Yes |
|
||||
|
||||
|
||||
#### Phase 8b: Dedup (~N Suffix) Forward-Sync
|
||||
|
||||
Tests that `~N` suffixed files are handled correctly: the suffix is just part of the filename stem, the real name comes from meta. Without meta, the full stem (including `~N`) IS the instance name.
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| 38 | `edit_dedup_file_with_meta` | Edit `Hey_Bro~1.luau` content. Verify patch shows Source change for instance named `Hey/Bro` (from meta), not `Hey_Bro~1`. | Yes |
|
||||
| 39 | `dedup_file_without_meta_uses_stem` | Create `Foo~1.luau` with NO meta file. Verify the instance name in the patch is literally `Foo~1` -- the tilde is NOT parsed as a dedup marker during forward-sync. This is the backward compatibility guarantee. | Yes |
|
||||
| 40 | `add_second_colliding_file` | Start with `Hey_Bro.luau` (name: `Hey_Bro`). Create `Hey_Bro~1.luau` + `Hey_Bro~1.meta.json5` with `{"name": "Hey:Bro"}`. Verify the patch adds a NEW instance named `Hey:Bro` as a sibling, without disturbing `Hey_Bro`. | Yes |
|
||||
| 41 | `remove_one_of_two_colliding_files` | Delete `Hey_Bro~1.luau` + `Hey_Bro~1.meta.json5`. Verify the patch removes exactly the instance named `Hey/Bro`, leaving `Hey_Bro` untouched. | Yes |
|
||||
| 42 | `add_third_collision` | With `Hey_Bro.luau` and `Hey_Bro~1.luau` already present, create `Hey_Bro~2.luau` + `Hey_Bro~2.meta.json5` with `{"name": "Hey*Bro"}`. Verify patch adds instance named `Hey*Bro`. All three coexist. | Yes |
|
||||
|
||||
|
||||
#### Phase 8c: Slugified Directory Format
|
||||
|
||||
Tests that directory-format instances with slugified names work correctly during connected mode. The real name is in `init.meta.json5`.
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| 43 | `edit_init_in_slugified_directory` | Edit `Slug_Dir/init.luau` content. Verify patch shows Source change for instance named `Slug:Dir` (from `init.meta.json5`), not `Slug_Dir`. | Yes |
|
||||
| 44 | `init_type_change_in_slugified_directory` | Rename `Slug_Dir/init.luau` -> `Slug_Dir/init.server.luau`. Verify ClassName changes from ModuleScript to Script. Instance name must remain `Slug:Dir` (from meta). | Yes |
|
||||
| 45 | `add_child_to_slugified_directory` | Create `Slug_Dir/NewChild.luau`. Verify patch adds a new ModuleScript child under the instance named `Slug:Dir`. | Yes |
|
||||
|
||||
|
||||
#### Phase 8d: Meta Name Field Lifecycle (connected mode)
|
||||
|
||||
Tests the full lifecycle of the meta `name` field -- the bridge between filesystem names and real instance names. Any bug here silently corrupts names.
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
|
||||
| 46 | `add_meta_name_to_clean_file` | `Normal.luau` has no meta (name = `Normal`). Create `Normal.meta.json5` with `{"name": "Nor/mal"}`. Verify the patch shows the instance name changing from `Normal` to `Nor/mal`. | Yes |
|
||||
| 47 | `update_meta_name_field` | `What_Module.meta.json5` has `{"name": "What?Module"}`. Change to `{"name": "What:Module"}`. Verify the patch shows name change. The slug (`What_Module`) doesn't change, only the real name does. | Yes |
|
||||
| 48 | `meta_name_with_other_properties` | Create meta file with BOTH `name` AND `$properties`: `{"name": "Real/Name", "$properties": {"Disabled": true}}`. Verify both the name override AND the property appear in the patch. | Yes |
|
||||
| 49 | `model_json_name_field_edit` | Edit `What_Model.model.json5` to change `"name": "What?Model"` -> `"name": "What:Model"`. Verify name change in patch. The `name` field in `.model.json5` serves the same purpose as in `.meta.json5`. | Yes |
|
||||
| 50 | `model_json_remove_name_field` | Edit `What_Model.model.json5` to remove the `"name"` field entirely. Verify instance name reverts from `What?Model` to `What_Model` (derived from filename stem). | Yes |
|
||||
|
||||
|
||||
#### Phase 8e: Multi-Step Slugify Scenarios (round-trip proofs)
|
||||
|
||||
Multi-step sequences that exercise the slugify system through state transitions, with round-trip verification at each step. These are the highest-value tests for catching subtle bugs.
|
||||
|
||||
|
||||
| # | Test Name | What It Tests | Round-Trip |
|
||||
| --- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| 51 | `slugify_rename_chain` | Multi-step: (1) Edit `What_Module.luau` content, (2) Update meta name `What?Module` -> `What*Module`, (3) Delete meta (name reverts to `What_Module`), (4) Re-create meta with `{"name": "What/Module"}`. Four patches, four state snapshots. Round-trip verified at each step. | Yes (each step) |
|
||||
| 52 | `collision_evolution` | Multi-step: (1) Add `Hey_Bro~2.luau` + meta `{"name": "Hey*Bro"}` (third collision), (2) Delete `Hey_Bro~1.luau` + meta (remove `Hey/Bro`), (3) Add `Hey_Bro~1.luau` + meta with different name `{"name": "Hey | Bro"}`(reuse the`~1` slot). Three patches. Verify correct instances exist at each step. |
|
||||
| 53 | `clean_to_slug_to_clean_via_filesystem` | Multi-step: (1) `Normal.luau` exists (clean name). Add `Normal.meta.json5` with `{"name": "Nor:mal"}` -- name changes to `Nor:mal`. (2) Delete the meta -- name reverts to `Normal`. (3) Rename `Normal.luau` -> `Has_Slash.luau` + create `Has_Slash.meta.json5` with `{"name": "Has/Slash"}` -- completely different slug+name. Round-trip verified at each step. | Yes (each step) |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Execution Order
|
||||
|
||||
1. **Fixtures**: Create 4 new fixture directories: `connected_scripts`, `connected_init`, `connected_models`, `connected_slugify`
|
||||
2. **Harness**: Add `get_message_cursor`, `NormalizedTree`/`NormalizedInstance`, `fresh_rebuild_read`, `assert_round_trip` to [tests/rojo_test/serve_util.rs](tests/rojo_test/serve_util.rs)
|
||||
3. **Phase 1** (format transitions): Highest value, most likely to catch bugs in the snapshot pipeline
|
||||
4. **Phase 2** (multi-step sequences): Core connected mode pattern, validates cursor chaining
|
||||
5. **Phase 3** (init file ops): Critical for directory handling correctness
|
||||
6. **Phases 4-7**: Property/meta, model JSON, cursor tracking, mixed API+filesystem
|
||||
7. **Phase 8a-e** (slugify/dedup): 22 tests covering the entire forward-sync direction of the name system, with round-trip proofs
|
||||
8. **Run**: `cargo test`, then `cargo insta review` to accept snapshots
|
||||
9. **Verify**: All tests pass on a clean run, no snapshot drift
|
||||
|
||||
---
|
||||
|
||||
## Notes on Test Design
|
||||
|
||||
### Filesystem events are OS-dependent
|
||||
|
||||
Some operations (like rename) may fire different events on macOS (kqueue/FSEvents) vs Windows (ReadDirectoryChangesW) vs Linux (inotify). Tests that depend on exact patch structure may need `#[cfg_attr(target_os = "macos", ignore)]` annotations if event ordering differs. The existing `ref_properties_remove` test already does this.
|
||||
|
||||
### The snapshot system may not auto-convert formats
|
||||
|
||||
When adding a child file next to a standalone script via the filesystem, the snapshot system may NOT automatically convert to directory format (that conversion might only happen through the change processor during API writes). Tests should verify and document the actual behavior rather than assuming conversion happens.
|
||||
|
||||
### Echo suppression is critical for mixed tests
|
||||
|
||||
Phase 7 tests depend on echo suppression working correctly. The API write path suppresses VFS events for paths it wrote, preventing feedback loops. If echo suppression is broken, these tests will see duplicate patches.
|
||||
|
|
@ -229,22 +229,26 @@ All in [tests/tests/two_way_sync.rs](tests/tests/two_way_sync.rs) and [tests/tes
|
|||
|
||||
### Files created
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/syncback/meta.rs` | Shared `upsert_meta_name()` and `remove_meta_name()` -- pure JSON operations without filesystem event suppression. Returns `RemoveNameOutcome` enum so callers handle suppression themselves. |
|
||||
| `rojo-test/build-tests/tilde_no_meta/` | Build fixture: `Foo~1.luau` with no meta file. Verifies forward sync produces instance name `Foo~1`. |
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/syncback/meta.rs` | Shared `upsert_meta_name()` and `remove_meta_name()` -- pure JSON operations without filesystem event suppression. Returns `RemoveNameOutcome` enum so callers handle suppression themselves. |
|
||||
| `rojo-test/build-tests/tilde_no_meta/` | Build fixture: `Foo~1.luau` with no meta file. Verifies forward sync produces instance name `Foo~1`. |
|
||||
|
||||
|
||||
### Files modified
|
||||
|
||||
| File | What changed |
|
||||
|------|-------------|
|
||||
| `src/syncback/file_names.rs` | Added `KNOWN_SCRIPT_SUFFIXES`, `strip_script_suffix()`, `adjacent_meta_path()`. Added 19 new unit tests (suffix stripping, meta path derivation, tilde collision, stress, idempotency). |
|
||||
| `src/syncback/mod.rs` | Added `pub mod meta;`. Exported `adjacent_meta_path`. Removed `deny_unknown_fields` from `SyncbackRules`. |
|
||||
| `src/change_processor.rs` | Rewrote `upsert_meta_name_field` / `remove_meta_name_field` to delegate to shared `syncback::meta` module. **C1 fix**: moved meta name update logic OUTSIDE `if new_path != *path` for both init-file and regular-file rename paths. Introduced `effective_dir_path` / `effective_meta_base` tracking variables. |
|
||||
| `src/web/api.rs` | **C2 fix**: `syncback_removed_instance` now uses `adjacent_meta_path()` instead of `instance_name` for meta cleanup. **CC2 fix**: init files trigger `remove_dir_all` on the parent directory + grandparent dir-level meta cleanup. Removed unused `instance_name` variable. Fixed misleading comment. |
|
||||
| `src/snapshot_middleware/project.rs` | **CC1 fix**: collected `new_child_map.drain()` into sorted `Vec` before processing, ensuring deterministic dedup ordering. |
|
||||
| `tests/tests/build.rs` | Added `tilde_no_meta` to `gen_build_tests!` macro. |
|
||||
| `tests/tests/two_way_sync.rs` | Added 2 integration tests: `add_instance_with_forbidden_chars_creates_slug_and_meta`, `add_two_colliding_instances_deduplicates`. |
|
||||
|
||||
| File | What changed |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/syncback/file_names.rs` | Added `KNOWN_SCRIPT_SUFFIXES`, `strip_script_suffix()`, `adjacent_meta_path()`. Added 19 new unit tests (suffix stripping, meta path derivation, tilde collision, stress, idempotency). |
|
||||
| `src/syncback/mod.rs` | Added `pub mod meta;`. Exported `adjacent_meta_path`. Removed `deny_unknown_fields` from `SyncbackRules`. |
|
||||
| `src/change_processor.rs` | Rewrote `upsert_meta_name_field` / `remove_meta_name_field` to delegate to shared `syncback::meta` module. **C1 fix**: moved meta name update logic OUTSIDE `if new_path != *path` for both init-file and regular-file rename paths. Introduced `effective_dir_path` / `effective_meta_base` tracking variables. |
|
||||
| `src/web/api.rs` | **C2 fix**: `syncback_removed_instance` now uses `adjacent_meta_path()` instead of `instance_name` for meta cleanup. **CC2 fix**: init files trigger `remove_dir_all` on the parent directory + grandparent dir-level meta cleanup. Removed unused `instance_name` variable. Fixed misleading comment. |
|
||||
| `src/snapshot_middleware/project.rs` | **CC1 fix**: collected `new_child_map.drain()` into sorted `Vec` before processing, ensuring deterministic dedup ordering. |
|
||||
| `tests/tests/build.rs` | Added `tilde_no_meta` to `gen_build_tests!` macro. |
|
||||
| `tests/tests/two_way_sync.rs` | Added 2 integration tests: `add_instance_with_forbidden_chars_creates_slug_and_meta`, `add_two_colliding_instances_deduplicates`. |
|
||||
|
||||
|
||||
### Test results
|
||||
|
||||
|
|
@ -255,10 +259,8 @@ All in [tests/tests/two_way_sync.rs](tests/tests/two_way_sync.rs) and [tests/tes
|
|||
|
||||
### Key design decisions
|
||||
|
||||
1. **`meta.rs` returns outcomes, doesn't suppress** -- The shared `upsert_meta_name` / `remove_meta_name` functions are pure I/O (read JSON, modify, write/delete). Filesystem event suppression stays in the caller (`change_processor.rs`) because it depends on the `JobThreadContext` API. This keeps the shared module framework-agnostic.
|
||||
1. `**meta.rs` returns outcomes, doesn't suppress** -- The shared `upsert_meta_name` / `remove_meta_name` functions are pure I/O (read JSON, modify, write/delete). Filesystem event suppression stays in the caller (`change_processor.rs`) because it depends on the `JobThreadContext` API. This keeps the shared module framework-agnostic.
|
||||
2. `**effective_dir_path` / `effective_meta_base` pattern** -- For the C1 fix, rather than duplicating the meta update logic in both branches of the path-change condition, we track which path ended up being used (original or renamed) and run the meta update once afterward. This avoids the "nested inside conditional" bug class entirely.
|
||||
3. `**adjacent_meta_path` derives from filesystem name** -- The helper strips the file extension and script suffix from the actual filesystem path, never from the instance name. This ensures the meta path always matches the script file regardless of slugification.
|
||||
4. **Alphabetical sort for project.rs determinism** -- Chose alphabetical sort over referent-ID sort because it's human-predictable and matches what a developer would expect when looking at the filesystem.
|
||||
|
||||
2. **`effective_dir_path` / `effective_meta_base` pattern** -- For the C1 fix, rather than duplicating the meta update logic in both branches of the path-change condition, we track which path ended up being used (original or renamed) and run the meta update once afterward. This avoids the "nested inside conditional" bug class entirely.
|
||||
|
||||
3. **`adjacent_meta_path` derives from filesystem name** -- The helper strips the file extension and script suffix from the actual filesystem path, never from the instance name. This ensures the meta path always matches the script file regardless of slugification.
|
||||
|
||||
4. **Alphabetical sort for project.rs determinism** -- Chose alphabetical sort over referent-ID sort because it's human-predictable and matches what a developer would expect when looking at the filesystem.
|
||||
130
.cursor/plans/fix_stale_tree_desync_9e74f718.plan.md
Normal file
130
.cursor/plans/fix_stale_tree_desync_9e74f718.plan.md
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
name: Fix stale tree desync
|
||||
overview: Add a tree validation mechanism that re-snapshots the project tree from the real filesystem when a plugin connects, ensuring the in-memory RojoTree never serves stale data even when VFS watcher events are missed.
|
||||
todos:
|
||||
- id: validate-method
|
||||
content: Add `validate_tree()` method to ServeSession that re-snapshots from disk and patches the tree
|
||||
status: completed
|
||||
- id: trigger-on-connect
|
||||
content: Call `validate_tree()` in the WebSocket connection handler before serving data
|
||||
status: completed
|
||||
- id: broadcast-corrections
|
||||
content: Push any corrections from validation to the message queue for all connected plugins
|
||||
status: completed
|
||||
- id: test-validation
|
||||
content: "Test: start serve, externally delete/modify a tracked file, connect plugin, verify tree is corrected"
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
# Fix Stale Tree Desync on Plugin Connect
|
||||
|
||||
## Problem
|
||||
|
||||
When a plugin connects (especially in one-shot mode), the server's in-memory `RojoTree` can be stale if the VFS file watcher missed external filesystem changes. The tree is built once on startup and only updated via VFS events through `ChangeProcessor`. If events are missed (watcher buffer overflow, rapid batch operations like git, OS-level quirks), the tree silently drifts from reality. The plugin reads this stale tree via `GET /api/read/:id`, computes its diff against Studio, and sees "no changes" even though the filesystem is different.
|
||||
|
||||
Rebooting `rojo serve` forces a fresh read and fixes it, confirming the issue is purely stale in-memory state.
|
||||
|
||||
## Solution: Tree Validation on Plugin Connect
|
||||
|
||||
When a new plugin connection is established (WebSocket handshake), trigger a validation pass that re-snapshots the root project path from the real filesystem, diffs against the current tree, and applies any corrections. This is essentially what `ServeSession::new` does on startup, but incremental and without restart.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `[src/serve_session.rs](src/serve_session.rs)` -- Owns the tree, VFS, and project. Add `validate_tree()` method here.
|
||||
- `[src/change_processor.rs](src/change_processor.rs)` -- Has `apply_patches()` which re-snapshots a path and patches the tree. Reuse this pattern.
|
||||
- `[src/web/api.rs](src/web/api.rs)` -- WebSocket connection handler. Call validation before serving the tree.
|
||||
- `[src/snapshot/patch_compute.rs](src/snapshot/patch_compute.rs)` -- `compute_patch_set` used to diff snapshots.
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Add `validate_tree()` to ServeSession
|
||||
|
||||
In `[src/serve_session.rs](src/serve_session.rs)`, add a public method that:
|
||||
|
||||
- Takes the root project's start path (`self.root_project.folder_location()`)
|
||||
- Locks the VFS and tree
|
||||
- Calls `snapshot_from_vfs(&instance_context, &vfs, start_path)` to get a fresh snapshot from disk
|
||||
- Calls `compute_patch_set(snapshot, &tree, root_id)` to diff against the current tree
|
||||
- If the patch is non-empty, calls `apply_patch_set(&mut tree, patch_set)` and pushes the result to the message queue
|
||||
|
||||
This reuses the same functions that `ServeSession::new` uses for initial tree construction. The VFS reads directly from the filesystem (no cache), so the snapshot will reflect the real state.
|
||||
|
||||
A compile-time constant at the top of `serve_session.rs` controls whether validation runs:
|
||||
|
||||
```rust
|
||||
/// Set to `false` to skip tree validation on plugin connect (useful for testing).
|
||||
const VALIDATE_TREE_ON_CONNECT: bool = true;
|
||||
```
|
||||
|
||||
The method itself:
|
||||
|
||||
```rust
|
||||
pub fn validate_tree(&self) -> Vec<AppliedPatchSet> {
|
||||
if !VALIDATE_TREE_ON_CONNECT {
|
||||
log::debug!("Tree validation skipped (VALIDATE_TREE_ON_CONNECT = false)");
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let start = std::time::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 validation snapshot error: {:?}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
let mut tree = self.tree.lock().unwrap();
|
||||
let root_id = tree.get_root_id();
|
||||
let patch_set = compute_patch_set(snapshot, &tree, root_id);
|
||||
|
||||
if patch_set.is_empty() {
|
||||
log::info!("Tree validation complete (no corrections needed) in {:.1?}", start.elapsed());
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
log::info!("Tree validation found stale state, applying corrections");
|
||||
let applied = apply_patch_set(&mut tree, patch_set);
|
||||
log::info!("Tree validation complete in {:.1?}", start.elapsed());
|
||||
vec![applied]
|
||||
}
|
||||
```
|
||||
|
||||
Note: The `Vfs` struct wraps the backend with a mutex internally (`self.inner.lock()`), so we access it via `self.vfs` (which is `Arc<Vfs>`) without needing an explicit lock. The exact locking pattern will follow the existing code style.
|
||||
|
||||
### 2. Trigger Validation on WebSocket Connect
|
||||
|
||||
In `[src/web/api.rs](src/web/api.rs)`, the WebSocket handler establishes a new connection. Before entering the message loop (or right after the handshake), call `validate_tree()` and broadcast any corrections:
|
||||
|
||||
```rust
|
||||
// In the WebSocket handler, after handshake:
|
||||
let corrections = self.serve_session.validate_tree();
|
||||
if !corrections.is_empty() {
|
||||
self.serve_session.message_queue().push_messages(&corrections);
|
||||
}
|
||||
```
|
||||
|
||||
This ensures the tree is consistent with disk before the plugin starts receiving data.
|
||||
|
||||
### 3. Broadcast Corrections to Already-Connected Plugins
|
||||
|
||||
If multiple plugins are connected, the corrections are pushed to the message queue, which broadcasts to all connected WebSocket clients. This ensures all plugins get the updated state.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Large projects**: Full re-snapshot could add 100-500ms latency on connect for very large projects. This is acceptable for one-shot mode (happens once) and live mode (happens on reconnect). Can be optimized later with targeted path validation if needed.
|
||||
- **Concurrent access**: The tree lock is held during validation. Other WebSocket handlers will block briefly. This matches the existing pattern in `ChangeProcessor::apply_patches`.
|
||||
- **No changes**: If the tree is already fresh (common case), `compute_patch_set` returns an empty patch and no work is done. The overhead is one `snapshot_from_vfs` call (directory walk + file stats).
|
||||
- **VFS consistency**: After validation, VFS events for the re-snapshotted paths will still fire (watches persist). The `compute_and_apply_changes` handler will process them as no-ops since the tree already matches.
|
||||
|
||||
## What This Fixes
|
||||
|
||||
After this change, "no changes appearing" on plugin reconnect should not happen regardless of whether VFS events were missed. The tree is always refreshed from disk on connect, matching the behavior of a fresh `rojo serve` startup.
|
||||
|
||||
## What This Does NOT Fix
|
||||
|
||||
The content "swap" issue (both directions firing during per-item selection) is a separate bug that needs further investigation. The stale tree fix ensures that even if a swap occurs, the developer can immediately reconnect and see the correct diff to recover.
|
||||
Loading…
Add table
Add a link
Reference in a new issue