authorclover caruso <git@paperclover.net> 2026-10-15 08:42:50-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 09:01:20-07:00
log171ffc3acdb21c0fe1b49e13481ce7f7f83fbec1
tree712525f53dc0ffc321c6f89095918b5929b45747
parent0641110e7a154fd20595e7fd290ef572c8a36235
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(snowbound): quitting closes every section before the process ends

Quitting dropped the window's state and returned, but dropping a section or a notebook's background only asks its thread to stop. The sync worker holds the replica until its current step ends, so the process could end first: the replica's connection never closed, leaving its write-ahead log beside it, and a step publishing to a share could be cut short. Quitting now stops each notebook's background, closes the section shown and the sections kept open, and waits for them within the three seconds it already gave the shown section to publish. A replay test edits two sections, quits, and finds both edits in their files and no log left. Assisted-by: claude-opus-5.5

2 files changed, 70 insertions(+), 16 deletions(-)

crates/snowbound/src/main.rs+36-16
......@@ -6565,7 +6565,7 @@ impl App {
65656565 .map(|outline| (&outline.id, outline.document())));
65666566 state.session.is_none() && !unchanged
65676567 }) else {
6568 return self.exit(event_loop);
6568 return event_loop.exit();
65696569 };
65706570 crate::confirm(
65716571 "Discard this page?",
......@@ -6578,19 +6578,12 @@ impl App {
65786578 }),
65796579 );
65806580 }
6581
6582 fn exit(&self, event_loop: &ActiveEventLoop) {
6583 if let Some(state) = &self.state {
6584 state.publish_now(QUIT_PUBLISH);
6585 }
6586 event_loop.exit();
6587 }
65886581}
65896582
6590/// How long quitting waits for the open section's edits to reach its file; they stay in the
6591/// replica and publish at the next launch otherwise.
6583/// How long quitting waits for the open section's edits to reach its file and for every
6584/// section to close; edits left stay in the replica and publish at the next launch.
65926585#[cfg(not(target_arch = "wasm32"))]
6593const QUIT_PUBLISH: std::time::Duration = std::time::Duration::from_secs(3);
6586const QUIT: std::time::Duration = std::time::Duration::from_secs(3);
65946587
65956588#[cfg(not(target_arch = "wasm32"))]
65966589impl ApplicationHandler<UserEvent> for App {
......@@ -6610,7 +6603,7 @@ impl ApplicationHandler<UserEvent> for App {
66106603 };
66116604 match event {
66126605 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop),
6613 UserEvent::Exit => self.exit(event_loop),
6606 UserEvent::Exit => event_loop.exit(),
66146607 UserEvent::Renderer(chosen) | UserEvent::Replay(Replay::Renderer(chosen)) => {
66156608 let state = self.state.take().expect("The window is open");
66166609 match state.switch_renderer(chosen) {
......@@ -6831,10 +6824,37 @@ impl ApplicationHandler<UserEvent> for App {
68316824 /// Drops the window's state while the event loop still holds the Wayland connection the
68326825 /// clipboard and the surface use; `run_app` closes it on returning.
68336826 fn exiting(&mut self, _: &ActiveEventLoop) {
6834 self.restart = self
6835 .state
6836 .take()
6837 .and_then(|state| state.updates.restarting());
6827 let Some(mut state) = self.state.take() else {
6828 return;
6829 };
6830 let deadline = Instant::now() + QUIT;
6831 state.publish_now(QUIT);
6832 self.restart = state.updates.restarting();
6833 let session = state.session.take();
6834 let libraries: Vec<_> = (state.notebooks.iter())
6835 .chain(session.as_ref().map(|session| &session.library))
6836 .cloned()
6837 .collect();
6838 // Dropped sections and backgrounds stop without waiting, so the process could end first.
6839 let (closed, closing) = mpsc::channel::<()>();
6840 std::thread::spawn(move || {
6841 // Backgrounds first, or a released section would be checked again.
6842 for background in libraries
6843 .iter()
6844 .filter_map(|library| library.background.as_ref())
6845 {
6846 background.stop();
6847 }
6848 if let Some(session) = session
6849 && let Err(error) = session.section.close()
6850 {
6851 eprintln!("Synchronization stopped: {error}");
6852 }
6853 libraries.iter().for_each(|library| library.close_kept());
6854 drop(closed);
6855 });
6856 drop(state);
6857 let _ = closing.recv_timeout(deadline.saturating_duration_since(Instant::now()));
68386858 }
68396859}
68406860
crates/snowbound/tests/replay.rs+34
......@@ -589,6 +589,40 @@ fn renaming_a_section_from_its_tab_types_into_the_tab_at_once() {
589589 assert!(renamed.contains(r#"Tab "Renamed""#), "{renamed}");
590590}
591591
592/// Quitting publishes the edits of the section shown and the one left, and closes both
593/// replicas, so SQLite leaves no write-ahead log beside them.
594#[test]
595fn quitting_publishes_and_closes_every_section() {
596 let scratch = Scratch::new("quit-closes");
597 let notebook =
598 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/native/notebook");
599 let type_into = |text| ["move 900 600", "press", "release", text, "settle"];
600 let mut steps = type_into("type LEFT_QA").to_vec();
601 steps.extend(["access click Cross", "settle"]);
602 steps.extend(type_into("type SHOWN_QA"));
603 replay(&scratch, Some(&notebook), &steps);
604 for (file, text) in [("synthetic.one", "LEFT_QA"), ("Cross.one", "SHOWN_QA")] {
605 let pages = stored_pages(&scratch.0.join("notebook").join(file));
606 assert!(
607 pages
608 .iter()
609 .any(|(_, page, _)| body_text(page).iter().any(|body| body == text)),
610 "{file} lacks {text}"
611 );
612 }
613 let replicas = std::fs::read_dir(scratch.0.join("cache/replicas"))
614 .unwrap()
615 .flat_map(|folder| std::fs::read_dir(folder.unwrap().path()).unwrap())
616 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
617 .filter(|name| name.contains(".sqlite"))
618 .collect::<Vec<_>>();
619 assert_eq!(replicas.len(), 2, "{replicas:?}");
620 assert!(
621 replicas.iter().all(|name| name.ends_with(".sqlite")),
622 "{replicas:?}"
623 );
624}
625
592626fn stored_pages(path: &Path) -> Vec<(onestore::ExGuid, onestore::page::Page, u32)> {
593627 let bytes = std::fs::read(path).unwrap();
594628 let store = onestore::Store::parse(&bytes).unwrap();