mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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.
289 lines
12 KiB
Rust
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,
|
|
});
|
|
}
|