diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index 9ff7eab0b..92e97afeb 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -4,15 +4,18 @@ //! autosave is written as DXF to `/__.sv$`. The file goes //! away when the drawing is saved or closed and when the app exits normally, so a file left behind //! means the app (or the machine) went down; the next start offers it for recovery. A running app -//! keeps a lock file (`.lock`) fresh, so another copy running at the same time doesn't -//! offer its files. SAVEFILEPATH may be a folder other programs use too, so only names built from -//! an instance id ([`is_instance_id`]) are ever read as ours or deleted. +//! holds an operating-system lock on its lock file (`.lock`) and keeps the file fresh, so +//! another copy running at the same time doesn't offer its files. The system drops the lock the +//! moment a process ends, however it ended, so a relaunch after a crash can offer recovery at once. +//! Where locks aren't available, and for lock files of older versions, a file not refreshed for +//! [`STALE_LOCK_SECS`] counts as stopped. SAVEFILEPATH may be a folder other programs use too, so +//! only names built from an instance id ([`is_instance_id`]) are ever read as ours or deleted. //! //! [`Autosaver::plan`] decides what to write and delete (no I/O, so it is testable); on the desktop //! [`Autosaver::tick`] carries it out, writing on a background thread. SAVETIME and SAVEFILEPATH //! are profile settings: process-wide, not saved in the drawing. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{Arc, Mutex, Weak}; @@ -29,6 +32,10 @@ pub const DEFAULT_SAVETIME: u32 = 10; pub const MAX_SAVETIME: u32 = 600; /// A lock file not refreshed for this long belongs to an app that is no longer running. pub const STALE_LOCK_SECS: u64 = 5 * 60; +/// How a lock file ends when its app holds the system lock on it, so a free lock proves the app +/// stopped. Older versions write only the process id and never hold one. +#[cfg(not(target_arch = "wasm32"))] +const LOCK_HELD: &str = " held"; /// How often a running app refreshes its lock file. #[cfg(not(target_arch = "wasm32"))] const HEARTBEAT_SECS: f64 = 60.0; @@ -149,13 +156,19 @@ pub struct Autosaver { docs: HashMap, /// Drawings whose autosave is being written right now. busy: HashSet, - /// Writes in progress; each tells whether its file was written. + /// Why the last autosave of each drawing failed, until one succeeds or the drawing no longer + /// needs it. Ordered, so [`Autosaver::failure`] doesn't change between frames. + failed: BTreeMap, + /// Writes in progress; each ends with why its file wasn't written, if it wasn't. #[cfg(not(target_arch = "wasm32"))] - running: Vec<(u64, std::thread::JoinHandle)>, + running: Vec<(u64, std::thread::JoinHandle>)>, #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant, #[cfg(not(target_arch = "wasm32"))] last_beat: Option, + /// The lock file this app holds the system lock on, open for as long as the lock is wanted. + #[cfg(not(target_arch = "wasm32"))] + lock: Option<(std::path::PathBuf, std::fs::File)>, } impl Autosaver { @@ -164,12 +177,15 @@ impl Autosaver { instance: instance.into(), docs: HashMap::new(), busy: HashSet::new(), + failed: BTreeMap::new(), #[cfg(not(target_arch = "wasm32"))] running: Vec::new(), #[cfg(not(target_arch = "wasm32"))] start: std::time::Instant::now(), #[cfg(not(target_arch = "wasm32"))] last_beat: None, + #[cfg(not(target_arch = "wasm32"))] + lock: None, } } @@ -187,6 +203,11 @@ impl Autosaver { self.docs.values().filter_map(|t| t.file.clone()).collect() } + /// Why a drawing currently has no up-to-date recovery copy, for the host to show the user. + pub fn failure(&self) -> Option<&str> { + self.failed.values().next().map(String::as_str) + } + /// What to do at `now` (seconds, any fixed origin) with SAVETIME `minutes` and autosave folder /// `dir`: write drawings changed `minutes` ago or earlier and not autosaved since, delete the /// files of drawings that were saved (no unsaved changes left) or closed. @@ -220,6 +241,7 @@ impl Autosaver { jobs.push(Job::Delete { path }); } *t = Tracked::default(); + self.failed.remove(&st.uid); continue; } let same = |w: &Option>, d: &Arc| w.as_ref().is_some_and(|w| std::ptr::eq(w.as_ptr(), Arc::as_ptr(d))); @@ -248,6 +270,8 @@ impl Autosaver { t.since = None; jobs.push(Job::Write { uid: st.uid, path, doc }); } + let docs = &self.docs; + self.failed.retain(|uid, _| docs.contains_key(uid)); jobs } @@ -277,12 +301,14 @@ pub use native::*; #[cfg(not(target_arch = "wasm32"))] mod native { + use std::fs::{File, OpenOptions, TryLockError}; + use std::io::{Read, Write}; use std::path::Path; use std::time::{Duration, SystemTime}; use super::{ - Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, parse_lock_name, - savefilepath, savetime, + Autosaver, Drawing, HEARTBEAT_SECS, HashMap, Job, LOCK_HELD, Recoverable, STALE_LOCK_SECS, Session, is_instance_id, parse_file_name, + parse_lock_name, savefilepath, savetime, }; /// A new instance id: the process id and the start time. @@ -307,6 +333,28 @@ mod native { Some(SystemTime::now().duration_since(m).unwrap_or_default()) } + /// Whether the app instance named by a lock file in `dir` may still be running: its system + /// lock answers at once, and the file's age decides when it can't (no lock support, a lock + /// file from an older version). + fn owner_alive(dir: &Path, instance: &str) -> bool { + let by_age = || instance_alive(lock_age(dir, instance)); + let path = lock_path(dir, instance); + // Only a plain file is opened: opening a FIFO planted in a shared folder would block. + if !std::fs::symlink_metadata(&path).is_ok_and(|m| m.is_file()) { + return by_age(); + } + let Ok(file) = File::open(&path) else { return by_age() }; + match file.try_lock() { + Err(TryLockError::WouldBlock) => true, + Err(TryLockError::Error(_)) => by_age(), + Ok(()) => { + let mut text = String::new(); + let held = file.take(64).read_to_string(&mut text).is_ok() && text.ends_with(LOCK_HELD); + !held && by_age() + } + } + } + /// The autosave files in `dir` left behind by apps that are no longer running (never those of /// `own`, this app's instance), oldest first. pub fn recoverable(dir: &Path, own: &str) -> Vec { @@ -316,7 +364,7 @@ mod native { for e in entries.flatten().take(10_000) { let name = e.file_name().to_string_lossy().to_string(); let Some((title, instance, _)) = parse_file_name(&name) else { continue }; - if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| instance_alive(lock_age(dir, instance))) { + if instance == own || *alive.entry(instance.to_string()).or_insert_with(|| owner_alive(dir, instance)) { continue; } let Ok(meta) = e.metadata() else { continue }; @@ -358,7 +406,7 @@ mod native { for n in &names { let Some(instance) = parse_lock_name(n) else { continue }; let has_files = names.iter().any(|f| parse_file_name(f).is_some_and(|(_, i, _)| i == instance)); - if !has_files && !instance_alive(lock_age(dir, instance)) { + if !has_files && !owner_alive(dir, instance) { let _ = std::fs::remove_file(dir.join(n)); } } @@ -425,22 +473,27 @@ mod native { /// Take in the writes that have finished ([`Autosaver::tick`] does this first). A drawing /// whose write failed is no longer counted as autosaved: the next plan restarts its clock, - /// so it is tried again SAVETIME later even if it doesn't change. + /// so it is tried again SAVETIME later even if it doesn't change, and [`Autosaver::failure`] + /// says why until a write succeeds. pub fn reap(&mut self) { let (done, running) = std::mem::take(&mut self.running).into_iter().partition::, _>(|(_, h)| h.is_finished()); self.running = running; for (uid, h) in done { self.busy.remove(&uid); - if !h.join().unwrap_or(false) { - self.unwritten(uid); + match h.join().unwrap_or_else(|_| Err("the autosave writer stopped unexpectedly".to_string())) { + Ok(()) => { + self.failed.remove(&uid); + } + Err(e) => self.unwritten(uid, e), } } } - fn unwritten(&mut self, uid: u64) { + fn unwritten(&mut self, uid: u64, why: String) { if let Some(t) = self.docs.get_mut(&uid) { t.written = None; } + self.failed.insert(uid, why); } /// Carry out jobs from [`Autosaver::plan`]: writes on a background thread, deletes now. @@ -451,16 +504,17 @@ mod native { Job::Write { uid, path, doc } => { let Some(write) = crate::cmd::file::io().map(|h| h.write) else { log::warn!("autosave: file formats are not available in this build"); + self.unwritten(uid, "file formats are not available in this build".to_string()); continue; }; let spawned = std::thread::Builder::new().name("autosave".into()).spawn(move || match write_file(write, &doc, &path) { Ok(()) => { log::info!("autosaved {path}"); - true + Ok(()) } Err(e) => { log::warn!("autosave {path}: {e}"); - false + Err(format!("{path}: {e}")) } }); match spawned { @@ -470,7 +524,7 @@ mod native { } Err(e) => { log::warn!("autosave: {e}"); - self.unwritten(uid); + self.unwritten(uid, e.to_string()); } } } @@ -485,12 +539,31 @@ mod native { } self.last_beat = Some(now); let dir = Path::new(dir); - let r = create_folder(dir).and_then(|()| std::fs::write(lock_path(dir, &self.instance), std::process::id().to_string())); - if let Err(e) = r { + if let Err(e) = create_folder(dir).and_then(|()| self.refresh_lock(&lock_path(dir, &self.instance))) { log::warn!("autosave: lock file in {}: {e}", dir.display()); } } + /// Refresh the lock file at `path`, taking the system lock on it first if this app doesn't + /// hold it there yet. Without one the file still works by its age. + fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> { + if let Some((held, file)) = &self.lock + && held == path + { + return file.set_modified(SystemTime::now()); + } + self.lock = None; + let id = std::process::id(); + let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?; + if file.try_lock().is_err() { + return std::fs::write(path, id.to_string()); + } + file.set_len(0)?; + file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?; + self.lock = Some((path.to_path_buf(), file)); + Ok(()) + } + /// The app is closing normally: finish writes in progress, then delete this app's autosave /// files and its lock file. pub fn shutdown(&mut self) { @@ -502,6 +575,7 @@ mod native { discard(&path); } self.docs.clear(); + self.lock = None; let dir = savefilepath(); if !dir.is_empty() && is_instance_id(&self.instance) { let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance)); diff --git a/crates/engine/tests/autosave.rs b/crates/engine/tests/autosave.rs index f44be5231..32d21fd05 100644 --- a/crates/engine/tests/autosave.rs +++ b/crates/engine/tests/autosave.rs @@ -169,6 +169,7 @@ fn recovery_offers_files_of_apps_that_stopped() { #[test] fn autosave_files_are_written_in_the_background_and_removed_on_exit() { + let _turn = folder_turn(); hooks(); let dir = temp_dir("write"); let mut s = Session::new(); @@ -255,6 +256,68 @@ fn a_new_autosave_folder_is_owner_only() { let _ = std::fs::remove_dir_all(&base); } +/// SAVEFILEPATH is process-wide: the tests that set it take turns. +static FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn folder_turn() -> std::sync::MutexGuard<'static, ()> { + FOLDER.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Run autosave once in `dir`, so `a` writes a file and takes its lock, and wait for the file. +fn autosave_once(a: &mut Autosaver, s: &Session, dir: &std::path::Path) -> String { + autosave::set_savefilepath(&dir.to_string_lossy()); + assert_eq!(a.save_now(s), 1); + for _ in 0..500 { + a.reap(); + if !a.writing() && !a.files().is_empty() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + a.files().pop().unwrap() +} + +#[test] +fn a_crashed_app_is_offered_at_once_and_a_running_one_never() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("instant"); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("41-e"); + let file = autosave_once(&mut a, &s, &dir); + let lock = dir.join("41-e.lock"); + assert!(std::fs::metadata(&lock).is_ok()); + + // Running: not offered, even once its lock file looks old (a suspended laptop). + std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now() - Duration::from_secs(3600)).ok(); + assert!(autosave::recoverable(&dir, "9-f").is_empty()); + autosave::prune_locks(&dir); + assert!(lock.exists(), "a running app's lock stays"); + + // Gone without closing, as in a crash: the file is fresh, and it is offered right away. + std::fs::File::options().write(true).open(&lock).unwrap().set_modified(SystemTime::now()).ok(); + drop(a); + let found = autosave::recoverable(&dir, "9-f"); + assert_eq!(found.iter().map(|r| r.path.as_str()).collect::>(), vec![file.as_str()]); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_normal_exit_removes_the_lock_file() { + hooks(); + let _turn = folder_turn(); + let dir = temp_dir("exit"); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("42-e"); + autosave_once(&mut a, &s, &dir); + assert!(dir.join("42-e.lock").exists()); + a.shutdown(); + assert!(!dir.join("42-e.lock").exists()); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_failed_autosave_is_tried_again_without_another_change() { hooks(); @@ -283,6 +346,7 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert!(rearmed, "a failed autosave must be tried again"); + assert!(a.failure().is_some_and(|m| m.contains("out")), "{:?}", a.failure()); std::fs::remove_file(&out).unwrap(); let jobs = a.plan(&s, 180.0, 1, &dir); @@ -303,6 +367,36 @@ fn a_failed_autosave_is_tried_again_without_another_change() { std::thread::sleep(Duration::from_millis(10)); } assert_eq!(a.next_due(1e4, 1), None); + assert_eq!(a.failure(), None, "a write that worked clears the failure"); + a.shutdown(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_failure_ends_when_the_drawing_no_longer_needs_a_copy() { + hooks(); + let root = temp_dir("failed-close"); + let out = root.join("out"); + std::fs::write(&out, b"not a folder").unwrap(); + let dir = out.to_string_lossy().to_string(); + let mut s = Session::new(); + draw(&mut s); + let mut a = Autosaver::new("32-e"); + a.plan(&s, 0.0, 1, &dir); + let jobs = a.plan(&s, 60.0, 1, &dir); + a.perform(jobs); + for _ in 0..500 { + a.reap(); + if a.failure().is_some() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(a.failure().is_some()); + let i = s.active; + s.execute("close", &json!({ "index": i })).unwrap(); + a.plan(&s, 120.0, 1, &dir); + assert_eq!(a.failure(), None); a.shutdown(); let _ = std::fs::remove_dir_all(&root); } diff --git a/crates/ui-egui/src/autosave.rs b/crates/ui-egui/src/autosave.rs index 012231683..d09d95770 100644 --- a/crates/ui-egui/src/autosave.rs +++ b/crates/ui-egui/src/autosave.rs @@ -32,6 +32,15 @@ pub fn load_prefs(json: &str) { } } +/// What the autosave failure notice says (see [`failure_notice`]). +#[derive(Clone, Debug, PartialEq)] +pub struct AutosaveNotice { + pub message: String, + /// The user closed it. The failure stays recorded so the notice doesn't come back on every + /// retry; it does for the next failure after a write has worked. + pub dismissed: bool, +} + /// What the GPU error notice says (see [`gpu_notice`]). #[derive(Clone, Debug, PartialEq)] pub struct GpuNotice { @@ -90,6 +99,24 @@ impl CadApp { if let Some(wait) = self.autosave.as_mut().and_then(|s| s.tick(&self.session)) { _ctx.request_repaint_after(wait); } + #[cfg(not(target_arch = "wasm32"))] + self.note_autosave_failure(_ctx); + } + + /// Keep [`Self::autosave_notice`] in step with autosave: a new failure is said once at the + /// command line and shown until it is dismissed or a write works. + #[cfg(not(target_arch = "wasm32"))] + fn note_autosave_failure(&mut self, ctx: &egui::Context) { + let failure = self.autosave.as_ref().and_then(|s| s.failure()).map(str::to_string); + match (failure, &mut self.autosave_notice) { + (None, notice) => *notice = None, + (Some(message), Some(n)) => n.message = message, + (Some(message), notice) => { + self.session.echo(format!("Autosave failed: {message}")); + *notice = Some(AutosaveNotice { message, dismissed: false }); + ctx.request_repaint(); + } + } } /// Every frame: after a GPU error or device loss, autosave unsaved drawings at once (the @@ -242,6 +269,37 @@ pub fn gpu_notice(app: &mut CadApp, ctx: &egui::Context) { } } +/// The notice that autosave isn't working, so unsaved changes have no recovery copy. It doesn't +/// block the drawing: the user needs to be able to save. +pub fn failure_notice(app: &mut CadApp, ctx: &egui::Context) { + let Some(n) = app.autosave_notice.as_ref().filter(|n| !n.dismissed) else { return }; + let t = Tokens::get(); + let mut dismiss = false; + egui::Window::new("Autosave failed") + .id(egui::Id::unique("cc_autosave_notice")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_TOP, vec2(0.0, 48.0)) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label("CADCraft could not write a recovery copy, so recent changes would be lost if the app stopped now."); + ui.label(RichText::new("Save your work. Autosave tries again after the SAVETIME interval; its folder is in Options.").strong()); + ui.add_space(4.0); + ui.collapsing("Details", |ui| { + ui.label(RichText::new(&n.message).color(t.text_dim).monospace()); + }); + // A row just as tall as the button (a plain right-to-left layout takes the rest). + ui.allocate_ui_with_layout(vec2(ui.available_width(), 0.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Dismiss").clicked() { + dismiss = true; + } + }); + }); + if dismiss && let Some(n) = &mut app.autosave_notice { + n.dismissed = true; + } +} + #[cfg(test)] mod tests { #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 142a589a3..03fa5285b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -305,6 +305,8 @@ pub struct CadApp { pub gpu_faults: std::sync::Arc, /// The notice shown after one (see [`autosave::gpu_notice`]). pub gpu_notice: Option, + /// The notice shown while autosave fails (see [`autosave::failure_notice`]). + pub autosave_notice: Option, /// The Command Aliases dialog while open ([`aliasdlg`]). pub alias_dlg: Option, /// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]). @@ -364,6 +366,7 @@ impl CadApp { recovery: autosave::Recovery::default(), gpu_faults: std::sync::Arc::default(), gpu_notice: None, + autosave_notice: None, alias_dlg: None, hatch_dlg: None, bedit: bedit::State::default(), @@ -903,6 +906,7 @@ impl CadApp { save_loss::prompt(self, ui.ctx()); autosave::show(self, ui.ctx()); autosave::gpu_notice(self, ui.ctx()); + autosave::failure_notice(self, ui.ctx()); self.frame_ms = now_ms() - t0; } diff --git a/crates/ui-egui/tests/autosave_failure.rs b/crates/ui-egui/tests/autosave_failure.rs new file mode 100644 index 000000000..28b6a66d0 --- /dev/null +++ b/crates/ui-egui/tests/autosave_failure.rs @@ -0,0 +1,82 @@ +//! A failed autosave reaches the user: a notice and one command-line message, gone once a write +//! works. SAVEFILEPATH is process-wide, so everything runs in one test. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::{CadApp, Services}; + +fn wait_for(app: &mut CadApp, ctx: &egui::Context, done: impl Fn(&CadApp) -> bool) { + for _ in 0..500 { + app.autosave_tick(ctx); + if done(app) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +/// The text the notice paints (the second frame: a new window sizes itself in the first). +fn painted_text(app: &mut CadApp, ctx: &egui::Context) -> String { + fn collect(shape: &egui::Shape, text: &mut String) { + match shape { + egui::Shape::Text(t) => text.push_str(t.galley.text()), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, text)), + _ => {} + } + } + let mut text = String::new(); + for _ in 0..2 { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| cadcraft_ui_egui::autosave::failure_notice(app, ui.ctx())); + out.textures_delta.clear(); // no renderer here + text.clear(); + out.shapes.iter().for_each(|s| collect(&s.shape, &mut text)); + } + text +} + +#[test] +fn a_failed_autosave_is_shown_until_a_write_works() { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(b"0\nEOF\n".to_vec()), plot: None }); + let root = std::env::temp_dir().join(format!("cadcraft-autosave-failure-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + // The folder can't be created while a file holds its name. + let dir = root.join("out"); + std::fs::write(&dir, b"not a folder").unwrap(); + cadcraft_engine::autosave::set_savefilepath(&dir.to_string_lossy()); + + let mut app = CadApp::new(Session::new(), Services::default()); + app.enable_autosave(&dir); + let ctx = egui::Context::default(); + app.autosave_tick(&ctx); + assert!(app.autosave_notice.is_none(), "nothing failed yet"); + + app.run("circle", serde_json::json!({"center": [0, 0], "radius": 1})).unwrap(); + app.gpu_faults.error("forces an autosave now".into()); + app.gpu_fault_tick(&ctx); + wait_for(&mut app, &ctx, |a| a.autosave_notice.is_some()); + let n = app.autosave_notice.clone().expect("the failure reaches the notice"); + assert!(!n.dismissed && n.message.contains("out"), "{n:?}"); + let said = |app: &CadApp| app.session.log.iter().filter(|l| l.starts_with("Autosave failed:")).count(); + assert_eq!(said(&app), 1); + assert!(painted_text(&mut app, &ctx).contains("could not write a recovery copy")); + + // Dismissed, it stays away through retries and isn't repeated at the command line. + app.autosave_notice.as_mut().unwrap().dismissed = true; + app.autosave_tick(&ctx); + assert!(app.autosave_notice.as_ref().is_some_and(|n| n.dismissed)); + assert!(painted_text(&mut app, &ctx).is_empty()); + assert_eq!(said(&app), 1); + + // The folder comes back: the next write clears it. + std::fs::remove_file(&dir).unwrap(); + app.run("circle", serde_json::json!({"center": [5, 0], "radius": 1})).unwrap(); + app.gpu_faults.error("forces an autosave now".into()); + app.gpu_fault_tick(&ctx); + wait_for(&mut app, &ctx, |a| a.autosave_notice.is_none()); + assert!(app.autosave_notice.is_none(), "a write that worked ends the notice"); + + app.shutdown_autosave(); + let _ = std::fs::remove_dir_all(&root); +}