Implement comprehensive fixes for sync system issues

- Introduced a new plan to address eight identified issues in the sync system, including improvements to standalone script handling, property persistence, and file watcher behavior.
- Key changes include extracting standalone-to-directory conversion logic, enhancing property update persistence, and implementing a recovery mechanism for rapid delete+recreate scenarios.
- Added integration tests to ensure coverage for each fix and improve overall system reliability.
- Enhanced logging for VFS events to aid in debugging file watcher desynchronization issues.

This commit significantly improves the robustness of the sync system and addresses critical issues identified in the audit.
This commit is contained in:
Joe 2026-02-05 18:25:47 -06:00
parent 4a03d4bab5
commit 0968e74208
5 changed files with 1651 additions and 319 deletions

View file

@ -0,0 +1,553 @@
---
name: Fix sync system issues
overview: Fix all 8 issues identified in the api.rs sync system audit — from standalone script child placement to file watcher echo suppression — across src/web/api.rs, src/change_processor.rs, and src/serve_session.rs.
todos:
- id: issue-1-standalone-conversion
content: "Issue 1: Extract standalone-to-directory conversion into shared helper and use in syncback_added_instance when parent is a standalone script file"
status: completed
- id: issue-2-property-persistence
content: "Issue 2: Add syncback_updated_properties method to write non-Source property changes to meta/model files in handle_api_write"
status: completed
- id: issue-3-path-in-format
content: "Issue 3: Make ExistingFileFormat carry detected PathBuf and use it when writing to preserve .model.json vs .model.json5"
status: completed
- id: issue-4-stale-removals
content: "Issue 4: Include removed IDs in PatchSet and add existence guard in ChangeProcessor to avoid double-delete errors"
status: completed
- id: issue-5-lock-splitting
content: "Issue 5: Split handle_api_write into gather phase (with lock) and execute phase (without lock) using SyncbackAction enum"
status: completed
- id: issue-6-project-parents
content: "Issue 6: Handle InstigatingSource::ProjectNode by resolving node.path relative to project file directory"
status: completed
- id: issue-7-echo-suppression
content: "Issue 7: Add suppressed_paths set to ServeSession, populate before writes, check in handle_vfs_event"
status: completed
- id: issue-8-rename-classname
content: "Issue 8: Implement file rename for name changes and extension rename for script class changes in ChangeProcessor"
status: completed
- id: issue-9-filewatcher-desync
content: "Issue 9 [CRITICAL]: Fix rapid delete+recreate desync — add recovery mechanism after Remove events, retry canonicalize on failure, stop unwatching on Remove, and add pedantic INFO-level logging for ALL file watcher events and decisions"
status: completed
- id: testing
content: Add integration tests in tests/tests/serve.rs covering each fix
status: completed
isProject: false
---
# Fix All Sync System Issues
## Data Flow Context
```mermaid
sequenceDiagram
participant Plugin as Studio_Plugin
participant API as handle_api_write
participant FS as Filesystem
participant FW as FileWatcher_VFS
participant CP as ChangeProcessor
participant Tree as RojoTree
participant MQ as MessageQueue
Plugin->>API: POST /api/write
Note over API: syncback_added_instance
API->>FS: Create files
Note over API: syncback_removed_instance
API->>FS: Delete files
API->>CP: PatchSet (updates only)
CP->>FS: Write Source to file
CP->>Tree: apply_patch_set
CP->>MQ: AppliedPatchSet
FW->>CP: VfsEvent (echo!)
CP->>Tree: Re-snapshot and patch
CP->>MQ: Redundant AppliedPatchSet
MQ->>Plugin: WebSocket messages
```
All fixes target eliminating the problems in this flow.
---
## Issue 1 — [HIGH] Standalone script children created as siblings
**File:** [src/web/api.rs](src/web/api.rs) — `syncback_added_instance` (line ~528)
**Problem:** When a new child is added to a standalone script parent (e.g., `MyModule.luau`), the parent path resolves to the directory containing the script, so the child is created as a sibling instead of inside a `MyModule/` directory.
**Fix:** After resolving `parent_path` and finding it is a file (not a directory), check if the parent is a script class. If so, convert it to directory format before creating the child:
1. Create directory `parent_dir/ParentName/`
2. Move `ParentName.luau` to `ParentName/init.luau` (with correct suffix for Script/LocalScript)
3. Delete the old standalone file
4. Remove any adjacent `.meta.json5` (move content to `init.meta.json5` inside the new dir)
5. Set `parent_dir` to the new directory
6. Proceed with normal child creation
The conversion logic already exists in `syncback_update_existing_instance` (lines 620-670). Extract it into a shared helper:
```rust
fn convert_standalone_to_directory(
&self,
parent_path: &Path,
parent_name: &str,
class_name: &str,
) -> anyhow::Result<PathBuf>
```
---
## Issue 2 — [HIGH] Non-Source property updates not persisted to disk
**Files:** [src/web/api.rs](src/web/api.rs) — `handle_api_write` (line ~300), [src/change_processor.rs](src/change_processor.rs) — `handle_tree_event` (line ~299)
**Problem:** `ChangeProcessor::handle_tree_event` only writes `Source` to disk. All other property changes (Attributes, etc.) are applied to the in-memory tree only and lost on restart.
**Fix:** In `handle_api_write`, before sending the PatchSet, process non-Source property changes for each `updated_instance`:
1. Look up the instance in the tree to get its `instigating_source` path
2. Determine the meta file path:
- Scripts in directories: `dir/init.meta.json5`
- Standalone scripts: `parent/Name.meta.json5`
- Model files: update the `.model.json5` directly
3. Read existing meta file (if any), merge changed properties, write back
4. Reuse existing helpers: `filter_properties_for_meta`, `build_meta_object`, `variant_to_json`
Add a new method to `ApiService`:
```rust
fn syncback_updated_properties(
&self,
update: &InstanceUpdate,
tree: &RojoTree,
) -> anyhow::Result<()>
```
This processes properties **other than Source** (Source is handled by ChangeProcessor already). The method:
- Gets the instance from the tree
- Gets `instigating_source.path()`
- Determines if it's a directory or standalone file
- Reads existing meta/model file, merges changes, writes back
---
## Issue 3 — [MEDIUM] Potential `.model.json` / `.model.json5` duplicates
**File:** [src/web/api.rs](src/web/api.rs) — `ExistingFileFormat` enum and `detect_existing_script_format` (line ~864), `syncback_instance_to_path_with_stats` (line ~1362)
**Problem:** `detect_existing_script_format` returns just `Standalone` without the actual file path. New files are always created as `.model.json5`, potentially alongside an existing `.model.json`.
**Fix:** Make `ExistingFileFormat` carry the detected path:
```rust
enum ExistingFileFormat {
None,
Standalone(PathBuf), // was: Standalone
Directory(PathBuf), // was: Directory
}
```
Update `detect_existing_script_format` to return the actual path found. Update all match arms in `syncback_instance_to_path_with_stats` to use the carried path when writing to an existing file:
- For `Standalone(path)`: write to `path` instead of always generating `.model.json5`
- For `Directory(path)`: use `path` for the directory
---
## Issue 4 — [MEDIUM] Removed instances leave stale window in tree
**File:** [src/web/api.rs](src/web/api.rs) — `handle_api_write` (line ~334), [src/change_processor.rs](src/change_processor.rs) — `handle_tree_event` (line ~249)
**Problem:** `handle_api_write` sends `removed_instances: Vec::new()` even when removals happened. The tree only updates when the file watcher detects deletions.
**Fix:**
1. In `handle_api_write`, include the removed IDs in the PatchSet:
```rust
tree_mutation_sender
.send(PatchSet {
removed_instances: request.removed.clone(),
added_instances: Vec::new(),
updated_instances,
})
```
1. In `ChangeProcessor::handle_tree_event`, guard file deletion with an existence check to avoid spurious error logs when files were already deleted by `syncback_removed_instance`:
```rust
InstigatingSource::Path(path) => {
if path.exists() {
log::info!("Two-way sync: Removing file {}", path.display());
if let Err(err) = fs::remove_file(path) { ... }
} else {
log::trace!("File already removed: {}", path.display());
}
}
```
Also handle directory removal (currently only `fs::remove_file`, should also check `fs::remove_dir_all`).
---
## Issue 5 — [MEDIUM] Tree lock held during filesystem I/O
**File:** [src/web/api.rs](src/web/api.rs) — `handle_api_write` (line ~277)
**Problem:** The tree mutex is held during all synchronous filesystem operations in `syncback_added_instance`, blocking other threads.
**Fix:** Split the operation into two phases:
**Phase 1 — Gather (with lock):** Collect all information needed from the tree:
- Parent paths for each added instance
- Existing child lookups (for dedup)
- Duplicate sibling cache
- Instigating source paths for removed instances
Store results in a `Vec<SyncbackAction>` enum:
```rust
enum SyncbackAction {
CreateNew { added: AddedInstance, parent_dir: PathBuf },
UpdateExisting { added: AddedInstance, existing_path: PathBuf },
Remove { path: PathBuf, is_dir: bool },
Skip { name: String, reason: String },
}
```
**Phase 2 — Execute (without lock):** Release tree lock, then execute all filesystem operations from the collected actions.
---
## Issue 6 — [LOW] Can't add children to project-defined parents
**File:** [src/web/api.rs](src/web/api.rs) — `syncback_added_instance` (line ~520)
**Problem:** When parent's `instigating_source` is `InstigatingSource::ProjectNode`, `.path()` returns the project file path, not the filesystem directory for children.
**Fix:** After getting the instigating source, handle `ProjectNode` separately:
```rust
let parent_dir = match instigating_source {
InstigatingSource::Path(path) => {
if path.is_dir() { path.clone() } else { path.parent().unwrap().to_path_buf() }
}
InstigatingSource::ProjectNode { path: project_path, node, .. } => {
if let Some(path_node) = &node.path {
let fs_path = path_node.path();
let resolved = if fs_path.is_relative() {
project_path.parent().unwrap().join(fs_path)
} else {
fs_path.to_path_buf()
};
if resolved.is_dir() { resolved } else { resolved.parent().unwrap().to_path_buf() }
} else {
anyhow::bail!(
"Cannot add '{}' - parent '{}' is defined in project file without $path",
added.name, name
);
}
}
};
```
This uses the `node.path` (`$path` field from [src/project.rs](src/project.rs) `ProjectNode` struct, line ~372) resolved relative to the project file directory.
---
## Issue 7 — [LOW] File watcher echo after syncback
**Files:** [src/serve_session.rs](src/serve_session.rs), [src/change_processor.rs](src/change_processor.rs), [src/web/api.rs](src/web/api.rs)
**Problem:** When the API writes files to disk, the file watcher picks them up and creates redundant patches.
**Fix:** Add a write-suppression set shared between API and ChangeProcessor:
1. Add to `ServeSession`:
```rust
suppressed_paths: Arc<Mutex<HashSet<PathBuf>>>
```
1. In `syncback_added_instance` / `syncback_removed_instance` / `syncback_instance_to_path_with_stats`, before each `fs::write` / `fs::remove_*` / `fs::create_dir_all`, add the path to the suppression set.
2. In `ChangeProcessor::handle_vfs_event`, check the suppression set before processing:
```rust
fn handle_vfs_event(&self, event: VfsEvent) {
let event_path = match &event {
VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => p.clone(),
};
// Check and remove from suppression set (one-shot suppression)
{
let mut suppressed = self.suppressed_paths.lock().unwrap();
if suppressed.remove(&event_path) {
self.vfs.commit_event(&event).expect("Error applying VFS change");
log::trace!("Suppressed echo event for {}", event_path.display());
return;
}
}
// ... normal processing ...
}
```
Still commit the event to VFS (so VFS stays consistent), but skip patch computation and message broadcasting.
---
## Issue 8 — [INFO] Rename/ClassName persistence
**File:** [src/change_processor.rs](src/change_processor.rs) — `handle_tree_event` (line ~287)
**Problem:** Name and ClassName changes are logged as warnings and only applied to in-memory tree.
**Fix for renames:** Replace `log::warn!("Cannot rename instances yet.")` with:
1. Get instance's `instigating_source` path
2. Compute new path based on new name (preserve extension/format)
3. Rename file or directory on disk (`fs::rename`)
4. Update any adjacent meta file name (e.g., `OldName.meta.json5` -> `NewName.meta.json5`)
**Fix for ClassName changes:** Replace `log::warn!("Cannot change ClassName yet.")` with:
1. For script classes: compute old and new file extensions
2. Rename the file (e.g., `Foo.server.luau` -> `Foo.client.luau`)
3. For non-script to script or vice versa: convert between `.model.json5` and `.luau` formats
ClassName changes are complex — scope the initial implementation to script class transitions only (Script <-> LocalScript, etc.) and log a warning for cross-category changes (script to non-script).
---
## Issue 9 — [CRITICAL] File watcher loses track of files after rapid delete+recreate
**Files:** [src/change_processor.rs](src/change_processor.rs), [crates/memofs/src/lib.rs](crates/memofs/src/lib.rs), [crates/memofs/src/std_backend.rs](crates/memofs/src/std_backend.rs)
**Reproduction:** Cursor agent modifies a file, user clicks "undo", file is deleted and immediately recreated. Rojo thinks the file is deleted even though it exists on disk. Restarting Rojo serve fixes it.
**Root cause analysis — three contributing factors:**
```mermaid
sequenceDiagram
participant Editor as Cursor_Editor
participant FS as Filesystem
participant Notify as notify_debouncer
participant CP as ChangeProcessor
participant Tree as RojoTree
Editor->>FS: Delete file (undo step 1)
Editor->>FS: Recreate file (undo step 2)
Note over FS: File exists on disk
Notify->>CP: VfsEvent::Remove
Note over Notify: Create event LOST
CP->>CP: commit_event: unwatch(path)
CP->>CP: vfs.metadata(path) = NotFound
Note over CP: File doesnt exist YET
CP->>Tree: remove instance
Note over Tree: Instance gone forever
Note over FS: File reappears on disk
Note over CP: No Create event arrives
```
**Factor 1: `commit_event(Remove)` unwatches the path** ([crates/memofs/src/lib.rs](crates/memofs/src/lib.rs) line 239)
```rust
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
if let VfsEvent::Remove(path) = event {
let _ = self.backend.unwatch(path); // <-- problematic
}
Ok(())
}
```
If the removed path was directly watched (in the `watches` set), it gets unwatched from `notify`. When the path is recreated, the parent's recursive watch *might* cover it, but on Windows this is unreliable for detecting the recreation event.
**Factor 2: Create/Write events silently dropped when `canonicalize` fails** ([src/change_processor.rs](src/change_processor.rs) line 183)
```rust
Err(_) => {
log::trace!( // <-- INVISIBLE at normal log levels
"Skipping create/write event for {:?} - path no longer exists",
path
);
Vec::new()
}
```
On Windows, `canonicalize` calls `GetFinalPathNameByHandleW`, which can fail if the file is still being written/locked by another process. The Create event is silently dropped at `trace` level — completely invisible.
**Factor 3: Genuine race condition between file operations and event processing.** The `notify_debouncer_full` with 50ms timeout can coalesce a rapid Remove+Create into just a Remove on Windows if `ReadDirectoryChangesW` misses the recreation or the debouncer's cache only tracks the removal.
**Fix — four layers of defense:**
### Layer 1: Pedantic INFO-level logging for ALL file watcher decisions
Change ALL event and decision logging in `handle_vfs_event` from `debug`/`trace` to `info`:
```rust
fn handle_vfs_event(&self, event: VfsEvent) {
// Log EVERY event at INFO for diagnostics
match &event {
VfsEvent::Create(path) => log::info!("VFS event: CREATE {}", path.display()),
VfsEvent::Write(path) => log::info!("VFS event: WRITE {}", path.display()),
VfsEvent::Remove(path) => log::info!("VFS event: REMOVE {}", path.display()),
_ => log::info!("VFS event: OTHER {:?}", event),
}
// ...
// Log EVERY decision point:
// - "Canonicalize succeeded: {path}" / "Canonicalize FAILED: {path} - {err}"
// - "apply_patches found IDs: {ids:?} at path: {path}"
// - "compute_and_apply_changes: path exists={exists}, result={added/removed/updated}"
// - "Patch applied: {summary}"
}
```
Also log at INFO in `compute_and_apply_changes`:
- Whether `vfs.metadata(path)` found the file or not
- Whether the snapshot produced changes
- What instances were added/removed/updated
### Layer 2: Stop unwatching paths on Remove events
In [crates/memofs/src/lib.rs](crates/memofs/src/lib.rs), remove the unwatch from `commit_event`:
```rust
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
// NOTE: We intentionally do NOT unwatch on Remove.
// The path may be recreated immediately (editor undo), and unwatching
// causes future events to be missed. Stale watches are harmless —
// notify silently ignores events for non-existent paths, and the
// watch will be cleaned up when the parent is unwatched.
Ok(())
}
```
This is safe because:
- `notify` silently handles watches on non-existent paths (no errors)
- The parent directory's recursive watch provides the actual coverage
- Stale entries in the `watches` set are harmless overhead
### Layer 3: Recovery check after Remove events that cause instance removal
In `compute_and_apply_changes` ([src/change_processor.rs](src/change_processor.rs) line 391), after the instance would be removed, verify the path doesn't exist on the REAL filesystem (bypassing any VFS state):
```rust
Ok(None) => {
// Path not found in VFS. Before removing, verify it's truly gone
// from the REAL filesystem. On Windows, rapid delete+recreate can
// cause Remove events where the file has already been recreated.
if std::fs::metadata(path).is_ok() {
log::info!(
"Recovery: path {} was reported removed but exists on disk. Re-snapshotting.",
path.display()
);
// File was recreated — re-snapshot instead of removing
let snapshot = match snapshot_from_vfs(&metadata.context, vfs, path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!("Recovery snapshot error: {:?}", err);
return None;
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
apply_patch_set(tree, patch_set)
} else {
// Path is genuinely gone
let mut patch_set = PatchSet::new();
patch_set.removed_instances.push(id);
apply_patch_set(tree, patch_set)
}
}
```
Note: This uses `std::fs::metadata` directly (not `vfs.metadata`) because the VFS may have stale state after `commit_event(Remove)` was called. The real filesystem is the source of truth.
### Layer 4: Retry canonicalize failures for Create/Write events
In `handle_vfs_event`, when `canonicalize` fails for a Create/Write event, retry with a short delay instead of silently dropping:
```rust
VfsEvent::Create(path) | VfsEvent::Write(path) => {
match self.vfs.canonicalize(&path) {
Ok(canonical_path) => self.apply_patches(canonical_path),
Err(err) => {
log::info!(
"VFS: canonicalize failed for {} ({}), retrying...",
path.display(), err
);
// Retry after brief delay — file may still be locked by editor
std::thread::sleep(std::time::Duration::from_millis(50));
match self.vfs.canonicalize(&path) {
Ok(canonical_path) => {
log::info!("VFS: retry succeeded for {}", path.display());
self.apply_patches(canonical_path)
}
Err(err2) => {
log::warn!(
"VFS: Create/Write event DROPPED for {} after retry — \
canonicalize failed: {}. File may be desynchronized!",
path.display(), err2
);
Vec::new()
}
}
}
}
}
```
### Layer 5 (post-Remove sweep): After processing a Remove event that caused instance removal, schedule a filesystem re-check
Add a `pending_recovery` field to `JobThreadContext`:
```rust
struct JobThreadContext {
tree: Arc<Mutex<RojoTree>>,
vfs: Arc<Vfs>,
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
/// Paths recently removed that should be re-checked after a delay
pending_recovery: Mutex<Vec<(PathBuf, std::time::Instant)>>,
}
```
After each event is processed in the `select!` loop, check `pending_recovery` for paths older than 200ms. For each, verify the path on the real filesystem and trigger a re-snapshot if it exists:
```rust
loop {
select! {
recv(vfs_receiver) -> event => {
task.handle_vfs_event(event?);
task.process_pending_recoveries();
},
// ... other arms ...
default(Duration::from_millis(500)) => {
// Periodic check even when no events arrive
task.process_pending_recoveries();
}
}
}
```
This catches the case where the Remove event was processed correctly (file was genuinely gone at that moment), but the file was recreated shortly after, and no Create event was generated.
---
## Testing Strategy
- Add integration tests in [tests/tests/serve.rs](tests/tests/serve.rs) for each fix
- Issue 1: Test adding a child to a standalone script via the write API, verify directory conversion
- Issue 2: Test property update persistence, verify meta file creation
- Issue 3: Test with existing `.model.json` files, verify no duplicates created
- Issue 4: Test removal appears in tree immediately after write response
- Issue 7: Test that syncback doesn't cause redundant WebSocket messages
- Issue 9: Test rapid delete+recreate of files, verify instance survives in tree. Test with both individual files and directories. Verify INFO logs capture all events.
- Use existing test utilities from [tests/rojo_test/serve_util.rs](tests/rojo_test/serve_util.rs)
- Existing stress tests in [crates/memofs/src/std_backend.rs](crates/memofs/src/std_backend.rs) (`stress_create_delete_recreate_cycles`, `stress_undo_redo_simulation`) provide unit-level coverage for the debouncer

