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 {...@@ -6565,7 +6565,7 @@ impl App {
6565 .map(|outline| (&outline.id, outline.document())));6565 .map(|outline| (&outline.id, outline.document())));
6566 state.session.is_none() && !unchanged6566 state.session.is_none() && !unchanged
6567 }) else {6567 }) else {
6568 return self.exit(event_loop);6568 return event_loop.exit();
6569 };6569 };
6570 crate::confirm(6570 crate::confirm(
6571 "Discard this page?",6571 "Discard this page?",
...@@ -6578,19 +6578,12 @@ impl App {...@@ -6578,19 +6578,12 @@ impl App {
6578 }),6578 }),
6579 );6579 );
6580 }6580 }
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 }
6588}6581}
65896582
6590/// How long quitting waits for the open section's edits to reach its file; they stay in the6583/// How long quitting waits for the open section's edits to reach its file and for every
6591/// replica and publish at the next launch otherwise.6584/// section to close; edits left stay in the replica and publish at the next launch.
6592#[cfg(not(target_arch = "wasm32"))]6585#[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
6595#[cfg(not(target_arch = "wasm32"))]6588#[cfg(not(target_arch = "wasm32"))]
6596impl ApplicationHandler<UserEvent> for App {6589impl ApplicationHandler<UserEvent> for App {
...@@ -6610,7 +6603,7 @@ impl ApplicationHandler<UserEvent> for App {...@@ -6610,7 +6603,7 @@ impl ApplicationHandler<UserEvent> for App {
6610 };6603 };
6611 match event {6604 match event {
6612 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop),6605 UserEvent::Quit | UserEvent::Replay(Replay::Quit) => self.close(event_loop),
6613 UserEvent::Exit => self.exit(event_loop),6606 UserEvent::Exit => event_loop.exit(),
6614 UserEvent::Renderer(chosen) | UserEvent::Replay(Replay::Renderer(chosen)) => {6607 UserEvent::Renderer(chosen) | UserEvent::Replay(Replay::Renderer(chosen)) => {
6615 let state = self.state.take().expect("The window is open");6608 let state = self.state.take().expect("The window is open");
6616 match state.switch_renderer(chosen) {6609 match state.switch_renderer(chosen) {
...@@ -6831,10 +6824,37 @@ impl ApplicationHandler<UserEvent> for App {...@@ -6831,10 +6824,37 @@ impl ApplicationHandler<UserEvent> for App {
6831 /// Drops the window's state while the event loop still holds the Wayland connection the6824 /// Drops the window's state while the event loop still holds the Wayland connection the
6832 /// clipboard and the surface use; `run_app` closes it on returning.6825 /// clipboard and the surface use; `run_app` closes it on returning.
6833 fn exiting(&mut self, _: &ActiveEventLoop) {6826 fn exiting(&mut self, _: &ActiveEventLoop) {
6834 self.restart = self6827 let Some(mut state) = self.state.take() else {
6835 .state6828 return;
6836 .take()6829 };
6837 .and_then(|state| state.updates.restarting());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()));
6838 }6858 }
6839}6859}
68406860
crates/snowbound/tests/replay.rs+34
...@@ -589,6 +589,40 @@ fn renaming_a_section_from_its_tab_types_into_the_tab_at_once() {...@@ -589,6 +589,40 @@ fn renaming_a_section_from_its_tab_types_into_the_tab_at_once() {
589 assert!(renamed.contains(r#"Tab "Renamed""#), "{renamed}");589 assert!(renamed.contains(r#"Tab "Renamed""#), "{renamed}");
590}590}
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
592fn stored_pages(path: &Path) -> Vec<(onestore::ExGuid, onestore::page::Page, u32)> {626fn stored_pages(path: &Path) -> Vec<(onestore::ExGuid, onestore::page::Page, u32)> {
593 let bytes = std::fs::read(path).unwrap();627 let bytes = std::fs::read(path).unwrap();
594 let store = onestore::Store::parse(&bytes).unwrap();628 let store = onestore::Store::parse(&bytes).unwrap();