mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 00:42:46 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
897d86b865 | ||
|
|
ef56e1be8c | ||
|
|
fdfe4d95a8 |
@@ -207,6 +207,41 @@ pub struct MLineGeometry {
|
||||
pub fill: Vec<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// For each segment direction in `dirs`, the first non-zero direction at or after it and the
|
||||
/// first at or before it, both wrapping around (`Vec2::X` when every direction is zero).
|
||||
///
|
||||
/// Two linear passes: searching from every segment separately is quadratic in a run of
|
||||
/// coincident vertices, which a hostile file can make as long as it likes (CWE-407).
|
||||
fn usable_directions(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let (Some(first), Some(last)) = (dirs.iter().position(|d| *d != Vec2::ZERO), dirs.iter().rposition(|d| *d != Vec2::ZERO)) else {
|
||||
return (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
};
|
||||
let (mut ahead, mut behind) = (vec![Vec2::X; n], vec![Vec2::X; n]);
|
||||
// Walking backwards from the last usable direction, the latest one seen is the next ahead.
|
||||
let mut cur = Vec2::X;
|
||||
for k in 0..n {
|
||||
let i = (last + n - k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(a) = ahead.get_mut(i) {
|
||||
*a = cur;
|
||||
}
|
||||
}
|
||||
// Walking forwards from the first usable direction, the latest one seen is the nearest behind.
|
||||
for k in 0..n {
|
||||
let i = (first + k) % n;
|
||||
if let Some(d) = dirs.get(i).filter(|d| **d != Vec2::ZERO) {
|
||||
cur = *d;
|
||||
}
|
||||
if let Some(b) = behind.get_mut(i) {
|
||||
*b = cur;
|
||||
}
|
||||
}
|
||||
(ahead, behind)
|
||||
}
|
||||
|
||||
impl MLine {
|
||||
/// A multiline through `pts` in style `def`.
|
||||
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
|
||||
@@ -268,16 +303,11 @@ impl MLine {
|
||||
let dirs = self.directions();
|
||||
let n = self.vertices.len();
|
||||
let nseg = dirs.len();
|
||||
// A degenerate segment takes its neighbour's direction.
|
||||
let dir = |i: usize| -> Vec2 {
|
||||
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
|
||||
};
|
||||
let dir_back = |i: usize| -> Vec2 {
|
||||
(0..nseg)
|
||||
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
|
||||
.find(|d| *d != Vec2::ZERO)
|
||||
.unwrap_or(Vec2::X)
|
||||
};
|
||||
// A degenerate segment takes its neighbour's direction: the first usable one at or after
|
||||
// it (`ahead`), or at or before it (`behind`), wrapping around.
|
||||
let (ahead, behind) = usable_directions(&dirs);
|
||||
let dir = |i: usize| -> Vec2 { ahead.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let dir_back = |i: usize| -> Vec2 { behind.get(i).copied().unwrap_or(Vec2::X) };
|
||||
let end = |d: Vec2, angle: f64| {
|
||||
let u = d.rotate(angle);
|
||||
let s = angle.sin();
|
||||
@@ -426,3 +456,61 @@ impl crate::Drawing {
|
||||
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The nearest usable directions found by searching from each segment, as miters did before.
|
||||
fn searched(dirs: &[Vec2]) -> (Vec<Vec2>, Vec<Vec2>) {
|
||||
let n = dirs.len();
|
||||
let ahead = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
let behind = (0..n).map(|i| (0..n).filter_map(|k| dirs.get((i + n - k) % n).copied()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X));
|
||||
(ahead.collect(), behind.collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_directions_match_a_search_from_each_segment() {
|
||||
let (z, a, b, c) = (Vec2::ZERO, Vec2::Y, -Vec2::X, Vec2::new(0.6, 0.8));
|
||||
for dirs in [
|
||||
vec![],
|
||||
vec![z],
|
||||
vec![a],
|
||||
vec![z, z, z],
|
||||
vec![a, z, z, b],
|
||||
vec![z, a, z, b, z],
|
||||
vec![z, z, c],
|
||||
vec![c, z, z],
|
||||
vec![a, b, c],
|
||||
vec![z, a, z, z, b, c, z],
|
||||
] {
|
||||
assert_eq!(usable_directions(&dirs), searched(&dirs), "{dirs:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_vertices_at_a_corner_take_the_corner_miter() {
|
||||
// Every vertex of a run on one point mitres between the segment before the run and the one after.
|
||||
let style = MLineStyle::default();
|
||||
let corner = MLine::new(&style, &[Vec2::ZERO, Vec2::Y, Vec2::new(-1.0, 1.0)], false, MLineJust::Zero, 1.0);
|
||||
let pts = [Vec2::ZERO, Vec2::Y, Vec2::Y, Vec2::Y, Vec2::new(-1.0, 1.0)];
|
||||
let run = MLine::new(&style, &pts, false, MLineJust::Zero, 1.0);
|
||||
let (expected, got) = (corner.miters(), run.miters());
|
||||
assert_eq!(got.get(1..4), Some(&[expected[1]; 3][..]));
|
||||
assert_eq!((got.first(), got.last()), (expected.first(), expected.last()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_coincident_vertices_takes_linear_time() {
|
||||
// 200 000 vertices on one point but the last: the miters must not search the run once per
|
||||
// vertex (quadratic, minutes) to find the one usable direction.
|
||||
let mut pts = vec![Vec2::ZERO; 200_000];
|
||||
pts.push(Vec2::new(1.0, 0.0));
|
||||
let m = MLine::new(&MLineStyle::default(), &pts, false, MLineJust::Zero, 1.0);
|
||||
let t = std::time::Instant::now();
|
||||
let miters = m.miters();
|
||||
assert_eq!(miters.len(), pts.len());
|
||||
assert!(miters.iter().all(|v| v.is_finite()));
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(5), "{:?}", t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user