Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor dd89f82e4a Extents: share block-bounds memoization across all the entities measured
Each top-level bounds call started with an empty block cache, so measuring a
space's extents or building a spatial-index chunk measured a block definition
again for every reference to it: R references to a block of B entities cost
R x B. BlockBoundsCache keeps the per-depth block bounds (separately for drawn
bounds and extents, which differ on layers turned off) across one bulk
operation; Drawing::extents and the spatial index use it (CWE-407).
2026-11-02 20:23:52 -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
8 changed files with 418 additions and 29 deletions
+77 -5
View File
@@ -94,25 +94,48 @@ fn mtext_box(t: &crate::MText) -> Bounds2 {
/// The box of what `e` draws (block contents on layers turned off left out).
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
bounds_of(d, e, depth, false)
bounds_of(d, e, depth, false, &mut HashMap::new())
}
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
bounds_of(d, e, 0, true)
bounds_of(d, e, 0, true, &mut HashMap::new())
}
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
/// The bounds of block contents measured so far, kept across the entities of one unchanged
/// drawing. Measuring many entities with [`entity_bounds_cached`] or [`extent_bounds_cached`]
/// and one cache measures each block definition once per depth for all of them; a fresh cache per
/// entity would measure a block again for every reference to it, R references to a block of B
/// entities costing R x B (CWE-407). Use a cache only while the drawing it measured is unchanged.
#[derive(Default)]
pub struct BlockBoundsCache<'a> {
/// By block name and depth, block contents on layers turned off left out ([`entity_bounds`]).
drawn: HashMap<(&'a str, usize), Bounds2>,
/// The same with those contents counted ([`extent_bounds`]).
extents: HashMap<(&'a str, usize), Bounds2>,
}
/// [`entity_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
pub fn entity_bounds_cached<'a>(d: &'a Drawing, e: &Entity, depth: usize, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
bounds_of(d, e, depth, false, &mut cache.drawn)
}
/// [`extent_bounds`] reusing the block bounds `cache` holds from earlier calls on drawing `d`.
pub fn extent_bounds_cached<'a>(d: &'a Drawing, e: &Entity, cache: &mut BlockBoundsCache<'a>) -> Bounds2 {
bounds_of(d, e, 0, true, &mut cache.extents)
}
fn bounds_of<'a>(d: &'a Drawing, e: &Entity, depth: usize, off_counts: bool, blocks: &mut HashMap<(&'a str, usize), Bounds2>) -> Bounds2 {
// A top-level annotative object measures as it draws at the current annotation scale.
if depth == 0 {
match d.anno_draw(e) {
crate::AnnoDraw::Plain => {}
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, blocks, off_counts),
}
}
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
bounds_in(d, e, depth, blocks, off_counts)
}
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
@@ -442,4 +465,53 @@ mod tests {
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
}
#[test]
fn a_shared_block_cache_measures_as_a_fresh_one_for_each_entity() {
let line = |a: (f64, f64), b: (f64, f64), layer: &str| {
let mut e = Entity::new(crate::Handle(1), EntityKind::Line(crate::Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }));
e.common.layer = layer.into();
e
};
let ins = |block: &str, at: f64| {
Entity::new(
crate::Handle(2),
EntityKind::Insert(crate::Insert {
block: block.into(),
insert: Vec3::new(at, at, 0.0),
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1,
rows: 1,
col_spacing: 0.0,
row_spacing: 0.0,
}),
)
};
let mut d = Drawing::new_imperial();
d.ensure_layer("OFF");
if let Some(l) = d.layer_mut("OFF") {
l.on = false;
}
// A block with a part on a layer turned off (left out when drawn, counted in extents),
// and a block that nests it.
let mut b = crate::Block::new("B");
b.entities.push(line((0.0, 0.0), (1.0, 1.0), "0"));
b.entities.push(line((0.0, 0.0), (5.0, 5.0), "OFF"));
d.blocks.insert("B".into(), std::sync::Arc::new(b));
let mut n = crate::Block::new("N");
n.entities.push(ins("B", 10.0));
d.blocks.insert("N".into(), std::sync::Arc::new(n));
let ents: Vec<Entity> = (0..50).map(|i| ins(if i % 2 == 0 { "B" } else { "N" }, f64::from(i))).collect();
// One cache for both kinds of measure, interleaved: neither may answer for the other.
let mut cache = BlockBoundsCache::default();
for e in &ents {
let (drawn, extents) = (entity_bounds_cached(&d, e, 0, &mut cache), extent_bounds_cached(&d, e, &mut cache));
let (want_drawn, want_extents) = (entity_bounds(&d, e, 0), extent_bounds(&d, e));
assert_eq!((drawn.min, drawn.max), (want_drawn.min, want_drawn.max));
assert_eq!((extents.min, extents.max), (want_extents.min, want_extents.max));
assert!(extents.width() > drawn.width());
}
}
}
+5 -2
View File
@@ -27,7 +27,7 @@ pub use cadcraft_color as color;
pub use cadcraft_geom as geom;
pub use constraint::*;
pub use entity::*;
pub use extents::{MAX_BLOCK_DEPTH, entity_bounds, extent_bounds};
pub use extents::{BlockBoundsCache, MAX_BLOCK_DEPTH, entity_bounds, entity_bounds_cached, extent_bounds, extent_bounds_cached};
pub use format::Format;
pub use gdt::*;
pub use header::{HVal, Header};
@@ -383,10 +383,13 @@ impl Drawing {
/// Bounds of the entities in a space that count in its extents ([`Drawing::in_extents`]).
pub fn extents(&self, space: &Space) -> cadcraft_geom::Bounds2 {
let mut b = cadcraft_geom::Bounds2::EMPTY;
// One block-bounds cache for the whole space: every reference to a block reuses its
// measured contents instead of measuring the definition again.
let mut cache = BlockBoundsCache::default();
if let Some(store) = self.space(space) {
for e in store.iter() {
if self.in_extents(e) {
b = b.union(&extent_bounds(self, e));
b = b.union(&extent_bounds_cached(self, e, &mut cache));
}
}
}
+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));
+5 -3
View File
@@ -15,7 +15,7 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use cadcraft_doc::{Block, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds};
use cadcraft_doc::{Block, BlockBoundsCache, DimStyle, Drawing, Entity, EntityKind, EntityStore, Space, entity_bounds_cached};
use cadcraft_geom::Bounds2;
use rstar::primitives::{GeomWithData, Rectangle};
use rstar::{AABB, RTree};
@@ -44,6 +44,8 @@ struct ChunkIx {
impl ChunkIx {
fn build(d: &Drawing, key: usize, items: &[Arc<Entity>]) -> ChunkIx {
let mut infinite = Vec::new();
// One block-bounds cache for the chunk: references to the same block measure it once.
let mut cache = BlockBoundsCache::default();
let bounds = items
.iter()
.enumerate()
@@ -51,7 +53,7 @@ impl ChunkIx {
if matches!(e.kind, EntityKind::XLine(_) | EntityKind::Ray(_)) {
infinite.push(u32::try_from(i).unwrap_or(u32::MAX));
}
entity_bounds(d, e, 0)
entity_bounds_cached(d, e, 0, &mut cache)
})
.collect();
let serial = NEXT_SERIAL.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
@@ -200,7 +202,7 @@ impl SpatialIndex {
let (c, l) = self.locate(i)?;
c.ents.get(l)
}
/// The bounds of the entity at draw index `i` (as [`entity_bounds`] computes them).
/// The bounds of the entity at draw index `i` (as [`cadcraft_doc::entity_bounds`] computes them).
pub fn bounds(&self, i: u32) -> Bounds2 {
self.locate(i).and_then(|(c, l)| c.bounds.get(l).copied()).unwrap_or(Bounds2::EMPTY)
}
+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);
}