authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 19:34:43-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 20:10:08-07:00
log3aa700c6c057e00e4eaf633161e28c5611b8b687
tree0fb67ca31c04f78dbcf41f099821730c65996704
parent08d400c2cd4212f7438a51ea0993a4fe19944a62
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: a replay settles only once every event sent before it went quiet has been taken

Two quiet frames were not enough under load: ticks queued ahead of a finished thread's event, so the iCloud folder's notebooks could list after the tree was written. The window now sends itself a mark on finding nothing on its way and settles when that mark arrives still quiet, and looking up the iCloud folder counts as work on its way. Assisted-by: claude-opus-5.5

2 files changed, 40 insertions(+), 20 deletions(-)

crates/snowbound/src/icloud_macos.rs+3-2
...@@ -90,9 +90,10 @@ pub fn folder() -> Option<PathBuf> {...@@ -90,9 +90,10 @@ pub fn folder() -> Option<PathBuf> {
9090
91/// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the91/// Looks for the app's own folder in iCloud Drive on a thread of its own, as asking the
92/// iCloud daemon can take a while, then calls `changed`, and again whenever a folder at its92/// iCloud daemon can take a while, then calls `changed`, and again whenever a folder at its
93/// top comes or goes, as another device adds or removes a notebook.93/// top comes or goes, as another device adds or removes a notebook. A replay settles only
94/// once it has looked.
94pub fn look_up(changed: impl Fn() + Send + Sync + 'static) {95pub fn look_up(changed: impl Fn() + Send + Sync + 'static) {
95 std::thread::spawn(move || {96 crate::spawn(move || {
96 let changed = std::sync::Arc::new(changed);97 let changed = std::sync::Arc::new(changed);
97 let found = container().map(|root| {98 let found = container().map(|root| {
98 let report = std::sync::Arc::clone(&changed);99 let report = std::sync::Arc::clone(&changed);
crates/snowbound/src/main.rs+37-18
...@@ -378,6 +378,9 @@ enum Replay {...@@ -378,6 +378,9 @@ enum Replay {
378 Settle(Option<PathBuf>, std::sync::mpsc::Sender<()>),378 Settle(Option<PathBuf>, std::sync::mpsc::Sender<()>),
379 /// A frame during a wait, as a visible window's display would ask for.379 /// A frame during a wait, as a visible window's display would ask for.
380 Tick,380 Tick,
381 /// What the window sent itself when it found nothing on its way, at that instant: once it
382 /// arrives, so has every event sent before.
383 Mark(Instant),
381 Appearance(winit::window::Theme),384 Appearance(winit::window::Theme),
382 /// Resizes the window's content, in points.385 /// Resizes the window's content, in points.
383 Resize([f32; 2]),386 Resize([f32; 2]),
...@@ -915,10 +918,13 @@ struct State {...@@ -915,10 +918,13 @@ struct State {
915 zoom_typed: Option<String>,918 zoom_typed: Option<String>,
916 /// Where a replay asked the next frame to be written.919 /// Where a replay asked the next frame to be written.
917 snapshot: Option<PathBuf>,920 snapshot: Option<PathBuf>,
918 /// A replay waiting for nothing to be on its way, and where it wants the accessibility921 /// A replay waiting for nothing to be on its way, where it wants the accessibility tree
919 /// tree written then.922 /// written then, and the mark sent since the window was last found busy.
920 /// Whether the last frame found the window quiet too, which the tree waits for.923 replay_settle: Option<(
921 replay_settle: Option<(Option<PathBuf>, std::sync::mpsc::Sender<()>, bool)>,924 Option<PathBuf>,
925 std::sync::mpsc::Sender<()>,
926 Option<Instant>,
927 )>,
922 /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by928 /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by
923 /// milliseconds since the window opened.929 /// milliseconds since the window opened.
924 frames: Option<(PathBuf, Instant)>,930 frames: Option<(PathBuf, Instant)>,
...@@ -5841,7 +5847,8 @@ fn spawn(work: impl FnOnce() + Send + 'static) {...@@ -5841,7 +5847,8 @@ fn spawn(work: impl FnOnce() + Send + 'static) {
5841 RUNNING.fetch_add(1, Ordering::Relaxed);5847 RUNNING.fetch_add(1, Ordering::Relaxed);
5842 let work = move || {5848 let work = move || {
5843 work();5849 work();
5844 RUNNING.fetch_sub(1, Ordering::Relaxed);5850 // Whatever `work` sent is in the queue before the count shows it done.
5851 RUNNING.fetch_sub(1, Ordering::Release);
5845 };5852 };
5846 #[cfg(not(target_arch = "wasm32"))]5853 #[cfg(not(target_arch = "wasm32"))]
5847 std::thread::spawn(work);5854 std::thread::spawn(work);
...@@ -5919,6 +5926,10 @@ impl State {...@@ -5919,6 +5926,10 @@ impl State {
5919 }5926 }
5920 UserEvent::Redraw => self.window.request_redraw(),5927 UserEvent::Redraw => self.window.request_redraw(),
5921 UserEvent::Replay(replay) => {5928 UserEvent::Replay(replay) => {
5929 let mark = match replay {
5930 Replay::Mark(at) => Some(at),
5931 _ => None,
5932 };
5922 match replay {5933 match replay {
5923 Replay::Input(event) => self.input(event),5934 Replay::Input(event) => self.input(event),
5924 Replay::Pinch(factor) => {5935 Replay::Pinch(factor) => {
...@@ -5928,9 +5939,9 @@ impl State {...@@ -5928,9 +5939,9 @@ impl State {
5928 }5939 }
5929 Replay::Snapshot(path) => self.snapshot = Some(path),5940 Replay::Snapshot(path) => self.snapshot = Some(path),
5930 Replay::Settle(path, settled) => {5941 Replay::Settle(path, settled) => {
5931 self.replay_settle = Some((path, settled, false));5942 self.replay_settle = Some((path, settled, None));
5932 }5943 }
5933 Replay::Tick | Replay::Quit => {}5944 Replay::Tick | Replay::Mark(_) | Replay::Quit => {}
5934 Replay::Appearance(appearance) => {5945 Replay::Appearance(appearance) => {
5935 self.window.set_theme(Some(appearance));5946 self.window.set_theme(Some(appearance));
5936 self.set_appearance(appearance);5947 self.set_appearance(appearance);
...@@ -5945,21 +5956,29 @@ impl State {...@@ -5945,21 +5956,29 @@ impl State {
5945 if let Err(error) = self.frame() {5956 if let Err(error) = self.frame() {
5946 eprintln!("{error}");5957 eprintln!("{error}");
5947 }5958 }
5948 // Quiet on two frames apart, what a thread sent as it ended has been taken.5959 // Quiet still when the mark sent on finding it quiet arrives, what a thread sent
5960 // as it ended has been taken, however many frames wait in the queue before it.
5949 let quiet = self.settled()5961 let quiet = self.settled()
5950 && self.opening.is_none()5962 && self.opening.is_none()
5951 && RUNNING.load(Ordering::Relaxed) == 05963 && RUNNING.load(Ordering::Acquire) == 0
5952 && self.search.pending.load(Ordering::Relaxed) == 0;5964 && self.search.pending.load(Ordering::Relaxed) == 0;
5953 if let Some((path, settled, was)) = self.replay_settle.take() {5965 if let Some((path, settled, sent)) = self.replay_settle.take() {
5954 if !(quiet && was) {5966 match (quiet, sent) {
5955 self.replay_settle = Some((path, settled, quiet));5967 (true, Some(sent)) if mark == Some(sent) => {
5956 } else {5968 if let Some(path) = path
5957 if let Some(path) = path5969 && let Err(error) = self.write_accessibility(&path)
5958 && let Err(error) = self.write_accessibility(&path)5970 {
5959 {5971 eprintln!("{error}");
5960 eprintln!("{error}");5972 }
5973 let _ = settled.send(());
5974 }
5975 (true, None) => {
5976 let now = Instant::now();
5977 let _ = self.proxy.send_event(UserEvent::Replay(Replay::Mark(now)));
5978 self.replay_settle = Some((path, settled, Some(now)));
5961 }5979 }
5962 let _ = settled.send(());5980 (true, sent) => self.replay_settle = Some((path, settled, sent)),
5981 (false, _) => self.replay_settle = Some((path, settled, None)),
5963 }5982 }
5964 }5983 }
5965 }5984 }