diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index a61161557d..3bcd7c32ca 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -1,6 +1,6 @@ //! Autosave and crash recovery. //! -//! A running app keeps one recovery entry in the recovery folder: `.solidcraft` (the design +//! Each open design keeps its own recovery entry in the recovery folder: `.solidcraft` (the design //! as of the last autosave), `.json` (its file path, name and time) and `.lock`, which //! the app holds locked while it runs. Autosaves are written atomically, only when the design //! has unsaved changes it hasn't written yet; a design with nothing unsaved has no entry. An @@ -163,7 +163,7 @@ pub fn remove(dir: &Path, id: &str) -> Result<()> { Ok(()) } -/// One app's autosaver. +/// One open design's autosaver. Keep it with that design when switching tabs. pub struct Autosaver { pub dir: PathBuf, pub id: String, @@ -232,7 +232,7 @@ impl Snapshot { } impl Autosaver { - /// Start autosaving into `dir` (created if needed), holding this app's lock. + /// Start autosaving into `dir` (created if needed), holding this design's lock. pub fn new(dir: &Path, interval: Duration) -> Result { let io = |e: std::io::Error| EngineError::Other(format!("{}: {e}", dir.display())); fs::create_dir_all(dir).map_err(io)?; @@ -361,6 +361,20 @@ impl Autosaver { Ok(()) } + /// The user closed this design (saving it or choosing Don't Save): discard only its entry. + pub fn discard(mut self) { + // A write still running would recreate the entry, without a lock, after it is removed: + // the next launch would offer a design the user closed. + if let Some(w) = self.writing.take() { + let _ = w.done.recv(); + } + for ext in ["json", solidcraft_io::DESIGN_EXT] { + let _ = fs::remove_file(file(&self.dir, &self.id, ext)); + } + self.lock = None; + let _ = fs::remove_file(file(&self.dir, &self.id, "lock")); + } + /// Closing the app: with nothing unsaved the entry goes; otherwise it stays for the next /// launch to offer. The lock is released either way. pub fn close(mut self, s: &Session) { diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 6a9b1c724c..7813661e5e 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -18,7 +18,9 @@ pub const MAX_OPEN: usize = 32; /// A design that is not the active one: everything that belongs to it alone. pub struct DocSlot { - session: Session, + pub(crate) session: Session, + pub(crate) autosave: Option, + pub(crate) big_operation: bool, cam: Camera, pre_sketch_cam: Option, pre_sketch_marker: Option>, @@ -38,7 +40,16 @@ struct DocView { impl DocSlot { fn new(session: Session) -> DocSlot { - DocSlot { session, cam: Camera::default(), pre_sketch_cam: None, pre_sketch_marker: None, view: DocView::default(), occurrence_move: None } + DocSlot { + session, + autosave: None, + big_operation: false, + cam: Camera::default(), + pre_sketch_cam: None, + pre_sketch_marker: None, + view: DocView::default(), + occurrence_move: None, + } } } @@ -58,6 +69,10 @@ pub struct Documents { } impl Documents { + pub(crate) fn background_mut(&mut self) -> impl Iterator { + self.slots.iter_mut().filter_map(Option::as_mut) + } + /// Number of open designs (at least the active one). pub fn count(&self) -> usize { self.slots.len().max(1) @@ -107,14 +122,19 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { app.menu.props = None; crate::sketch_attachment_ui::clear(app); let out_rev = app.session.revision; - let out = DocSlot { + let mut out = DocSlot { session: std::mem::replace(&mut app.session, slot.session), + autosave: app.autosave.as_mut().and_then(|a| std::mem::replace(&mut a.active, slot.autosave)), + big_operation: std::mem::replace(&mut app.big_operation, slot.big_operation), cam: std::mem::replace(&mut app.cam, slot.cam), pre_sketch_cam: std::mem::replace(&mut app.pre_sketch_cam, slot.pre_sketch_cam), pre_sketch_marker: std::mem::replace(&mut app.pre_sketch_marker, slot.pre_sketch_marker), view: take_view(app), occurrence_move: std::mem::replace(&mut app.tree.occurrence_move, slot.occurrence_move), }; + // Only the displayed design owns the process-wide crash replay journal. + app.session.crash_journal = out.session.crash_journal; + out.session.crash_journal = false; put_view(app, slot.view); // Preferences the session holds go with the user, not the tab (#680). app.session.auto_project = out.session.auto_project; @@ -254,6 +274,10 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) { app.docs.closing = None; if n <= 1 { // The last design: a blank one takes its place. + if let Some(a) = app.autosave.as_mut().and_then(|a| a.active.take()) { + a.discard(); + } + app.big_operation = false; crate::sketch_text_move::cancel_drag(app); let rev = app.session.revision; crate::dialogs::cancel(app); @@ -273,7 +297,11 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) { let next = if i + 1 < n { i + 1 } else { i - 1 }; switch(app, next); } - app.docs.slots.remove(i); + if let Some(slot) = app.docs.slots.remove(i) + && let Some(a) = slot.autosave + { + a.discard(); + } if app.docs.active > i { app.docs.active -= 1; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f142dd786a..f2cee33a0f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -430,7 +430,7 @@ pub struct SolveApp { pub status: Option<(String, f64, bool)>, pub quit_requested: bool, /// Autosave into the recovery folder (the desktop app turns it on). - pub autosave: Option, + pub autosave: Option, /// Designs left by crashed apps, offered for recovery. pub recoverable: Vec, /// The bodies of a just-opened Y-up file, offered to be stood up (`stand_up`). diff --git a/crates/ui-egui/src/recovery_ui.rs b/crates/ui-egui/src/recovery_ui.rs index edb337e3a2..ce835e8e50 100644 --- a/crates/ui-egui/src/recovery_ui.rs +++ b/crates/ui-egui/src/recovery_ui.rs @@ -2,6 +2,9 @@ use serde_json::json; use solidcraft_engine::recovery::{self, Autosaver, Entry}; +use solidcraft_engine::{Result, Session}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use crate::SolveApp; use crate::ui_kit; @@ -9,11 +12,71 @@ use crate::ui_kit; /// A command that ran at least this long counts as a big operation: autosave right after it. pub const BIG_OPERATION_MS: f64 = 1500.0; -/// Autosave (when due, or after a big operation). +/// A tab's autosaver has no clock of its own: [`tick`] tells it when its turn has come. +const SHARED_CADENCE: Duration = Duration::MAX; + +/// Shared settings and cadence; each tab owns its own entry and change tracking. +pub struct Autosaves { + pub dir: PathBuf, + pub interval: Duration, + pub(crate) active: Option, + last_at: Instant, +} + +impl Autosaves { + /// Save the active design immediately, including when taking over a recovered entry. + pub fn save(&mut self, session: &Session) -> Result { + save_slot(&self.dir, &mut self.active, session, true, true) + } +} + +/// Bring one tab's autosave up to date. `due`: its turn has come. `wait`: write on this thread +/// and wait for it (Save, close, shutdown); otherwise the write runs on a worker thread, so a +/// scheduled autosave of several large designs never holds up the window (#3044). +fn save_slot(dir: &Path, slot: &mut Option, session: &Session, due: bool, wait: bool) -> Result { + // Clean tabs need no serialization or entry, even between scheduled saves. + if !session.is_dirty() { + if let Some(mut a) = slot.take() { + // Rebase the active crash journal before deleting the autosave it may refer to. + a.save(session)?; + a.discard(); + } + return Ok(false); + } + if !due { + return Ok(false); + } + if slot.is_none() { + *slot = Some(Autosaver::new(dir, SHARED_CADENCE)?); + } + match slot.as_mut() { + Some(a) if wait => a.save(session), + // Skips a tab whose previous write is still running. + Some(a) => a.tick(session, true), + None => Ok(false), + } +} + +/// Autosave every open design on the shared cadence, or a tab after its big operation. pub fn tick(app: &mut SolveApp) { let big = std::mem::take(&mut app.big_operation); let Some(a) = app.autosave.as_mut() else { return }; - if let Err(e) = a.tick(&app.session, big) { + let due = a.last_at.elapsed() >= a.interval; + if due { + a.last_at = Instant::now(); + } + let mut error = None; + for slot in app.docs.background_mut() { + let big = std::mem::take(&mut slot.big_operation); + if let Err(e) = save_slot(&a.dir, &mut slot.autosave, &slot.session, due || big, false) { + error.get_or_insert(e); + } + } + // Background writes must not replace the active design's crash-journal checkpoint. + if let Err(e) = save_slot(&a.dir, &mut a.active, &app.session, due || big, false) { + error.get_or_insert(e); + } + if let Some(e) = error { let msg = format!("autosave failed: {e}"); if app.status.as_ref().is_none_or(|(s, _, _)| *s != msg) { app.set_status(msg, true); @@ -23,10 +86,10 @@ pub fn tick(app: &mut SolveApp) { /// Start autosaving into `dir` and queue the designs that crashed apps left there. pub fn start(app: &mut SolveApp, dir: &std::path::Path, interval: std::time::Duration) { - match Autosaver::new(dir, interval) { + match Autosaver::new(dir, SHARED_CADENCE) { Ok(a) => { app.recoverable = recovery::orphans(dir); - app.autosave = Some(a); + app.autosave = Some(Autosaves { dir: dir.to_path_buf(), interval, active: Some(a), last_at: Instant::now() }); } Err(e) => app.set_status(format!("autosave is off: {e}"), true), } @@ -34,8 +97,17 @@ pub fn start(app: &mut SolveApp, dir: &std::path::Path, interval: std::time::Dur /// The app is closing: keep unsaved work for the next launch, drop everything else. pub fn close(app: &mut SolveApp) { - if let Some(a) = app.autosave.take() { - a.close(&app.session); + if let Some(mut a) = app.autosave.take() { + for slot in app.docs.background_mut() { + let _ = save_slot(&a.dir, &mut slot.autosave, &slot.session, true, true); + if let Some(saver) = slot.autosave.take() { + saver.close(&slot.session); + } + } + let _ = a.save(&app.session); + if let Some(saver) = a.active.take() { + saver.close(&app.session); + } } } @@ -105,8 +177,277 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { #[cfg(test)] mod tests { - use serde_json::json; - use solidcraft_engine::recovery::Entry; + use super::*; + use crate::{Services, documents}; + use std::fs; + + fn app(tag: &str, interval: Duration) -> (SolveApp, PathBuf) { + let dir = std::env::temp_dir().join(format!("solidcraft-recovery-tabs-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + let mut app = SolveApp::new(Session::default(), Services::default()); + start(&mut app, &dir, interval); + (app, dir) + } + + fn edit(app: &mut SolveApp) { + app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + // Tests control the cadence without relying on how long the command took. + app.big_operation = false; + } + + fn entries(dir: &Path) -> Vec { + let mut entries: Vec = fs::read_dir(dir) + .unwrap() + .map(|p| p.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .map(|p| serde_json::from_slice(&fs::read(p).unwrap()).unwrap()) + .collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + entries + } + + /// A tick, then wait for the writes it started on worker threads. Waiting writes nothing + /// new: every design it would write was just written. + fn tick_and_wait(app: &mut SolveApp) { + tick(app); + let Some(a) = app.autosave.as_mut() else { return }; + for slot in app.docs.background_mut() { + if let Some(saver) = slot.autosave.as_mut() { + let _ = saver.save(&slot.session); + } + } + if let Some(saver) = a.active.as_mut() { + let _ = saver.save(&app.session); + } + } + + fn name(app: &mut SolveApp, name: &str) { + app.run("file.new", json!({"name": name})).unwrap(); + } + + #[test] + fn dirty_background_tab_is_saved_and_offered_after_a_crash() { + let (mut app, dir) = app("background", Duration::ZERO); + name(&mut app, "Background A"); + edit(&mut app); + let expected = app.session.doc.clone(); + documents::new_tab(&mut app); + assert!(!app.session.is_dirty()); + tick_and_wait(&mut app); + assert_eq!(entries(&dir).len(), 1); + assert!(recovery::orphans(&dir).is_empty(), "all live entries stay locked"); + drop(app); // Simulate a crash: release locks without a final save. + let restarted = restart(&dir); + assert_eq!(restarted.recoverable.len(), 1); + let entry = &restarted.recoverable[0]; + assert_eq!(entry.name, "Background A"); + assert_eq!(recovery::load(&dir, &entry.id).unwrap().0, *expected); + drop(restarted); + fs::remove_dir_all(dir).unwrap(); + } + + fn restart(dir: &Path) -> SolveApp { + let mut app = SolveApp::new(Session::default(), Services::default()); + start(&mut app, dir, Duration::ZERO); + app + } + + #[test] + fn switching_to_a_clean_tab_keeps_the_background_entry_and_its_id() { + let (mut app, dir) = app("clean-tab", Duration::ZERO); + edit(&mut app); + tick_and_wait(&mut app); + let before = entries(&dir); + documents::new_tab(&mut app); + tick_and_wait(&mut app); + assert_eq!(entries(&dir), before, "the clean tab cannot delete another tab's entry"); + documents::switch(&mut app, 0); + tick_and_wait(&mut app); + assert_eq!(entries(&dir), before, "switching back preserves identity and skips unchanged content"); + drop(app); + assert_eq!(recovery::orphans(&dir), before); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn several_dirty_tabs_keep_distinct_entries_and_recover_their_own_designs() { + let (mut app, dir) = app("several", Duration::ZERO); + let mut expected = Vec::new(); + for i in 0..3 { + if i > 0 { + documents::new_tab(&mut app); + } + name(&mut app, &format!("Design {i}")); + for _ in 0..=i { + edit(&mut app); + } + expected.push(app.session.doc.clone()); + } + tick_and_wait(&mut app); + let saved = entries(&dir); + assert_eq!(saved.len(), 3); + let ids: std::collections::HashSet<_> = saved.iter().map(|e| &e.id).collect(); + assert_eq!(ids.len(), 3); + for i in [0, 2, 1, 0] { + documents::switch(&mut app, i); + tick_and_wait(&mut app); + assert_eq!(entries(&dir), saved); + } + drop(app); + let mut restarted = restart(&dir); + assert_eq!(restarted.recoverable.len(), 3); + for (entry, doc) in saved.iter().zip(expected) { + assert_eq!(recovery::load(&dir, &entry.id).unwrap().0, *doc); + restarted.run("doc.recover", json!({"dir": dir, "id": entry.id})).unwrap(); + assert_eq!(*restarted.session.doc, *doc); + assert!(restarted.session.is_dirty()); + } + drop(restarted); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn saving_and_closing_remove_only_the_affected_tabs_entry() { + let (mut app, dir) = app("cleanup", Duration::ZERO); + for i in 0..3 { + if i > 0 { + documents::new_tab(&mut app); + } + name(&mut app, &format!("Design {i}")); + edit(&mut app); + } + tick_and_wait(&mut app); + let saved = entries(&dir); + // Cleanup is immediate, even when the next autosave is far away. + app.autosave.as_mut().unwrap().interval = Duration::from_secs(3600); + app.run("file.save_as", json!({"path": dir.join("saved.solidcraft")})).unwrap(); + tick_and_wait(&mut app); + assert_eq!(entries(&dir), saved[..2]); + documents::close(&mut app, 0, false); + documents::answer_prompt(&mut app, documents::Answer::Cancel); + assert_eq!(entries(&dir), saved[..2], "cancelling close keeps recovery"); + documents::close(&mut app, 0, true); // Discard a background tab. + assert_eq!(entries(&dir), saved[1..2]); + documents::switch(&mut app, 0); + documents::close(&mut app, 0, true); // Discard the active tab. + assert!(entries(&dir).is_empty()); + edit(&mut app); + app.autosave.as_mut().unwrap().save(&app.session).unwrap(); + assert_eq!(entries(&dir).len(), 1); + documents::close(&mut app, 0, true); // Last tab becomes a fresh blank design. + assert!(entries(&dir).is_empty()); + drop(app); + assert!(recovery::orphans(&dir).is_empty()); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn shutdown_keeps_all_unsaved_tabs_but_confirmed_quit_discards_them() { + for discard in [false, true] { + let (mut app, dir) = app(if discard { "quit" } else { "shutdown" }, Duration::from_secs(3600)); + edit(&mut app); + documents::new_tab(&mut app); + edit(&mut app); + if discard { + // Make entries first, then exercise Don't Save for every design. + app.autosave.as_mut().unwrap().interval = Duration::ZERO; + tick_and_wait(&mut app); + assert_eq!(entries(&dir).len(), 2); + documents::request_quit(&mut app); + documents::answer_prompt(&mut app, documents::Answer::DontSave); + documents::answer_prompt(&mut app, documents::Answer::DontSave); + assert!(app.quit_requested); + } + close(&mut app); + assert_eq!(recovery::orphans(&dir).len(), if discard { 0 } else { 2 }); + assert!(app.docs.background_mut().all(|s| s.autosave.is_none()), "shutdown releases every lock"); + fs::remove_dir_all(dir).unwrap(); + } + } + + #[test] + fn cadence_and_big_operations_follow_the_document_and_preferences() { + let (mut app, dir) = app("cadence", Duration::from_secs(3600)); + app.session.crash_journal = true; + edit(&mut app); + app.big_operation = true; + documents::new_tab(&mut app); + assert!(app.session.crash_journal); + assert!(app.docs.background_mut().all(|s| !s.session.crash_journal)); + assert!(!app.big_operation); + edit(&mut app); + tick_and_wait(&mut app); + assert_eq!(entries(&dir).len(), 1, "only the background big operation is due"); + assert!(app.docs.background_mut().all(|s| !s.big_operation)); + tick_and_wait(&mut app); + assert_eq!(entries(&dir).len(), 1, "the ordinary active edit waits for the interval"); + app.autosave_minutes = 0.00001; + crate::prefs::set_autosave(&mut app); + app.autosave.as_mut().unwrap().last_at = Instant::now() - Duration::from_secs(1); + tick_and_wait(&mut app); + assert_eq!(entries(&dir).len(), 2, "changed preferences apply to every tab"); + drop(app); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn one_failed_autosave_does_not_skip_the_other_tabs() { + let (mut app, dir) = app("failure", Duration::ZERO); + name(&mut app, "A"); + edit(&mut app); + tick_and_wait(&mut app); + let id = entries(&dir)[0].id.clone(); + let blocked = dir.join(format!("{id}.solidcraft")); + fs::remove_file(&blocked).unwrap(); + fs::create_dir(&blocked).unwrap(); + edit(&mut app); + documents::new_tab(&mut app); + name(&mut app, "B"); + edit(&mut app); + // The writes run on worker threads: a failure is reported by a later tick. + let failed = |app: &SolveApp| app.status.as_ref().is_some_and(|(message, _, error)| *error && message.contains("autosave failed")); + let deadline = Instant::now() + Duration::from_secs(60); + tick(&mut app); + while !failed(&app) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + tick(&mut app); + } + assert!(failed(&app)); + tick_and_wait(&mut app); + let saved = entries(&dir); + assert_eq!(saved.len(), 2); + assert_eq!(recovery::load(&dir, &saved[1].id).unwrap().0, *app.session.doc); + // Retrying at the next tick also recovers the previously failing tab. + fs::remove_dir(&blocked).unwrap(); + tick_and_wait(&mut app); + assert_eq!(recovery::load(&dir, &id).unwrap().0.features.len(), 2); + drop(app); + fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn a_tab_closed_while_its_autosave_is_written_leaves_nothing_to_recover() { + let (mut app, dir) = app("discard-race", Duration::ZERO); + name(&mut app, "Closed"); + // Enough of a design that its write is still running when the tab is closed. + for i in 0..40 { + app.run("solid.cylinder", json!({"radius": 3, "height": 5, "base": [i * 8, 0, 0], "operation": "new"})).unwrap(); + } + app.big_operation = false; + documents::new_tab(&mut app); + tick(&mut app); // Starts writing the background tab on a worker thread. + documents::close(&mut app, 0, true); // Don't Save, right away. + let files = || fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).map(|e| e.file_name()).collect::>(); + // A write that outlived the close would recreate the entry; give it time to. + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + assert!(entries(&dir).is_empty(), "the closed design came back: {:?}", files()); + std::thread::sleep(Duration::from_millis(20)); + } + drop(app); + assert!(recovery::orphans(&dir).is_empty()); + fs::remove_dir_all(dir).unwrap(); + } fn entry(id: &str, name: &str) -> Entry { Entry { id: id.into(), path: None, name: name.into(), saved_at: 0, features: 3 } diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 01ad7c13a9..63b9eb104c 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -62,8 +62,9 @@ written as STL files next to the `.urdf`. See [`docs/urdf.md`](https://github.co Saves are atomic: a crash at any moment leaves either the old file or the new one, and the replaced version is kept as `.bak`. A save keeps the file's permissions, and a design opened through a symbolic link (a synced folder linked into a project) is saved into the file the -link points to, so the link stays. Unsaved changes are autosaved to a recovery folder, -and the next launch offers to recover them. Older design files are upgraded on open, and files +link points to, so the link stays. Unsaved changes in every open tab are autosaved to separate +entries in a recovery folder, and the next launch offers to recover each design. Saving or +closing one tab removes only its recovery entry. Older design files are upgraded on open, and files from a newer version are refused with a message. **Save** writes a container by default; files named `.json` keep JSON on subsequent saves.