- Introduced a new audit plan addressing six identified issues during the live syncback audit, including resource leaks, error handling, concurrent request management, input validation, documentation clarity, and test coverage. - Implemented fixes such as replacing `mem::forget` with `drop` for better memory management, enhancing error recovery to prevent server crashes, and validating chunk counts against blob sizes. - Updated documentation to reflect the audit findings and standards, ensuring clarity on the requirements for each fix. - Added five new integration tests to cover various edge cases and ensure robustness in the syncback functionality. These changes aim to improve the reliability and correctness of the live syncback feature while providing a structured approach to addressing identified issues.
19 KiB
| name | overview | todos | isProject | |||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Live Syncback Audit Fixes | Fix 6 issues found during the live syncback audit: resource leaks from mem::forget, crash-on-error instead of recovery, concurrent syncback races, silent malformed input corruption, misleading doc comments, and missing test coverage. |
|
false |
Live Syncback 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 "Live syncback" feature on the ambiguous-paths branch. This feature adds a one-click full sync from Studio to filesystem via POST /api/syncback. The plugin serializes all services (properties via msgpack, children via a single rbxm blob), sends them to the server, which reconstructs a WeakDom, runs syncback_loop(incremental=false), and writes the result. The server shuts down and restarts after each syncback.
Key files audited:
src/cli/serve.rs-- serve loop,run_live_syncback(),build_dom_from_chunks()src/web/api.rs--handle_api_syncback()endpointsrc/web/mod.rs--SyncbackSignal,LiveServersrc/web/interface.rs--ServiceChunk,SyncbackRequest,SyncbackPayloadplugin/src/App/init.lua--performSyncback(),SYNCBACK_SERVICESplugin/src/ChangeBatcher/encodeService.lua-- service property/ref encoding
No critical round-trip identity violations found. The CLI parity tests (assert_live_matches_cli) provide strong guarantees. The round-trip test (roundtrip_build_syncback_rebuild) covers the full lifecycle. All 6 fixes below are correctness hardening, resilience, and test coverage.
Fixes
Each fix below was approved by the user during the audit quiz. Implement them in order. Each fix includes its test requirements -- a fix is not complete until its tests pass.
Fix 1: Replace mem::forget with proper drop
- Status: Approved
- Finding: Step 14 (Challenged Limitations) -- resource leak accumulating per syncback invocation
- Files:
[src/cli/serve.rs](src/cli/serve.rs)(lines 61, 135) - Problem:
forget(session_old)at line 135 leaks the entire oneshotServeSession(tree, VFS, project) after every live syncback.forget(session)at line 61 leaks the initial config-reading session at startup. The oneshot session haschange_processor: None,suppressed_paths: None,ref_path_index: None,git_repo_root: None-- allOptiontypes that drop cleanly asNone. TheVfsdestructor will stop its watcher thread. There is no reason to useforget(). - Solution:
- Line 135: Replace
forget(session_old);withdrop(session_old); - Line 61: Replace
forget(session);withdrop(session); - Remove
use std::mem::forget;from the imports (line 4) if no other uses remain
- Line 135: Replace
- Risk: Very low. The oneshot session's fields are all
Option::Noneor standard types. The VFS watcher thread is dormant (no watches registered). Existing live syncback tests exercise this path. - Verify round-trip: Run
parity_scriptsandroundtrip_build_syncback_rebuild-- both must pass. - Verify syncback parity: Run
assert_live_matches_clitests -- filesystem output unchanged. - Tests required:
- No new tests needed. Existing tests
syncback_twice_different_dataandserver_comes_back_functionalalready exercise repeated syncback and verify the server recovers correctly.
- No new tests needed. Existing tests
Fix 2: Recover from syncback errors instead of crashing
- Status: Approved
- Finding: Step 14 (Challenged Limitations) -- server crashes permanently on syncback failure
- Files:
[src/cli/serve.rs](src/cli/serve.rs)(lines 84-88) - Problem:
run_live_syncback(&project_path, payload)?propagates errors via?, crashing the serve process. The plugin shows "Syncback data sent" but the server never comes back. - Solution: Catch the error and log it, then continue to restart the server:
ServerExitReason::SyncbackRequested(payload) => {
log::info!("Live syncback requested, running...");
match run_live_syncback(&project_path, payload) {
Ok(()) => log::info!("Syncback complete, restarting serve..."),
Err(err) => log::error!("Live syncback failed: {err:#}. Restarting serve..."),
}
continue;
}
- Risk: Very low. The server restarts fresh regardless (new VFS, new session). An error just means the filesystem wasn't updated -- the old state persists.
- Verify round-trip: Existing tests still pass. The error path is tested by Fix 6 test 4.
- Verify syncback parity: N/A -- this fix doesn't change the syncback logic, only error handling.
- Tests required:
- Rust integration test (Fix 6, test 4): Send malformed chunk counts that trigger an error in
build_dom_from_chunks. Verify the server recovers and comes back online.
- Rust integration test (Fix 6, test 4): Send malformed chunk counts that trigger an error in
Fix 3: Reject concurrent syncback requests
- Status: Approved
- Finding: Step 14 (Challenged Limitations) -- second syncback silently overwrites first payload
- Files:
[src/web/mod.rs](src/web/mod.rs)(SyncbackSignal::fire, lines 43-46),[src/web/api.rs](src/web/api.rs)(handle_api_syncback, line 259) - Problem:
fire()unconditionally overwrites any existing payload. If two requests arrive before the accept loop processes the first, the first payload is silently lost. - Solution:
- Change
SyncbackSignal::fire()to returnbool:
- Change
pub fn fire(&self, payload: SyncbackPayload) -> bool {
let mut guard = self.payload.lock().unwrap_or_else(|e| e.into_inner());
if guard.is_some() {
return false;
}
*guard = Some(payload);
self.notify.notify_one();
true
}
- In
handle_api_syncback, check the return value:
if !syncback_signal.fire(payload) {
return msgpack(
ErrorResponse::bad_request("A syncback is already in progress"),
StatusCode::CONFLICT,
);
}
- Risk: Very low. Happy path unchanged. Only adds rejection for the concurrent case.
- Verify round-trip: Existing tests pass (single syncback). New test verifies rejection.
- Verify syncback parity: N/A -- this fix doesn't change syncback output.
- Tests required:
- Rust integration test (Fix 6, test 3): Fire two syncback requests rapidly, verify the second returns 409 Conflict and the server still processes the first successfully.
Fix 4: Validate chunk counts match rbxm blob size
- Status: Approved
- Finding: Step 11g (Audit Step 4, server-side processing) -- silent cursor clamping corrupts subsequent services
- Files:
[src/cli/serve.rs](src/cli/serve.rs)(build_dom_from_chunks, lines 184-210) - Problem: Line 187
let end = (cursor + total).min(cloned_children.len());silently clamps when counts exceed blob size. This corrupts cursor position for all subsequent services. - Solution:
- Inside the service loop, replace the
.min()clamp with an early error:
- Inside the service loop, replace the
let end = cursor + total;
anyhow::ensure!(
end <= cloned_children.len(),
"Service '{}' claims {} children + {} carriers but only {} instances remain in blob",
chunk.class_name, child_count, ref_count,
cloned_children.len().saturating_sub(cursor)
);
- After the service loop, validate all instances were consumed:
anyhow::ensure!(
cursor == cloned_children.len(),
"Service chunks account for {} instances but rbxm blob contains {}",
cursor, cloned_children.len()
);
- Risk: Very low. Only triggers on malformed input. Normal requests are unaffected. With Fix 2, the error is caught and the server recovers.
- Verify round-trip: Existing tests pass (all have correct counts). New test verifies rejection.
- Verify syncback parity: N/A -- this fix doesn't change output for valid input.
- Tests required:
- Rust integration test (Fix 6, test 4): Send a request where service chunks claim more children than the blob contains. Verify the server logs an error and recovers.
Fix 5: Correct misleading ServiceChunk doc comment
- Status: Approved
- Finding: Step 4 (server-side processing audit) -- doc describes behavior that doesn't exist
- Files:
[src/web/interface.rs](src/web/interface.rs)(lines 30-42) - Problem: Doc says "Indices <= child_count point directly to a child instance" but the implementation only indexes into the carrier subrange. This misleads future maintainers.
- Solution: Replace the doc comment block (lines 30-42) with:
/// Metadata for a single service in the live syncback request.
///
/// Children are NOT stored here -- they live in the top-level `data` blob
/// (a single rbxm containing all children from all services).
///
/// Each service's range in the blob is `child_count + ref_target_count`
/// consecutive entries. The first `child_count` are real children (parented
/// to the service). The next `ref_target_count` are ObjectValue carriers
/// whose `Value` property holds a Ref to the actual target.
///
/// `refs` maps property names to 1-based indices into the carrier subrange
/// (NOT the full service range). Index 1 = first carrier, index 2 = second,
/// etc. Index 0 or out-of-range values are ignored (treated as nil).
- Risk: Zero. Documentation-only change.
- Verify round-trip: N/A
- Verify syncback parity: N/A
- Tests required: None.
Fix 6: Add missing test coverage
- Status: Approved
- Finding: Step 13 (audit report) -- several edge cases lack test coverage
- Files:
[tests/tests/live_syncback.rs](tests/tests/live_syncback.rs) - Problem: Missing tests for service attributes/tags, multiple Ref properties, concurrent requests, malformed input, and properties-only services.
- Solution: Add the following tests to
tests/tests/live_syncback.rs:
Test 1: parity_service_attributes_and_tags
- Create a Lighting service with custom Attributes (e.g.,
{"CustomFloat": Float32(1.5)}) and Tags (e.g.,["TestTag"]) - Use
make_service_chunk_fullwith Attributes and Tags encoded asVariant::AttributesandVariant::Tagsin the properties map - Run
assert_live_matches_cli
Test 2: parity_multiple_service_refs
- Create a service (Workspace) with two Ref properties: one pointing to a Part child, one pointing to a Light child
- Use
make_service_chunk_fullwith two carrier ObjectValues and tworefsentries - Run
assert_live_matches_cli
Test 3: rejects_concurrent_syncback (for Fix 3)
- Use
run_serve_testto start a server - Send a valid syncback request (don't wait for restart)
- Immediately send a second syncback request
- Verify the second returns 409 Conflict
- Wait for the server to come back online (first syncback completes)
Test 4: recovers_from_malformed_chunks (for Fixes 2+4)
- Use
run_serve_testto start a server - Record the initial session info
- Send a syncback request with mismatched counts (e.g.,
child_count: 999but empty data blob) - Verify the server comes back online (new session ID)
- Verify the filesystem is unchanged (the malformed syncback was rejected before writing)
Test 5: parity_properties_only_no_children
- Create 3 services (Lighting, SoundService, TextChatService) all with properties but zero children
- Verify empty rbxm blob case works with properties
- Run
assert_live_matches_cli - Risk: Zero. Test-only additions.
- Verify round-trip: The parity tests verify round-trip by design.
- Verify syncback parity: The parity tests verify CLI parity by design.
- Tests required: Self-contained -- the fix IS the tests.
Skipped Fixes
None -- all identified fixes were approved.
Accepted Limitations
rbxm Child Ordering Assumption
- Limitation: Live syncback relies on
SerializationService:SerializeInstancesAsync()preserving the array order of top-level instances in the resulting rbxm blob. If Roblox ever changes the serializer to reorder by class, the cursor-based partitioning inbuild_dom_from_chunkswould break. - Scenario: A future Roblox engine update changes serialization order, causing children to be assigned to wrong services.
- User decision: Accepted for this release. This is the standard approach used by all Rojo-family tools. The rbxm format preserves parent-child relationships and child ordering through the PRNT chunk. Roblox has never changed this behavior.
- Mitigation: Fix 4 adds count validation that would catch the symptom (cursor doesn't consume all instances) even if not the root cause.
Asymmetric ClockTime Exclusion
- Limitation:
encodeService.luaexcludesLighting.ClockTime(alias forTimeOfDay) but the server-sidebuild_dom_from_chunksdoesn't have this exclusion. - Scenario: A crafted request with both
ClockTimeandTimeOfDaycould produce conflicting properties. This never happens in normal usage since the plugin prevents it. - User decision: Accepted. The plugin is the only intended client, and it already filters correctly.
- Mitigation: None needed beyond the existing plugin-side filter.
No Session ID on /api/syncback
- Limitation: Unlike
/api/write, the syncback endpoint has no session ID validation. Any client that knows the host/port can trigger a syncback. - Scenario: On a shared network with
serveAddress: "0.0.0.0", someone could overwrite project files. - User decision: Accepted by design. The syncback is intended to work from the NotConnected page (no active session). The
servePlaceIds/blockedPlaceIdswhitelist provides protection for team environments. - Mitigation: Existing place ID validation. The serve address defaults to
127.0.0.1(localhost only).
Deferred Refactors
Duplicate Service Lists
The service list exists in 3 places that must stay synchronized:
SYNCBACK_SERVICESinplugin/src/App/init.lua(lines 7-22)VISIBLE_SERVICESinsrc/syncback/mod.rs(lines 73-88)- Implicitly in test helper code (
serve_util.rs)
Currently they match exactly (14 services). If these diverge, Ref properties targeting instances in a missing service would be silently lost. A cross-language shared constant is impossible (Lua vs Rust), but a CI check or a test that compares the lists (e.g., parsing the Lua source) could prevent drift. Deferred because it's a build/CI infrastructure change, not a code change.
build_dom_from_chunks Duplication with Test Helper
build_dom_from_chunks in serve.rs and make_rbxl_from_chunks in serve_util.rs implement nearly identical DOM reconstruction logic. The test helper was written to mirror the production code for CLI parity tests. If the production code changes, the test helper must be updated in lockstep. Consider extracting the shared logic into a librojo function that both can call. Deferred because it requires making build_dom_from_chunks public and adjusting its API.
Test Plan
Tests are not optional. Every approved fix must have test coverage. A fix is not complete until its tests pass.
Rust Integration Tests (tests/tests/live_syncback.rs)
parity_service_attributes_and_tags-- service with Attributes + Tags survives round-trip (Fix 6, test 1)parity_multiple_service_refs-- service with 2+ Ref properties, all carriers resolved correctly (Fix 6, test 2)rejects_concurrent_syncback-- second syncback returns 409 Conflict (Fix 6, test 3)recovers_from_malformed_chunks-- mismatched counts cause error, server recovers (Fix 6, test 4)parity_properties_only_no_children-- 3 services with properties, zero children, empty blob (Fix 6, test 5)
Existing Tests (Must Continue Passing)
All existing tests in tests/tests/live_syncback.rs must pass after all fixes:
parity_scripts,parity_models_with_properties,parity_special_names,parity_duplicate_namesparity_deep_hierarchy,parity_mixed_file_types,parity_multi_serviceparity_empty_services_mixed,parity_lighting_properties,parity_soundservice_propertiesparity_starterplayer_properties,parity_textchatservice_propertiesparity_childless_service_with_properties,parity_service_properties_with_childrenparity_multiple_services_with_properties,parity_cross_service_refsparity_camera_not_synced,rejects_bad_protocol,rejects_bad_versionrejects_blocked_place,rejects_unlisted_place,allows_whitelisted_placeallows_any_place_when_unrestricted,server_comes_back_functionalsyncback_twice_different_data,syncback_replaces_all_old_filesroundtrip_build_syncback_rebuild
Test Rules
The implementer MUST follow these rules when writing tests for this plan:
For bugs found and fixed
Every bug fix MUST have a test that:
- Would have FAILED before the fix
- PASSES after the fix
- Prevents the bug from regressing
For missing coverage identified
For each missing test coverage gap:
- Write the test
- Verify it passes against the current implementation
- If it fails, investigate -- it may have found another bug
Scope: test every affected layer
For each finding, determine which test layers are affected and write tests in ALL of them:
- Fixes 1-5 affect the Rust server -- integration tests in
tests/tests/live_syncback.rs - No Lua spec tests needed (plugin encoding is unchanged)
- No snapshot tests needed (syncback output format is unchanged)
Final Step: Run CI
After ALL fixes and tests are implemented, run the /ci command (see .cursor/commands/ci.md) to execute the full CI pipeline. Every fix must pass CI before the plan is considered complete. Do not skip this step.