Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 897d86b865 Multilines: test that a run of coincident vertices at a corner takes the corner's miter 2026-11-02 21:22:59 -05:00
Dylan M. Taylor ef56e1be8c Multilines: find each segment's usable neighbour direction in two linear passes
MLine::miters searched the direction array forwards and backwards from every
vertex for the nearest non-degenerate segment, so a long run of coincident
vertices made it quadratic. The nearest usable directions ahead and behind
are now computed once, in one backward and one forward pass (CWE-407).
2026-11-02 20:42:50 -05:00
Tanish Manku fdfe4d95a8 Autosave: failures are shown, and a crash is recovered at the first relaunch (#2670)
* Autosave: a failed write is shown until one works (E6)

A failure only reached the log, so someone could work for hours with no
recovery copy. The autosaver keeps why each drawing's write failed; the app
says it once at the command line and shows a notice that doesn't block saving.
It ends when a write works or the drawing no longer needs a copy.

* Crash recovery: offered at the first relaunch, not 5 minutes later (E5)

A crashed app's lock file looked alive until it went stale, so a relaunch
within 5 minutes skipped its autosaves, and a suspended app's looked dead.
A running app now holds a system lock on the file, which the OS drops however
the process ends. File age still decides where locks don't work and for lock
files of older versions, which don't record holding one.
2026-11-02 18:06:35 -07:00
6 changed files with 429 additions and 29 deletions
+98 -10
View File
@@ -207,6 +207,41 @@ pub struct MLineGeometry {
pub fill: Vec<Vec<Vec2>>,
}
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
///
/// Two linear passes: searching from every segment separately is quadratic in a run of
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
let n = dirs.len();
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
return (vec![Vec2::X; n], vec![Vec2::X; n]);
};
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
let mut cur = Vec2::X;
for k in 0..n {
let i = (last + n - k) % n;
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
cur = *d;
}
if let Some(a) = ahead.get_mut(i) {
*a = cur;
}
}
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
for k in 0..n {
let i = (first + k) % n;
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
cur = *d;
}
if let Some(b) = behind.get_mut(i) {
*b = cur;
}
}
(ahead, behind)
}
impl MLine {
/// A multiline through `pts` in style `def`.
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
@@ -268,16 +303,11 @@ impl MLine {
let dirs = self.directions();
let n = self.vertices.len();
let nseg = dirs.len();
// A degenerate segment takes its neighbour's direction.
let dir = |i: usize| -> Vec2 {
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
};
let dir_back = |i: usize| -> Vec2 {
(0..nseg)
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
.find(|d| *d != Vec2::ZERO)
.unwrap_or(Vec2::X)
};
// A degenerate segment takes its neighbour's direction: the first usable one at or after
// it (`ahead`), or at or before it (`behind`), wrapping around.
let (ahead, behind) = usable_directions(&dirs);
let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) };
let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) };
let end = |d: Vec2, angle: f64| {
let u = d.rotate(angle);
let s = angle.sin();
@@ -426,3 +456,61 @@ impl crate::Drawing {
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The nearest usable directions found by searching from each segment, as miters did before.
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
let n = dirs.len();
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
(ahead.collect(), behind.collect())
}
#[test]
fn usable_directions_match_a_search_from_each_segment() {
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
for dirs in [
vec![],
vec![z],
vec![a],
vec![z, z, z],
vec![a, z, z, b],
vec![z, a, z, b, z],
vec![z, z, c],
vec![c, z, z],
vec![a, b, c],
vec![z, a, z, z, b, c, z],
] {
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
}
}
#[test]
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
// Every vertex of a run on one point mitres between the segment before the run and the one after.
let style = MLineStyle::default();
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
let (expected, got) = (corner.miters(), run.miters());
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
}
#[test]
fn a_long_run_of_coincident_vertices_takes_linear_time() {
// 200 000 vertices on one point but the last: the miters must not search the run once per
// vertex (quadratic, minutes) to find the one usable direction.
let mut pts = vec![Vec2::ZERO; 200_000];
pts.push(Vec2::new(1.0, 0.0));
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
let t = std::time::Instant::now();
let miters = m.miters();
assert_eq!(miters.len(), pts.len());
assert!(miters.iter().all(|v| v.is_finite()));
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
}
}
+93 -19
View File
@@ -4,15 +4,18 @@
//! autosave is written as DXF to `<SAVEFILEPATH>/<drawing>_<instance>_<uid>.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 (`<instance>.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 (`<instance>.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<u64, Tracked>,
/// Drawings whose autosave is being written right now.
busy: HashSet<u64>,
/// 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<u64, String>,
/// 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<bool>)>,
running: Vec<(u64, std::thread::JoinHandle<std::result::Result<(), String>>)>,
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
last_beat: Option<f64>,
/// 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<Weak<Drawing>>, d: &Arc<Drawing>| 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<Recoverable> {
@@ -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::<Vec<_>, _>(|(_, 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));
+94
View File
@@ -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<_>>(), 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);
}
+58
View File
@@ -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]
+4
View File
@@ -305,6 +305,8 @@ pub struct CadApp {
pub gpu_faults: std::sync::Arc<gpu::GpuFaults>,
/// The notice shown after one (see [`autosave::gpu_notice`]).
pub gpu_notice: Option<autosave::GpuNotice>,
/// The notice shown while autosave fails (see [`autosave::failure_notice`]).
pub autosave_notice: Option<autosave::AutosaveNotice>,
/// The Command Aliases dialog while open ([`aliasdlg`]).
pub alias_dlg: Option<aliasdlg::AliasDlgState>,
/// 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;
}
+82
View File
@@ -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);
}