From d346997d83d55b6f3bfd10182c685c5c2a9d1b1f Mon Sep 17 00:00:00 2001 From: SriBalan <105964659+sribalan98@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:18:35 +0530 Subject: [PATCH] fix(sync): resolve two-way sync feedback loop and cursor jumping via echo suppression --- plugin/src/App/StatusPages/Settings/init.lua | 1 - plugin/src/ChangeBatcher/init.lua | 2 -- plugin/src/InstanceMap.lua | 14 +++++++--- src/change_processor.rs | 28 +++++++++++++++++--- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/plugin/src/App/StatusPages/Settings/init.lua b/plugin/src/App/StatusPages/Settings/init.lua index b93eba3e..4c6a462b 100644 --- a/plugin/src/App/StatusPages/Settings/init.lua +++ b/plugin/src/App/StatusPages/Settings/init.lua @@ -235,7 +235,6 @@ function SettingsPage:render() description = "Editing files in Studio will sync them into the filesystem", locked = self.props.syncActive, lockedTooltip = "(Cannot change while currently syncing. Disconnect first.)", - tag = "unstable", transparency = self.props.transparency, layoutOrder = layoutIncrement(), }), diff --git a/plugin/src/ChangeBatcher/init.lua b/plugin/src/ChangeBatcher/init.lua index 5d320ce2..bf35e8c7 100644 --- a/plugin/src/ChangeBatcher/init.lua +++ b/plugin/src/ChangeBatcher/init.lua @@ -60,8 +60,6 @@ function ChangeBatcher:__cycle(dt) self.__onChangesFlushed(patch) end end - - self.__instanceMap:unpauseAllInstances() end function ChangeBatcher:__flush() diff --git a/plugin/src/InstanceMap.lua b/plugin/src/InstanceMap.lua index 250f41e6..8076ff24 100644 --- a/plugin/src/InstanceMap.lua +++ b/plugin/src/InstanceMap.lua @@ -145,7 +145,7 @@ end --[[ Pause updates for an instance. ]] -function InstanceMap:pauseInstance(instance) +function InstanceMap:pauseInstance(instance, duration) local id = self.fromInstances[instance] -- If we don't know about this instance, ignore it. @@ -153,7 +153,8 @@ function InstanceMap:pauseInstance(instance) return end - self.pausedUpdateInstances[instance] = true + -- Pause with expiration timestamp (0.6s) to ensure property replication signals settle + self.pausedUpdateInstances[instance] = os.clock() + (duration or 0.6) end --[[ @@ -202,8 +203,13 @@ end function InstanceMap:__maybeFireInstanceChanged(instance, propertyName) Log.trace("{}.{} changed", instance:GetFullName(), propertyName) - if self.pausedUpdateInstances[instance] then - return + local pauseExpiry = self.pausedUpdateInstances[instance] + if pauseExpiry ~= nil then + if os.clock() < pauseExpiry then + return + else + self.pausedUpdateInstances[instance] = nil + end end if self.onInstanceChanged == nil then diff --git a/src/change_processor.rs b/src/change_processor.rs index c1f245f2..1da0ca4e 100644 --- a/src/change_processor.rs +++ b/src/change_processor.rs @@ -2,7 +2,9 @@ use crossbeam_channel::{select, Receiver, RecvError, Sender}; use jod_thread::JoinHandle; use memofs::{IoResultExt, Vfs, VfsEvent}; use rbx_dom_weak::types::{Ref, Variant}; +use std::collections::HashMap; use std::path::PathBuf; +use std::time::{Duration, Instant}; use std::{ fs, sync::{Arc, Mutex}, @@ -54,10 +56,12 @@ impl ChangeProcessor { ) -> Self { let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1); let vfs_receiver = vfs.event_receiver(); + let recent_writes = Arc::new(Mutex::new(HashMap::new())); let task = JobThreadContext { tree, vfs, message_queue, + recent_writes, }; let job_thread = jod_thread::Builder::new() @@ -111,6 +115,10 @@ 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>, + + /// Tracks recent filesystem writes initiated by Studio client two-way sync, + /// suppressing file-watcher echo loops back into the message queue. + recent_writes: Arc>>, } impl JobThreadContext { @@ -165,6 +173,18 @@ impl JobThreadContext { .commit_event(&event) .expect("Error applying VFS change"); + // Suppress echo if this event corresponds to a write triggered by two-way sync + if let VfsEvent::Create(path) | VfsEvent::Write(path) = &event { + if let Ok(canonical_path) = self.vfs.canonicalize(path) { + let mut writes = self.recent_writes.lock().unwrap(); + writes.retain(|_, time| time.elapsed() < Duration::from_secs(3)); + if writes.remove(&canonical_path).is_some() { + log::trace!("Suppressing VFS echo for client-originated write: {:?}", canonical_path); + return; + } + } + } + // For a given VFS event, we might have many changes to different parts // of the tree. Calculate and apply all of these changes. let applied_patches = match event { @@ -258,6 +278,9 @@ impl JobThreadContext { path.display(), err ); + } else if let Ok(canonical_path) = self.vfs.canonicalize(path) { + let mut writes = self.recent_writes.lock().unwrap(); + writes.insert(canonical_path, Instant::now()); } } else { log::warn!("Cannot change Source to non-string value."); @@ -288,9 +311,8 @@ impl JobThreadContext { apply_patch_set(&mut tree, patch_set) }; - if !applied_patch.is_empty() { - self.message_queue.push_messages(&[applied_patch]); - } + // Suppress pushing self-generated patch back to message queue to eliminate client echo + let _ = applied_patch; } }