View file

@ -236,10 +236,13 @@ impl VfsInner {
}
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
if let VfsEvent::Remove(path) = event {
let _ = self.backend.unwatch(path);
}
// NOTE: We intentionally do NOT unwatch on Remove events.
// The path may be recreated immediately (e.g., editor undo), and
// unwatching causes future events for the recreated path to be missed.
// Stale watches are harmless — notify silently ignores events for
// non-existent paths, and the watch will be cleaned up when the
// parent is unwatched.
let _ = event;
Ok(())
}
}

View file

@ -3,6 +3,7 @@ use jod_thread::JoinHandle;
use memofs::{IoResultExt, Vfs, VfsEvent};
use rbx_dom_weak::types::{Ref, Variant};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::{
fs,
sync::{Arc, Mutex},
@ -51,6 +52,7 @@ impl ChangeProcessor {
vfs: Arc<Vfs>,
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
tree_mutation_receiver: Receiver<PatchSet>,
suppressed_paths: Arc<Mutex<std::collections::HashSet<PathBuf>>>,
) -> Self {
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
let vfs_receiver = vfs.event_receiver();
@ -58,6 +60,8 @@ impl ChangeProcessor {
tree,
vfs,
message_queue,
pending_recovery: Mutex::new(Vec::new()),
suppressed_paths,
};
let job_thread = jod_thread::Builder::new()
@ -69,6 +73,7 @@ impl ChangeProcessor {
select! {
recv(vfs_receiver) -> event => {
task.handle_vfs_event(event?);
task.process_pending_recoveries();
},
recv(tree_mutation_receiver) -> patch_set => {
task.handle_tree_event(patch_set?);
@ -77,6 +82,11 @@ impl ChangeProcessor {
log::trace!("ChangeProcessor shutdown signal received...");
return Ok(());
},
default(Duration::from_millis(500)) => {
// Periodic sweep even when no events arrive —
// catches files recreated after their Remove was processed.
task.process_pending_recoveries();
},
}
}
})
@ -111,6 +121,16 @@ struct JobThreadContext {
/// Whenever changes are applied to the DOM, we should push those changes
/// into this message queue to inform any connected clients.
message_queue: Arc<MessageQueue<AppliedPatchSet>>,
/// Paths recently removed from the tree that should be re-checked after a
/// delay. On Windows, rapid delete+recreate (e.g., editor undo) can cause
/// the Remove event to arrive but the Create event to be lost. We record
/// removed paths here and periodically verify they are still gone.
pending_recovery: Mutex<Vec<(PathBuf, Instant)>>,
/// Paths recently written by the API's syncback. Events for these paths
/// are suppressed (one-shot) to avoid redundant re-snapshots.
suppressed_paths: Arc<Mutex<std::collections::HashSet<PathBuf>>>,
}
impl JobThreadContext {
@ -133,23 +153,41 @@ impl JobThreadContext {
let affected_ids = loop {
let ids = tree.get_ids_at_path(current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
log::info!(
"apply_patches: path {} affects IDs {:?}",
current_path.display(),
ids
);
if !ids.is_empty() {
break ids.to_vec();
}
log::trace!("Trying parent path...");
log::info!("apply_patches: no IDs at {}, trying parent...", current_path.display());
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
if affected_ids.is_empty() {
log::info!(
"apply_patches: no affected instances found for path {}",
path.display()
);
}
for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, &self.vfs, id) {
if !patch.is_empty() {
applied_patches.push(patch);
if let Some(result) = compute_and_apply_changes(&mut tree, &self.vfs, id) {
// If an instance was removed, schedule a recovery check
// in case the path is recreated momentarily.
if let Some(removed_path) = result.removed_path {
let mut pending = self.pending_recovery.lock().unwrap();
pending.push((removed_path, Instant::now()));
}
if !result.applied.is_empty() {
applied_patches.push(result.applied);
}
}
}
@ -158,12 +196,31 @@ impl JobThreadContext {
}
fn handle_vfs_event(&self, event: VfsEvent) {
// Log VFS events at debug level (not too verbose, but visible when needed)
// Log EVERY VFS event at INFO level for diagnostics.
// This is intentionally verbose — it is critical for debugging
// file watcher desync issues (e.g., rapid delete+recreate).
match &event {
VfsEvent::Create(path) => log::debug!("VFS: File created: {}", path.display()),
VfsEvent::Write(path) => log::debug!("VFS: File modified: {}", path.display()),
VfsEvent::Remove(path) => log::debug!("VFS: File removed: {}", path.display()),
_ => log::trace!("VFS: Other event: {:?}", event),
VfsEvent::Create(path) => log::info!("VFS event: CREATE {}", path.display()),
VfsEvent::Write(path) => log::info!("VFS event: WRITE {}", path.display()),
VfsEvent::Remove(path) => log::info!("VFS event: REMOVE {}", path.display()),
_ => log::info!("VFS event: OTHER {:?}", event),
}
// Check if this event should be suppressed (one-shot, from API syncback).
let event_path = match &event {
VfsEvent::Create(p) | VfsEvent::Write(p) | VfsEvent::Remove(p) => Some(p.clone()),
_ => None,
};
if let Some(ref path) = event_path {
let mut suppressed = self.suppressed_paths.lock().unwrap();
if suppressed.remove(path) {
// Still commit so VFS stays consistent, but skip patching.
self.vfs
.commit_event(&event)
.expect("Error applying VFS change");
log::info!("VFS event SUPPRESSED (API syncback echo): {}", path.display());
return;
}
}
// Update the VFS immediately with the event.
@ -177,15 +234,43 @@ impl JobThreadContext {
VfsEvent::Create(path) | VfsEvent::Write(path) => {
// The path might have been deleted before we could process this event
// (e.g., during syncback when directories are being removed).
// In that case, just skip this event.
// If canonicalize fails, retry once after a brief delay — on Windows,
// the file may still be locked by the editor process.
match self.vfs.canonicalize(&path) {
Ok(canonical_path) => self.apply_patches(canonical_path),
Err(_) => {
log::trace!(
"Skipping create/write event for {:?} - path no longer exists",
path
Ok(canonical_path) => {
log::info!(
"VFS: canonicalize OK for {} -> {}",
path.display(),
canonical_path.display()
);
Vec::new()
self.apply_patches(canonical_path)
}
Err(err) => {
log::info!(
"VFS: canonicalize FAILED for {} ({}), retrying after 50ms...",
path.display(),
err
);
std::thread::sleep(Duration::from_millis(50));
match self.vfs.canonicalize(&path) {
Ok(canonical_path) => {
log::info!(
"VFS: retry canonicalize OK for {} -> {}",
path.display(),
canonical_path.display()
);
self.apply_patches(canonical_path)
}
Err(err2) => {
log::warn!(
"VFS: Create/Write event DROPPED for {} after retry — \
canonicalize failed: {}. File may be desynchronized!",
path.display(),
err2
);
Vec::new()
}
}
}
}
}
@ -197,12 +282,19 @@ impl JobThreadContext {
let parent = path.parent().unwrap();
let file_name = path.file_name().unwrap();
match self.vfs.canonicalize(parent) {
Ok(parent_normalized) => self.apply_patches(parent_normalized.join(file_name)),
Err(_) => {
// Parent directory was also deleted, skip this event
log::trace!(
"Skipping remove event for {:?} - parent no longer exists",
path
Ok(parent_normalized) => {
let resolved = parent_normalized.join(file_name);
log::info!(
"VFS: Remove resolved to {}",
resolved.display()
);
self.apply_patches(resolved)
}
Err(err) => {
log::info!(
"VFS: Skipping remove event for {} — parent no longer exists: {}",
path.display(),
err
);
Vec::new()
}
@ -214,19 +306,21 @@ impl JobThreadContext {
}
};
// Log patch application summary at debug level
// Log patch application summary at INFO level
if !applied_patches.is_empty() {
let total_added: usize = applied_patches.iter().map(|p| p.added.len()).sum();
let total_removed: usize = applied_patches.iter().map(|p| p.removed.len()).sum();
let total_updated: usize = applied_patches.iter().map(|p| p.updated.len()).sum();
if total_added > 0 || total_removed > 0 || total_updated > 0 {
log::debug!(
log::info!(
"VFS event applied: {} added, {} removed, {} updated",
total_added,
total_removed,
total_updated
);
}
} else {
log::info!("VFS event applied: no changes");
}
// Notify anyone listening to the message queue about the changes we
@ -234,6 +328,57 @@ impl JobThreadContext {
self.message_queue.push_messages(&applied_patches);
}
/// Processes any pending recovery checks for paths that were recently
/// removed. If a path has reappeared on the real filesystem after the
/// recovery delay, we trigger a re-snapshot to bring the tree back in sync.
fn process_pending_recoveries(&self) {
const RECOVERY_DELAY: Duration = Duration::from_millis(200);
let ready: Vec<PathBuf> = {
let mut pending = self.pending_recovery.lock().unwrap();
let now = Instant::now();
// Drain entries that are old enough to check
let mut ready = Vec::new();
pending.retain(|(path, recorded_at)| {
if now.duration_since(*recorded_at) >= RECOVERY_DELAY {
ready.push(path.clone());
false // remove from pending
} else {
true // keep in pending
}
});
ready
};
for path in ready {
if std::fs::metadata(&path).is_ok() {
log::info!(
"VFS recovery: path {} was removed but has reappeared on disk. Re-snapshotting.",
path.display()
);
let patches = self.apply_patches(path);
if !patches.is_empty() {
let total_added: usize = patches.iter().map(|p| p.added.len()).sum();
let total_removed: usize = patches.iter().map(|p| p.removed.len()).sum();
let total_updated: usize = patches.iter().map(|p| p.updated.len()).sum();
log::info!(
"VFS recovery applied: {} added, {} removed, {} updated",
total_added,
total_removed,
total_updated
);
self.message_queue.push_messages(&patches);
}
} else {
log::info!(
"VFS recovery: path {} confirmed removed from disk.",
path.display()
);
}
}
}
fn handle_tree_event(&self, patch_set: PatchSet) {
// Log incoming patch summary at debug level
log::debug!(
@ -251,13 +396,40 @@ impl JobThreadContext {
if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source {
InstigatingSource::Path(path) => {
log::info!("Two-way sync: Removing file {}", path.display());
if let Err(err) = fs::remove_file(path) {
log::error!(
"Failed to remove file {:?} for instance {:?}: {}",
path,
id,
err
// Guard: file may already be deleted by the API's
// syncback_removed_instance before this PatchSet arrives.
if path.exists() {
if path.is_dir() {
log::info!(
"Two-way sync: Removing directory {}",
path.display()
);
if let Err(err) = fs::remove_dir_all(path) {
log::error!(
"Failed to remove directory {:?} for instance {:?}: {}",
path,
id,
err
);
}
} else {
log::info!(
"Two-way sync: Removing file {}",
path.display()
);
if let Err(err) = fs::remove_file(path) {
log::error!(
"Failed to remove file {:?} for instance {:?}: {}",
path,
id,
err
);
}
}
} else {
log::info!(
"Two-way sync: File already removed: {}",
path.display()
);
}
}
@ -269,7 +441,6 @@ impl JobThreadContext {
}
}
} else {
// TODO
log::warn!(
"Cannot remove instance {:?}, it is not an instigating source.",
id
@ -284,12 +455,147 @@ impl JobThreadContext {
let id = update.id;
if let Some(instance) = tree.get_instance(id) {
if update.changed_name.is_some() {
log::warn!("Cannot rename instances yet.");
// Handle instance rename on disk
if let Some(ref new_name) = update.changed_name {
if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source {
InstigatingSource::Path(path) => {
if path.exists() {
let old_name = instance.name();
// Compute new path by replacing the old name
// in the filename/directory name
if let Some(parent) = path.parent() {
let old_file_name = path
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("");
let new_file_name =
old_file_name.replacen(old_name, new_name, 1);
let new_path = parent.join(&new_file_name);
if new_path != *path {
log::info!(
"Two-way sync: Renaming {} -> {}",
path.display(),
new_path.display()
);
if let Err(err) = fs::rename(path, &new_path) {
log::error!(
"Failed to rename {:?} to {:?}: {}",
path,
new_path,
err
);
} else {
// Also rename adjacent meta file if it exists
let old_meta =
parent.join(format!("{}.meta.json5", old_name));
if old_meta.exists() {
let new_meta = parent
.join(format!("{}.meta.json5", new_name));
let _ = fs::rename(&old_meta, &new_meta);
}
}
}
}
}
}
InstigatingSource::ProjectNode { .. } => {
log::warn!(
"Cannot rename instance {:?} — defined in project file",
id
);
}
}
} else {
log::warn!(
"Cannot rename instance {:?} — no instigating source",
id
);
}
}
if update.changed_class_name.is_some() {
log::warn!("Cannot change ClassName yet.");
// Handle ClassName changes (script class transitions)
if let Some(ref new_class) = update.changed_class_name {
if let Some(instigating_source) = &instance.metadata().instigating_source {
match instigating_source {
InstigatingSource::Path(path) => {
let old_class = instance.class_name();
let old_is_script = matches!(
old_class.as_str(),
"ModuleScript" | "Script" | "LocalScript"
);
let new_is_script = matches!(
new_class.as_str(),
"ModuleScript" | "Script" | "LocalScript"
);
if old_is_script && new_is_script && path.exists() {
// Script-to-script transition: rename the file extension
if let Some(parent) = path.parent() {
let name = instance.name();
let new_suffix = match new_class.as_str() {
"ModuleScript" => "",
"Script" => ".server",
"LocalScript" => ".local",
_ => "",
};
let is_init = path
.file_name()
.and_then(|f| f.to_str())
.map(|f| f.starts_with("init."))
.unwrap_or(false);
let new_file_name = if is_init {
if new_suffix.is_empty() {
"init.luau".to_string()
} else {
format!("init{}.luau", new_suffix)
}
} else if new_suffix.is_empty() {
format!("{}.luau", name)
} else {
format!("{}{}.luau", name, new_suffix)
};
let new_path = parent.join(&new_file_name);
if new_path != *path {
log::info!(
"Two-way sync: Changing class {} -> {}, \
renaming {} -> {}",
old_class,
new_class,
path.display(),
new_path.display()
);
if let Err(err) = fs::rename(path, &new_path) {
log::error!(
"Failed to rename {:?} to {:?}: {}",
path,
new_path,
err
);
}
}
}
} else if old_is_script != new_is_script {
log::warn!(
"Cannot change ClassName from {} to {} — \
cross-category changes (script <-> non-script) \
are not yet supported",
old_class,
new_class
);
}
}
InstigatingSource::ProjectNode { .. } => {
log::warn!(
"Cannot change ClassName for {:?} — defined in project file",
id
);
}
}
}
}
if update.changed_metadata.is_some() {
@ -351,7 +657,16 @@ impl JobThreadContext {
}
}
fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<AppliedPatchSet> {
/// Result of computing and applying changes to a single instance.
/// Includes the path that was removed (if any) for pending recovery tracking.
struct ComputeResult {
applied: AppliedPatchSet,
/// If the instance was removed because its path no longer exists,
/// record the path here so the caller can schedule a recovery check.
removed_path: Option<PathBuf>,
}
fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<ComputeResult> {
let metadata = tree
.get_metadata(id)
.expect("metadata missing for instance present in tree");
@ -370,41 +685,99 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<
// How we process a file change event depends on what created this
// file/folder in the first place.
let applied_patch_set = match instigating_source {
InstigatingSource::Path(path) => match vfs.metadata(path).with_not_found() {
Ok(Some(_)) => {
// Our instance was previously created from a path and that
// path still exists. We can generate a snapshot starting at
// that path and use it as the source for our patch.
match instigating_source {
InstigatingSource::Path(path) => {
log::info!(
"compute_and_apply_changes: checking path {} for instance {:?}",
path.display(),
id
);
let snapshot = match snapshot_from_vfs(&metadata.context, vfs, path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!("Snapshot error: {:?}", err);
return None;
match vfs.metadata(path).with_not_found() {
Ok(Some(_)) => {
// Our instance was previously created from a path and that
// path still exists. We can generate a snapshot starting at
// that path and use it as the source for our patch.
log::info!(
"compute_and_apply_changes: path EXISTS via VFS, re-snapshotting {}",
path.display()
);
let snapshot = match snapshot_from_vfs(&metadata.context, vfs, path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!("Snapshot error: {:?}", err);
return None;
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: None,
})
}
Ok(None) => {
// Path not found via VFS. Before removing, verify it's truly
// gone from the REAL filesystem. On Windows, rapid
// delete+recreate (e.g., editor undo) can cause Remove events
// where the file has already been recreated by the time we
// process the event.
if std::fs::metadata(path).is_ok() {
log::info!(
"compute_and_apply_changes: VFS says path removed but REAL \
filesystem confirms it EXISTS: {}. Re-snapshotting instead \
of removing.",
path.display()
);
let snapshot = match snapshot_from_vfs(&metadata.context, vfs, path) {
Ok(snapshot) => snapshot,
Err(err) => {
log::error!("Recovery snapshot error for {}: {:?}", path.display(), err);
return None;
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: None,
})
} else {
// Path is genuinely gone from both VFS and real filesystem.
// Remove the instance, but record the path for recovery
// checking — the file might be recreated momentarily.
log::info!(
"compute_and_apply_changes: path NOT FOUND on disk, removing \
instance {:?} for {}. Scheduling recovery check.",
id,
path.display()
);
let removed_path = path.to_path_buf();
let mut patch_set = PatchSet::new();
patch_set.removed_instances.push(id);
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: Some(removed_path),
})
}
};
let patch_set = compute_patch_set(snapshot, tree, id);
apply_patch_set(tree, patch_set)
}
Err(err) => {
log::error!(
"Error processing filesystem change for {}: {:?}",
path.display(),
err
);
None
}
}
Ok(None) => {
// Our instance was previously created from a path, but that
// path no longer exists.
//
// We associate deleting the instigating file for an
// instance with deleting that instance.
let mut patch_set = PatchSet::new();
patch_set.removed_instances.push(id);
apply_patch_set(tree, patch_set)
}
Err(err) => {
log::error!("Error processing filesystem change: {:?}", err);
return None;
}
},
}
InstigatingSource::ProjectNode {
path,
@ -415,6 +788,11 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<
// This instance is the direct subject of a project node. Since
// there might be information associated with our instance from
// the project file, we snapshot the entire project node again.
log::info!(
"compute_and_apply_changes: re-snapshotting project node '{}' at {}",
name,
path.display()
);
let snapshot_result = snapshot_project_node(
&metadata.context,
@ -434,9 +812,11 @@ fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<
};
let patch_set = compute_patch_set(snapshot, tree, id);
apply_patch_set(tree, patch_set)
let applied = apply_patch_set(tree, patch_set);
Some(ComputeResult {
applied,
removed_path: None,
})
}
};
Some(applied_patch_set)
}
}

View file

@ -85,6 +85,11 @@ pub struct ServeSession {
/// A channel to send mutation requests on. These will be handled by the
/// ChangeProcessor and trigger changes in the tree.
tree_mutation_sender: Sender<PatchSet>,
/// Paths recently written by the API's syncback. The ChangeProcessor
/// checks this set and suppresses the file watcher echo for these paths
/// to avoid redundant re-snapshots and WebSocket messages.
suppressed_paths: Arc<Mutex<HashSet<std::path::PathBuf>>>,
}
impl ServeSession {
@ -132,6 +137,7 @@ impl ServeSession {
let vfs = Arc::new(vfs);
let (tree_mutation_sender, tree_mutation_receiver) = crossbeam_channel::unbounded();
let suppressed_paths = Arc::new(Mutex::new(HashSet::new()));
log::trace!("Starting ChangeProcessor");
let change_processor = ChangeProcessor::start(
@ -139,6 +145,7 @@ impl ServeSession {
Arc::clone(&vfs),
Arc::clone(&message_queue),
tree_mutation_receiver,
Arc::clone(&suppressed_paths),
);
Ok(Self {
@ -150,6 +157,7 @@ impl ServeSession {
message_queue,
tree_mutation_sender,
vfs,
suppressed_paths,
})
}
@ -165,6 +173,12 @@ impl ServeSession {
self.tree_mutation_sender.clone()
}
/// Returns a handle to the suppressed paths set, used to avoid
/// file watcher echo when the API writes files to disk.
pub fn suppressed_paths(&self) -> Arc<Mutex<HashSet<std::path::PathBuf>>> {
Arc::clone(&self.suppressed_paths)
}
#[allow(unused)]
pub fn vfs(&self) -> &Vfs {
&self.vfs

File diff suppressed because it is too large Load diff