- Updated the `vscode-rojo` submodule to the latest commit. - Added a new plan for addressing issues found during the audit of parallelization and optimization commits, including fixes for a data loss bug in the orphan scanner, a duplicate-children bug in the prefetch cache, and a nested-detection correctness issue. - Implemented a small DRY refactor by extracting a shared helper function for UNC path stripping. - Enhanced the `syncback` logic to ensure hidden directories are preserved during clean mode operations and added tests to validate these changes. These updates aim to improve the reliability and correctness of the parallel I/O functionality while maintaining code clarity and performance.
14 KiB
| name | overview | todos | isProject | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Parallel IO audit fixes | Fix three issues found during audit of the parallelization/optimization commits: a data loss bug in the orphan scanner, a duplicate-children bug in the prefetch cache, and a nested-detection correctness issue, plus a small DRY refactor. |
|
false |
Parallel I/O Audit Fixes
This plan was generated by
/auditfrom a Plan-mode session. Refer to.cursor/rules/atlas.mdcfor full project standards.
Standards
These standards govern every fix in this plan. An implementer MUST read .cursor/rules/atlas.mdc before starting.
Round-Trip Identity (from atlas.mdc - Quality Standard)
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.
CLI Syncback Parity (from atlas.mdc - Two-Way Sync Strategy)
Plugin-based sync must produce exactly the same filesystem output that atlas syncback would give for the same input. Byte-for-byte identical files, identical directory structures, identical naming. Any divergence is a bug until proven otherwise.
Code Quality (from atlas.mdc - Code Quality Standard)
DRY code that is easy to reason about and hard to get wrong. The same slugify/dedup/meta-update pattern should not be copy-pasted across files. Small refactors (2-3 call sites) are included as fixes. Major rewrites (10+ call sites) are deferred.
Context
Audited the last 7 commits on the current branch (from 1ffed121 through 56e1ab4a), which add:
- VFS prefetch cache for parallel file reading at startup
- Parallel snapshot building via rayon in
dir.rs - Parallel RefPathIndex population via rayon + walkdir
- Parallel tree hashing in syncback
- Orphan scanner rewrite using walkdir
- Placeholder-based ref path substitution optimization
- ServeCommand refactor and dead code cleanup
Fixes
Fix 1: Prune hidden directories from walkdir orphan scanner
- Status: Approved
- Finding: Orphan scanner walkdir replacement doesn't prune hidden directories
- Files:
[src/syncback/mod.rs](src/syncback/mod.rs) - Problem: Lines ~406-424. The
walkdir::WalkDiriterator yields ALL entries including children of hidden directories. The hidden-name filter only checksfile_name().starts_with('.'), sosettings.jsoninside.vscode/passes the filter and gets added toexisting_paths. In clean mode, these files would be deleted. The old recursivescan_directoryfunction skipped hidden directories AND never recursed into them. The walkdir replacement must replicate this behavior. - Solution: Replace the flat
.flatten()+ post-hoc hidden check withfilter_entry()to prune the walk at hidden directories:
for entry in walkdir::WalkDir::new(&canonical_dir)
.follow_links(true)
.into_iter()
.filter_entry(|e| {
if e.depth() == 0 {
return true;
}
e.file_name()
.to_str()
.map_or(true, |n| !n.starts_with('.') || n == ".gitkeep")
})
.flatten()
{
if entry.depth() == 0 {
continue;
}
let path = entry.path().to_path_buf();
if !is_valid_path(&ignore_patterns, project_path, &path) {
continue;
}
// Hidden check no longer needed here -- filter_entry handles it
paths.insert(path);
}
filter_entry controls which directories walkdir descends into. When it returns false for a directory, walkdir skips the directory AND all its descendants. This exactly matches the old recursive behavior.
- Risk: Low -- only touches the orphan scanner loop.
filter_entryis walkdir's standard pruning mechanism. - Verify round-trip: Clean mode syncback with a
.vscode/directory inside a$pathsource dir should NOT delete.vscode/contents. - Verify syncback parity: N/A -- this only affects clean mode orphan removal, not the sync pipeline.
- Tests required:
- Integration test: create a temp project with
src/.hidden/keep.txt, run clean-mode syncback, verify.hidden/keep.txtsurvives.
- Integration test: create a temp project with
Fix 2: Deduplicate overlapping roots in prefetch cache
- Status: Approved
- Finding: Overlapping
$pathroots cause duplicate children in prefetch cache - Files:
[src/serve_session.rs](src/serve_session.rs) - Problem: Lines ~136-156 in
prefetch_project_files.collect_path_rootscollects ALL$pathentries including nested ones. For a project with$path: "src"and$path: "src/shared", walkdir walkssrc/sharedtwice -- once as part ofsrcand once as its own root. Thechildren_mapaccumulates duplicate entries forsrc/shared's children. When consumed by VFSread_dir, these duplicates cause duplicate instance snapshots. - Solution: After collecting roots in
prefetch_project_files, deduplicate by removing any root that is a descendant of another root:
let mut roots: Vec<std::path::PathBuf> = Vec::new();
collect_path_roots(&project.tree, folder, &mut roots);
// Remove roots that are descendants of other roots to avoid
// walking the same directory twice and producing duplicate children.
roots.sort();
let mut deduped: Vec<PathBuf> = Vec::new();
for root in roots {
if !deduped.iter().any(|existing| root.starts_with(existing)) {
deduped.push(root);
}
}
let roots = deduped;
After sorting, a parent path always sorts before its descendants. By iterating in order and only keeping roots that are NOT descendants of already-kept roots, we eliminate all overlaps in O(n^2) worst case but typically O(n) since most projects have few $path entries.
- Risk: Low -- only modifies which directories are walked during prefetch. The dedup logic is simple and well-understood.
- Verify round-trip: Build a project with overlapping
$pathentries. Verify snapshot tree has no duplicate instances. Run build 5 times, verify deterministic output. - Verify syncback parity: N/A -- prefetch cache only affects forward sync initial load.
- Tests required:
- Unit test in
tests/tests/build.rs: create a project with$path: "src"and a child with$path: "src/shared". Runsnapshot_from_vfswith prefetch cache. Verify no duplicate children under theshareddirectory instance.
- Unit test in
Fix 3: Fix nested path detection in orphan removal
- Status: Approved (O(n log n) approach)
- Finding: Binary search nested detection misses sibling paths
- Files:
[src/syncback/mod.rs](src/syncback/mod.rs) - Problem: Lines ~869-888. The optimization uses
binary_searchto find the current path's position, then checks onlysorted_removals[idx - 1]. This misses cases where a sibling path sits between the parent and the current path in sorted order. Example:sorted = ["a/b", "a/b/c", "a/b/d"]. Fora/b/dat idx 2, the code checkssorted[1] = "a/b/c"(a sibling, not an ancestor). The actual ancestor"a/b"at idx 0 is not checked. - Solution: Replace binary search with a sorted single-pass using an
current_ancestortracker:
let mut sorted_removals: Vec<_> = paths_to_remove.iter().cloned().collect();
sorted_removals.sort();
let mut current_ancestor: Option<&PathBuf> = None;
for old_path in &sorted_removals {
if let Some(ancestor) = current_ancestor {
if old_path.starts_with(ancestor) && old_path != ancestor {
continue; // nested under current ancestor, skip
}
}
current_ancestor = Some(old_path);
let relative_path = old_path
.strip_prefix(&canonical_project_path)
.unwrap_or(old_path);
log::debug!("Removing orphaned path: {}", relative_path.display());
if old_path.is_dir() {
fs_snapshot.remove_dir(relative_path);
} else {
fs_snapshot.remove_file(relative_path);
}
}
In sorted path order, all descendants of a path appear consecutively after it (because Path comparison is component-based). So a single variable tracking the current top-level ancestor is sufficient. When we encounter a non-descendant, it becomes the new ancestor. This is O(n log n) for the sort + O(n) for the walk.
This also removes the intermediate paths_vec and the binary_search code, simplifying the function.
- Risk: Low -- the logic is simpler than what it replaces. The correctness argument is straightforward: sorted paths guarantee all descendants appear consecutively.
- Verify round-trip: Clean mode syncback with nested directories. Verify parent-only removal (no redundant child removals).
- Verify syncback parity: N/A -- orphan removal is internal to clean mode.
- Tests required:
- Unit test: create paths
["a/b", "a/b/c", "a/b/d", "x/y", "x/y/z"], verify only"a/b"and"x/y"are top-level removals.
- Unit test: create paths
Fix 4: Extract shared strip_unc_prefix helper
- Status: Approved
- Finding: Duplicated UNC prefix stripping logic in 3 locations
- Files:
[src/syncback/mod.rs](src/syncback/mod.rs) - Problem: The same UNC prefix stripping pattern appears at:
- Lines ~396-404 (orphan scanner: canonicalize
dirs_to_scanentries) - Lines ~649-656 (
strip_unc_prefixhelper definition inside orphan removal block) - Lines ~703-711 (inline canonicalize in
collect_all_path_refs_and_mappings) Location 2 defines a properfn strip_unc_prefix, but locations 1 and 3 inline the same logic.
- Lines ~396-404 (orphan scanner: canonicalize
- Solution: Move
strip_unc_prefixto module level (or just above the clean-mode block), and replace all 3 inline instances with calls to it. The function is already defined at location 2:
fn strip_unc_prefix(p: PathBuf) -> PathBuf {
let path_str = p.to_string_lossy();
if path_str.starts_with(r"\\?\") || path_str.starts_with("//?/") {
PathBuf::from(&path_str[4..])
} else {
p
}
}
Move this definition before the existing_paths block (around line ~258), then replace the inline code at lines ~396-404 with strip_unc_prefix(std::fs::canonicalize(dir).unwrap_or_else(|_| dir.clone())) and at lines ~703-711 with strip_unc_prefix(p).
- Risk: Zero -- pure refactor, no behavior change.
- Verify round-trip: N/A -- refactor only.
- Verify syncback parity: N/A -- refactor only.
- Tests required: None -- existing tests cover this code path.
Skipped Fixes
Skipped: Serve command loads project twice
- Finding:
src/cli/serve.rsloads the project file viaProject::load_initial_projectfor port/address, thenServeSession::newloads it again ininit_tree. - Problem: Minor performance -- project file read twice from disk at startup.
- Reason skipped: Acceptable overhead. Project files are small and this only happens once.
Skipped: Commented-out git config in init.rs
- Finding: Four commented-out git config lines in
src/cli/init.rslines 134-137. - Reason skipped: User indicated these are planned for future enablement.
Accepted Limitations
- Limitation: Symlinks within project
$pathdirectories may cause path mismatches - Scenario: If a symlink inside a
$pathsource directory points elsewhere, walkdir-collected paths won't matchadded_pathsduring clean-mode orphan removal. Files behind the symlink could be incorrectly removed. - User decision: Accepted for this release
- Mitigation: Symlinks within source directories are not a supported configuration in Roblox development. Root-level symlinks (project folder itself) still work because
canonicalizeis called on the root.
Deferred Refactors
None identified beyond the small strip_unc_prefix extraction (included as Fix 4).
Test Plan
Rust Integration Tests (tests/tests/)
- Hidden directory survival in clean syncback (Fix 1): Create temp project with
src/.hidden/keep.txt, run clean-mode syncback, assert.hidden/keep.txtstill exists after syncback. - Overlapping
$pathroots don't create duplicates (Fix 2): Create project with overlapping$pathentries ("src"and"src/shared"), runsnapshot_from_vfswith prefetch cache, verify no duplicate children.
Rust Unit Tests
- Nested path detection correctness (Fix 3): Test the ancestor-tracking algorithm with paths
["a/b", "a/b/c", "a/b/d", "x/y", "x/y/z"], verify only"a/b"and"x/y"are emitted as top-level.
Test Rules
- Every bug fix MUST have a test that would have FAILED before the fix and PASSES after.
- Fix 4 (refactor) does not need new tests -- existing test coverage is sufficient.
Final Step: Run CI
After ALL fixes and tests are implemented, run the full CI pipeline (cargo test --locked, cargo clippy, cargo fmt --check). Every fix must pass CI before the plan is considered complete.