Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor c6676f94a7 Dynamic blocks: list a block as dynamic from its graph object without walking the graph's members 2026-11-02 21:37:47 -05:00
Dylan M. Taylor af2d413d5e Dynamic blocks: index kept objects once per listing and copy each value record once
Listing blocks rebuilt a handle index of every kept foreign object for each
block with an extension dictionary, multiplying the block count by the object
count. The listing now builds one ObjectIndex and asks is_dynamic_block_in per
block; dynamic_block answers None before indexing when the block has no
extension dictionary.

templates() copied a value record's tags once per dictionary key, so a record
listed under many keys was copied many times, and it looked up each
dictionary entry by scanning every object. It now resolves entries through
its handle index and copies each record at most once (CWE-400, CWE-407).
2026-11-02 20:13:57 -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
9 changed files with 427 additions and 32 deletions
+41 -6
View File
@@ -323,8 +323,9 @@ impl<'a> Cursor<'a> {
}
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
/// `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph object, when the dictionary has one. Only the
/// dictionary's own tags are read, so asking whether a block is dynamic never walks the graph.
fn evaluation_graph<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<&'a ForeignObject> {
let dict = objects.get(&xdict)?;
let mut graph = None;
let mut tags = dict.tags.iter();
@@ -334,7 +335,12 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
break;
}
}
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
objects.get(&graph?).copied().filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))
}
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
let graph = evaluation_graph(objects, xdict)?;
let members = section(&sections(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
// Deduplicate member handles before resolving them: a graph can list the same member many
// times, and parsing each occurrence into its own owned parameter multiplies a single large
@@ -343,13 +349,42 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
}
/// The kept foreign objects of a drawing by handle. Building it walks every object, so a caller
/// that asks about many blocks (listing them) builds it once and passes it to
/// [`dynamic_block_in`] and [`is_dynamic_block_in`] instead of rebuilding it per block, which
/// multiplies the block count by the object count (CWE-407).
pub struct ObjectIndex<'a>(HashMap<Handle, &'a ForeignObject>);
impl<'a> ObjectIndex<'a> {
pub fn new(d: &'a Drawing) -> Self {
ObjectIndex(d.foreign.objects.iter().map(|o| (o.handle, o)).collect())
}
}
/// The extension dictionary of block `name`'s record, if it has one.
fn block_xdict(d: &Drawing, name: &str) -> Option<Handle> {
d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict
}
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
/// block's own drawing), or `None` when it has none.
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// A block without an extension dictionary is never dynamic: answer before indexing objects.
block_xdict(d, name)?;
dynamic_block_in(d, &ObjectIndex::new(d), name)
}
/// Whether block `name` is a dynamic block (has an evaluation graph), without reading its
/// parameters and actions; `objects` is the drawing's [`ObjectIndex`].
pub fn is_dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> bool {
block_xdict(d, name).and_then(|x| evaluation_graph(&objects.0, x)).is_some()
}
/// [`dynamic_block`] with the drawing's [`ObjectIndex`] already built.
pub fn dynamic_block_in(d: &Drawing, objects: &ObjectIndex<'_>, name: &str) -> Option<DynamicBlock> {
let xdict = block_xdict(d, name)?;
let mut out = DynamicBlock::default();
for o in graph_members(&objects, xdict)? {
for o in graph_members(&objects.0, xdict)? {
let secs = sections(&o.tags);
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
+47 -6
View File
@@ -54,7 +54,11 @@ fn record_handle(d: &Drawing, name: &str) -> Option<Handle> {
/// The object entry `key` of dictionary `dict` refers to.
fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;
dictionary_entry(d.foreign.objects.iter().find(|o| o.handle == dict)?, key)
}
/// The object entry `key` (any case) of dictionary object `o` names.
fn dictionary_entry(o: &ForeignObject, key: &str) -> Option<Handle> {
let mut tags = o.tags.iter();
while let Some(t) = tags.next() {
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
@@ -66,7 +70,11 @@ fn entry(d: &Drawing, dict: Handle, key: &str) -> Option<Handle> {
/// Every entry of dictionary `dict`: (key, object).
fn entries(d: &Drawing, dict: Handle) -> Vec<(String, Handle)> {
let Some(o) = d.foreign.objects.iter().find(|o| o.handle == dict) else { return Vec::new() };
d.foreign.objects.iter().find(|o| o.handle == dict).map(dictionary_entries).unwrap_or_default()
}
/// Every entry of dictionary object `o`: (key, object).
fn dictionary_entries(o: &ForeignObject) -> Vec<(String, Handle)> {
let mut out = Vec::new();
let mut key: Option<&str> = None;
for t in &o.tags {
@@ -692,19 +700,33 @@ fn refill_hatches(b: &mut Block) {
/// The value records of the references to the dynamic block whose record is `def_record` (from
/// any one of them, by graph id): AutoCAD's layout of each record, class ids included.
///
/// Each record is copied once at most: a key naming a record an earlier key already took is
/// skipped. Records are copied per key (and copied again per key when a new reference's data is
/// made), so a file listing one large record under many keys would otherwise multiply its size by
/// the key count (CWE-400); AutoCAD gives every key its own record.
fn templates(d: &Drawing, def_record: Handle) -> HashMap<String, Vec<RawTag>> {
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
// Dictionary entries looked up through the index, not by scanning every object per
// dictionary (which is quadratic in the object count).
let entry = |dict: Handle, key: &str| objects.get(&dict).and_then(|o| dictionary_entry(o, key));
let mut out: HashMap<String, Vec<RawTag>> = HashMap::new();
let mut taken: HashSet<Handle> = HashSet::new();
for rep in d.foreign.objects.iter().filter(|o| o.kind.eq_ignore_ascii_case("DICTIONARY")) {
// A representation dictionary whose data names this dynamic block.
let Some(rd) = entry(d, rep.handle, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
let Some(rd) = dictionary_entry(rep, "AcDbRepData").and_then(|h| objects.get(&h)) else { continue };
if !rd.tags.iter().any(|t| t.code == 340 && Handle::parse_hex(&t.value) == Some(def_record)) {
continue;
}
let Some(values) = entry(d, rep.handle, "AppDataCache").and_then(|c| entry(d, c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
for (k, h) in entries(d, values) {
let Some(values) = dictionary_entry(rep, "AppDataCache").and_then(|c| entry(c, "ACAD_ENHANCEDBLOCKDATA")) else { continue };
let Some(values) = objects.get(&values) else { continue };
for (k, h) in dictionary_entries(values) {
if out.contains_key(&k) || taken.contains(&h) {
continue;
}
if let Some(x) = objects.get(&h).filter(|x| x.kind.eq_ignore_ascii_case("XRECORD")) {
out.entry(k).or_insert_with(|| x.tags.clone());
taken.insert(h);
out.insert(k, x.tags.clone());
}
}
}
@@ -1044,4 +1066,23 @@ mod tests {
assert_eq!(by_tag(&d, &def), vec![(10, Handle(1)), (20, Handle(2))]);
assert_eq!(by_tag(&d, &anon), vec![(20, Handle(5)), (1_000_001, Handle(6))]);
}
#[test]
fn a_value_record_listed_under_many_keys_is_copied_once() {
// One large XRECORD named by many keys of a reference's value dictionary: the templates
// keep one copy, not one per key.
let mut d = Drawing::new_imperial();
let (def_record, rep, rd, cache, values, record) = (Handle(0x10), Handle(0x20), Handle(0x21), Handle(0x22), Handle(0x23), Handle(0x24));
let keys: Vec<String> = (0..1000).map(|i| i.to_string()).collect();
let entries: Vec<(&str, Handle)> = keys.iter().map(|k| (k.as_str(), record)).collect();
let f = std::sync::Arc::make_mut(&mut d.foreign);
f.objects.push(dictionary(rep, Handle(1), &[("AcDbRepData", rd), ("AppDataCache", cache)]));
f.objects.push(ForeignObject { handle: rd, kind: "ACDB_BLOCKREPRESENTATION_DATA".into(), tags: vec![tag(340, def_record.hex())] });
f.objects.push(dictionary(cache, rep, &[("ACAD_ENHANCEDBLOCKDATA", values)]));
f.objects.push(dictionary(values, cache, &entries));
f.objects.push(ForeignObject { handle: record, kind: "XRECORD".into(), tags: (0..1000).map(|_| tag(1, "x")).collect() });
let t = templates(&d, def_record);
assert_eq!(t.len(), 1, "one record, one template");
assert_eq!(t.get("0").map(Vec::len), Some(1000));
}
}
+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));
+4 -1
View File
@@ -379,8 +379,11 @@ fn run_base(s: &mut Session, p: &Value) -> Result<Value> {
fn run_list(s: &mut Session, _p: &Value) -> Result<Value> {
let d = s.doc()?;
// One object index for the whole listing: rebuilding it per block multiplies the block count
// by the drawing's kept object count (CWE-407).
let objects = cadcraft_doc::dynblock::ObjectIndex::new(d);
let block = |b: &Block| {
let dynamic = cadcraft_doc::dynblock::dynamic_block(d, &b.name).is_some();
let dynamic = cadcraft_doc::dynblock::is_dynamic_block_in(d, &objects, &b.name);
json!({"name": b.name, "entities": b.entities.len(), "base": [b.base.x, b.base.y], "description": b.description, "dynamic": dynamic})
};
Ok(json!({ "blocks": d.blocks.values().filter(|b| !b.anonymous).map(|b| block(b)).collect::<Vec<_>>() }))
+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);
}
@@ -83,6 +83,10 @@ fn blocks_without_dynamic_data_and_bad_names() {
s.execute("block", &json!({ "name": "Window", "base": [0, 0], "handles": [l] })).unwrap();
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "Window" })).unwrap(), json!(null));
assert_eq!(s.execute("blocks.dynamic", &json!({ "name": "*Model_Space" })).unwrap(), json!(null));
let list = s.execute("blocks.list", &json!({})).unwrap();
let flags: Vec<_> = list["blocks"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap().to_string(), b["dynamic"].clone())).collect();
assert!(flags.contains(&("Window".into(), json!(false))), "{flags:?}");
assert!(flags.contains(&("Door".into(), json!(true))), "{flags:?}");
for p in [json!({}), json!({ "name": 3 }), json!({ "name": "Nope" }), json!(null)] {
assert!(s.execute("blocks.dynamic", &p).is_err(), "{p}");
}
+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);
}