Files
cadcraft/crates/engine/tests/autosave.rs
T
Marco Arnulfo 266ee7e6ea Autosave: a write that failed is tried again without another change (fixes #717) (#774)
The planner marked a drawing as autosaved when it dispatched the write, and the background
worker only logged a failure. An unchanged drawing whose write failed was then never written
again, even once the folder became writable. The worker now reports whether the file was
written; on a failure (or a thread that could not be spawned) the drawing is no longer counted as
autosaved, so the next plan restarts its SAVETIME clock and tries again.
2026-10-13 04:35:02 -04:00

289 lines
12 KiB
Rust

//! Autosave: SAVETIME timing, which drawings are written, files deleted on save and close, and
//! the files Drawing Recovery offers after a crash (#406, docs/gaps.md F4).
use std::time::{Duration, SystemTime};
use cadcraft_engine::Session;
use cadcraft_engine::autosave::{self, Autosaver, Job};
use cadcraft_engine::cmd::file::{IoHooks, set_io};
use serde_json::json;
fn writes(jobs: &[Job]) -> Vec<String> {
jobs.iter()
.filter_map(|j| match j {
Job::Write { path, .. } => Some(path.clone()),
Job::Delete { .. } => None,
})
.collect()
}
fn deletes(jobs: &[Job]) -> Vec<String> {
jobs.iter()
.filter_map(|j| match j {
Job::Delete { path } => Some(path.clone()),
Job::Write { .. } => None,
})
.collect()
}
fn draw(s: &mut Session) {
s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
}
#[test]
fn a_changed_drawing_is_written_savetime_minutes_after_the_change() {
let mut s = Session::new();
let uid = s.state().unwrap().uid;
let mut a = Autosaver::new("77-a");
assert!(a.plan(&s, 0.0, 10, "/auto").is_empty(), "nothing to save in a clean drawing");
draw(&mut s);
assert!(a.plan(&s, 30.0, 10, "/auto").is_empty(), "the change starts the clock");
assert_eq!(a.next_due(100.0, 10), Some(530.0));
assert!(a.plan(&s, 629.0, 10, "/auto").is_empty());
let jobs = a.plan(&s, 630.0, 10, "/auto");
let path = std::path::Path::new("/auto").join(format!("Drawing1_77-a_{uid}.sv$")).to_string_lossy().to_string();
assert_eq!(writes(&jobs), vec![path.clone()]);
assert!(matches!(&jobs[0], Job::Write { doc, .. } if std::sync::Arc::ptr_eq(doc, &s.state().unwrap().doc)));
assert!(a.plan(&s, 5000.0, 10, "/auto").is_empty(), "no change since the autosave");
assert_eq!(a.next_due(5000.0, 10), None);
// The next change restarts the clock; the same file is written again.
draw(&mut s);
assert!(a.plan(&s, 6000.0, 10, "/auto").is_empty());
assert_eq!(writes(&a.plan(&s, 6600.0, 10, "/auto")), vec![path.clone()]);
assert_eq!(a.files(), vec![path.clone()]);
// Saving (nothing unsaved left) deletes the file.
let st = s.state_mut().unwrap();
st.saved = st.doc.clone();
assert_eq!(deletes(&a.plan(&s, 6601.0, 10, "/auto")), vec![path]);
assert!(a.files().is_empty());
}
#[test]
fn closing_deletes_and_savetime_zero_or_no_folder_writes_nothing() {
let mut s = Session::new();
s.execute("new", &json!({})).unwrap();
let mut a = Autosaver::new("5-b");
draw(&mut s);
a.plan(&s, 0.0, 1, "/auto");
assert!(a.plan(&s, 1e6, 0, "/auto").is_empty(), "SAVETIME 0 is off");
assert!(a.plan(&s, 1e6, 1, " ").is_empty(), "no folder, no autosave");
let path = writes(&a.plan(&s, 1e6, 1, "/auto")).pop().unwrap();
assert!(path.contains("Drawing2_5-b_"), "{path}");
// Undo back to the saved state: no unsaved changes, no file.
s.undo().unwrap();
assert_eq!(deletes(&a.plan(&s, 1e6 + 1.0, 1, "/auto")), vec![path.clone()]);
draw(&mut s);
a.plan(&s, 0.0, 1, "/auto");
assert_eq!(writes(&a.plan(&s, 60.0, 1, "/auto")).len(), 1);
s.execute("close", &json!({})).unwrap();
assert_eq!(deletes(&a.plan(&s, 61.0, 1, "/auto")).len(), 1, "closing deletes the file");
}
#[test]
fn file_names_round_trip() {
let n = autosave::file_name("My plan_v2.dwg", "123-abc", 7);
assert_eq!(n, "My plan_v2.dwg_123-abc_7.sv$");
assert_eq!(autosave::parse_file_name(&n), Some(("My plan_v2.dwg", "123-abc", 7)));
assert_eq!(autosave::file_name("a/b:c*", "1-2", 3), "a-b-c-_1-2_3.sv$");
assert_eq!(autosave::file_name("..", "1-2", 3), "Drawing_1-2_3.sv$");
for bad in
["plan.dxf", "x_1.sv$", "_1-2_3.sv$", "a_1-2_x.sv$", "x.sv$", "notes_foo_3.sv$", "a_1-xyz_3.sv$", "a_-1_3.sv$", "a_1-2_+3.sv$", "a_1-2_.sv$"]
{
assert_eq!(autosave::parse_file_name(bad), None, "{bad}");
}
assert!(autosave::is_instance_id(&autosave::instance_id()));
assert_eq!(autosave::parse_lock_name("123-18f3a2b4c5d.lock"), Some("123-18f3a2b4c5d"));
for bad in ["Cargo.lock", "foo.lock", "package-lock.json", "yarn-lock.lock", "1-2.lock.bak", ".lock", "-1.lock", "1-.lock", "1-2-3.lock"] {
assert_eq!(autosave::parse_lock_name(bad), None, "{bad}");
}
}
#[test]
fn files_of_other_programs_in_the_autosave_folder_are_never_deleted() {
// SAVEFILEPATH may point at a project folder: only CADCraft's own lock and autosave names
// (built from an instance id) are ever pruned or discarded.
let dir = temp_dir("foreign");
let old = SystemTime::now() - Duration::from_secs(3600);
let foreign = ["Cargo.lock", "package-lock.json", "foo.lock", "yarn-lock.lock", "x.sv$", "notes_foo_3.sv$", "a_b-c_1.sv$"];
for name in foreign.iter().chain(&["1-a.lock"]) {
let f = std::fs::File::create(dir.join(name)).unwrap();
f.set_modified(old).unwrap();
}
assert!(autosave::recoverable(&dir, "9-f").is_empty(), "foreign files are not offered");
autosave::prune_locks(&dir);
assert!(!dir.join("1-a.lock").exists(), "a stopped app's own lock is pruned");
for name in foreign {
autosave::discard(&dir.join(name).to_string_lossy());
}
autosave::prune_locks(&dir);
for name in foreign {
assert!(dir.join(name).exists(), "{name} must survive");
}
let _ = std::fs::remove_dir_all(&dir);
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("cadcraft-autosave-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn recovery_offers_files_of_apps_that_stopped() {
let dir = temp_dir("scan");
let touch = |name: &str| std::fs::write(dir.join(name), b"0\nEOF\n").unwrap();
touch("Crashed.dxf_1-a_3.sv$"); // no lock: the app went down
touch("Live.dxf_2-b_4.sv$"); // fresh lock: still running
std::fs::write(dir.join("2-b.lock"), b"2").unwrap();
touch("Stale.dxf_3-c_5.sv$"); // lock not refreshed for 10 minutes
let lock = std::fs::File::create(dir.join("3-c.lock")).unwrap();
lock.set_modified(SystemTime::now() - Duration::from_secs(600)).unwrap();
drop(lock);
touch("Mine.dxf_9-f_1.sv$"); // this app's own
touch("notes.txt");
std::fs::write(dir.join("Half.dxf_4-d_6.sv$.tmp"), b"").unwrap();
let found = autosave::recoverable(&dir, "9-f");
let names: Vec<&str> = found.iter().map(|r| r.drawing.as_str()).collect();
assert_eq!(names.len(), 2, "{found:?}");
assert!(names.contains(&"Crashed.dxf") && names.contains(&"Stale.dxf"));
assert!(found.iter().all(|r| r.bytes == 6 && r.modified > 0));
// Discarding deletes the file and the dead app's lock once it has no files left.
let stale = found.iter().find(|r| r.drawing == "Stale.dxf").unwrap();
autosave::discard(&stale.path);
assert!(!std::path::Path::new(&stale.path).exists());
assert!(!dir.join("3-c.lock").exists());
assert!(dir.join("2-b.lock").exists(), "a running app's lock stays");
assert_eq!(autosave::recoverable(&dir, "9-f").len(), 1);
assert!(autosave::recoverable(&dir.join("missing"), "9-f").is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn autosave_files_are_written_in_the_background_and_removed_on_exit() {
hooks();
let dir = temp_dir("write");
let mut s = Session::new();
draw(&mut s);
let mut a = Autosaver::new(autosave::instance_id());
a.plan(&s, 0.0, 1, &dir.to_string_lossy());
let jobs = a.plan(&s, 60.0, 1, &dir.to_string_lossy());
let path = writes(&jobs).pop().unwrap();
a.perform(jobs);
for _ in 0..500 {
if std::path::Path::new(&path).exists() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
// While the app runs, its lock keeps its files out of recovery. SAVETIME and SAVEFILEPATH
// are system variables (process-wide, so set in this test only).
assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVETIME"), Some(json!(10)));
s.execute("setvar", &json!({"name": "SAVETIME", "value": 3})).unwrap();
assert_eq!(autosave::savetime(), 3);
assert!(s.execute("setvar", &json!({"name": "SAVETIME", "value": -1})).is_err());
assert!(s.execute("setvar", &json!({"name": "SAVETIME", "value": 601})).is_err());
s.execute("setvar", &json!({"name": "SAVEFILEPATH", "value": format!(" {} ", dir.display())})).unwrap();
assert_eq!(cadcraft_engine::sysvars::get(&s, "SAVEFILEPATH"), Some(json!(dir.to_string_lossy())));
a.tick(&s);
assert!(dir.join(format!("{}.lock", a.instance)).exists());
assert!(autosave::recoverable(&dir, "someone-else").is_empty());
// After a GPU error a change made a moment ago is autosaved right away, not SAVETIME later.
assert!(a.tick(&s).is_none(), "no change waiting");
draw(&mut s);
assert!(a.tick(&s).is_some(), "SAVETIME (3 minutes) is not up yet");
assert_eq!(a.save_now(&s), 1);
assert_eq!(a.save_now(&s), 0, "being written or written: nothing more to save");
a.shutdown();
assert!(!std::path::Path::new(&path).exists());
assert!(!dir.join(format!("{}.lock", a.instance)).exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_failed_autosave_is_tried_again_without_another_change() {
hooks();
let root = temp_dir("retry");
// The folder can't be created while a file holds its name: the write fails (#717).
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("31-e");
a.plan(&s, 0.0, 1, &dir);
let jobs = a.plan(&s, 60.0, 1, &dir);
let path = writes(&jobs).pop().unwrap();
a.perform(jobs);
// Once the write has failed, the unchanged drawing waits SAVETIME again.
let mut rearmed = false;
for _ in 0..500 {
a.reap();
assert!(writes(&a.plan(&s, 120.0, 1, &dir)).is_empty());
if a.next_due(120.0, 1) == Some(60.0) {
rearmed = true;
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(rearmed, "a failed autosave must be tried again");
std::fs::remove_file(&out).unwrap();
let jobs = a.plan(&s, 180.0, 1, &dir);
assert_eq!(writes(&jobs), vec![path.clone()]);
a.perform(jobs);
for _ in 0..500 {
if std::path::Path::new(&path).exists() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
assert!(std::fs::read(&path).unwrap().ends_with(b"EOF\n"));
// A write that succeeded is not repeated.
for _ in 0..50 {
a.reap();
assert!(writes(&a.plan(&s, 1e4, 1, &dir)).is_empty());
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(a.next_due(1e4, 1), None);
a.shutdown();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn recover_as_copy_opens_an_unsaved_untitled_drawing() {
hooks();
let mut s = Session::new();
let dir = temp_dir("copy");
let file = dir.join("Plan.dxf_1-a_2.sv$");
std::fs::write(&file, b"0\nEOF\n").unwrap();
let r = s.execute("recover", &json!({"path": file.to_string_lossy(), "copy": true, "title": "Plan.dxf"})).unwrap();
let st = s.state().unwrap();
assert_eq!((st.title.as_str(), st.path.as_deref()), ("Plan.dxf", None));
assert!(st.is_dirty(), "a recovered copy has unsaved changes");
assert_eq!(r["uid"], st.uid);
let _ = std::fs::remove_dir_all(&dir);
}
/// File hooks for every test in this file (the first call installs them).
fn hooks() {
set_io(IoHooks {
read: |_, _| Ok(cadcraft_engine::doc::Drawing::new_metric()),
write: |_, _| Ok(b"0\nSECTION\n0\nENDSEC\n0\nEOF\n".to_vec()),
plot: None,
});
}