Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 6d9487e200 Regions: test a loop's probe only against loops whose box holds it, within a budget
Region::renest tested every loop's probe point against every other
tessellated loop, scanning all of its edges each time, so the work was the
loop count times the total vertex count. It now skips loops whose bounding
box does not hold the probe, and stops once MAX_RENEST_EDGE_VISITS edge tests
have been spent, leaving the flags unchanged and returning false; UNION,
SUBTRACT and INTERSECT then report the regions as too complex to combine, as
they already do for too many boundary pairs (CWE-407).
2026-11-02 20:42:51 -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
7 changed files with 407 additions and 27 deletions
+73 -7
View File
@@ -9,6 +9,9 @@ use super::HatchLoop;
/// Most loops one region has (hostile files and edits).
pub const MAX_REGION_LOOPS: usize = 10_000;
/// Most polygon edges [`Region::renest`] tests, over all its point-in-loop tests.
pub const MAX_RENEST_EDGE_VISITS: usize = 50_000_000;
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Region {
/// Closed loops with bulges; `outer` false for islands.
@@ -76,20 +79,49 @@ impl Region {
}
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
/// an outer loop, odd an island.
pub fn renest(&mut self) {
/// an outer loop, odd an island. `false`, with the flags left as they were, when that takes
/// more than [`MAX_RENEST_EDGE_VISITS`] polygon edge tests.
#[must_use]
pub fn renest(&mut self) -> bool {
let polys = self.tessellate(self.fine_tol());
let depth: Vec<usize> = polys
// A loop's probe can only be inside loops whose box holds it: test only those. A box with
// a non-finite corner says nothing (an edge through a non-finite point never counts, so
// such a polygon need not be closed), so its loop is always tested.
let boxes: Vec<Option<Bounds2>> = polys
.iter()
.enumerate()
.map(|(i, l)| {
let Some(p) = probe(l) else { return 0 };
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
.map(|l| {
let b = Bounds2::from_points(l.iter().copied());
(l.iter().all(|p| p.is_finite()) && b.min.is_finite() && b.max.is_finite()).then_some(b)
})
.collect();
let mut visits = 0usize;
let mut depth = Vec::with_capacity(polys.len());
for (i, l) in polys.iter().enumerate() {
let Some(p) = probe(l) else {
depth.push(0);
continue;
};
let mut d = 0;
for (j, (o, b)) in polys.iter().zip(&boxes).enumerate() {
if j == i || b.is_some_and(|b| !b.contains(p)) {
continue;
}
// Each loop against every other is loops x vertices: bounded so a region of many
// large nested loops cannot hold the thread (CWE-407).
visits = visits.saturating_add(o.len());
if visits > MAX_RENEST_EDGE_VISITS {
return false;
}
if point_in_polygon(o, p) {
d += 1;
}
}
depth.push(d);
}
for (l, d) in self.loops.iter_mut().zip(depth) {
l.outer = d % 2 == 0;
}
true
}
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
@@ -128,3 +160,37 @@ fn probe(l: &[Vec2]) -> Option<Vec2> {
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn square(c: f64, half: f64, n: usize, outer: bool) -> HatchLoop {
// `n` vertices along the bottom edge, then the other three corners.
let mut vertices: Vec<PolyVertex> =
(0..n).map(|k| PolyVertex::new(Vec2::new(c - half + 2.0 * half * k as f64 / n as f64, c - half))).collect();
vertices.extend([Vec2::new(c + half, c - half), Vec2::new(c + half, c + half), Vec2::new(c - half, c + half)].map(PolyVertex::new));
HatchLoop { vertices, outer, sources: Vec::new() }
}
#[test]
fn renest_sets_depth_parity_and_skips_loops_far_away() {
// Three nested squares, and one beside them.
let mut r =
Region { loops: vec![square(0.0, 3.0, 1, false), square(0.0, 2.0, 1, false), square(0.0, 1.0, 1, false), square(10.0, 1.0, 1, false)] };
assert!(r.renest());
assert_eq!(r.loops.iter().map(|l| l.outer).collect::<Vec<_>>(), [true, false, true, true]);
}
#[test]
fn renest_of_many_large_nested_loops_stops_at_its_budget() {
// Concentric loops of many vertices each: each probe is inside the box of every larger
// loop (about loops² / 2 pairs), so each of those tests is a full polygon scan.
let loops = 300;
let n = 3 * MAX_RENEST_EDGE_VISITS / (loops * loops);
let mut r = Region { loops: (0..loops).map(|i| square(0.0, 1.0 + i as f64, n, false)).collect() };
let before = r.clone();
assert!(!r.renest());
assert_eq!(r, before, "flags left as they were");
}
}
+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));
+3 -1
View File
@@ -128,7 +128,9 @@ fn combine(a: &Region, b: &Region, op: Op) -> Result<Option<Region>> {
}
loops.retain(|l| l.vertices.len() >= 2);
let mut r = Region { loops };
r.renest();
if !r.renest() {
return Err(other("These regions are too complex to combine."));
}
Ok(Some(r))
}
+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);
}