Files
cadcraft/crates/engine/src/autosave.rs
T
Tanish Manku 2e9761249a Autosave: keep every folder's lock, and don't let a failure hide or wait (#2676)
From review of #2670. Changing SAVEFILEPATH released the old folder's lock while
files remained there, so another app could offer a running app's drawings. A
write failing while the app was idle waited for the next input to be shown. A
second drawing's failure stayed hidden behind a dismissed notice.
2026-11-02 19:22:30 -07:00

648 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Autosave (SAVETIME, SAVEFILEPATH) and the files Drawing Recovery offers after a crash.
//!
//! Every SAVETIME minutes after a change, each drawing with unsaved changes made since its last
//! 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
//! 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::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex, Weak};
use cadcraft_doc::Drawing;
use serde::Serialize;
use crate::Session;
/// The autosave file extension.
pub const EXT: &str = "sv$";
/// SAVETIME's default, in minutes.
pub const DEFAULT_SAVETIME: u32 = 10;
/// SAVETIME's largest value, in minutes.
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;
static SAVETIME: AtomicU32 = AtomicU32::new(DEFAULT_SAVETIME);
static ISAVEBAK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
/// ISAVEBAK: whether saving over a drawing keeps the previous file as `<name>.bak` (on by
/// default, as in AutoCAD). A profile setting like SAVETIME.
pub fn isavebak() -> bool {
ISAVEBAK.load(Ordering::Relaxed)
}
/// Turn ISAVEBAK on or off.
pub fn set_isavebak(on: bool) {
ISAVEBAK.store(on, Ordering::Relaxed);
}
static SAVEFILEPATH: Mutex<String> = Mutex::new(String::new());
/// SAVETIME: minutes between autosaves; 0 turns autosave off.
pub fn savetime() -> u32 {
SAVETIME.load(Ordering::Relaxed)
}
/// Set SAVETIME (clamped to 0–600 minutes).
pub fn set_savetime(minutes: u32) {
SAVETIME.store(minutes.min(MAX_SAVETIME), Ordering::Relaxed);
}
/// SAVEFILEPATH: the folder autosave files go to (the host sets a per-user default at start).
pub fn savefilepath() -> String {
SAVEFILEPATH.lock().map(|p| p.clone()).unwrap_or_default()
}
pub fn set_savefilepath(path: &str) {
if let Ok(mut p) = SAVEFILEPATH.lock() {
*p = path.trim().to_string();
}
}
/// The autosave file name for drawing `uid` titled `title`, written by app instance `instance`.
pub fn file_name(title: &str, instance: &str, uid: u64) -> String {
// File systems limit a name to 255 bytes, so the stem gets what the suffix leaves, cut at a character boundary.
let suffix = format!("_{instance}_{uid}.{EXT}");
let budget = 255usize.saturating_sub(suffix.len());
let mut used = 0;
let stem: String = title
.chars()
.map(|c| if c.is_alphanumeric() || " .-_()".contains(c) { c } else { '-' })
.take(80)
.take_while(|c| {
used += c.len_utf8();
used <= budget
})
.collect();
let stem = stem.trim().trim_matches('.');
let stem = if stem.is_empty() { "Drawing" } else { stem };
format!("{stem}{suffix}")
}
/// Whether `s` has the form of an app instance id (`<process id>-<start time in hex>`, see
/// `instance_id`). Only file names built from one are treated as CADCraft's own, so files of
/// other programs in the autosave folder are never offered for recovery or deleted.
pub fn is_instance_id(s: &str) -> bool {
let Some((pid, time)) = s.split_once('-') else { return false };
(1..=10).contains(&pid.len())
&& pid.bytes().all(|b| b.is_ascii_digit())
&& (1..=32).contains(&time.len())
&& time.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
/// An autosave file name's parts: the drawing's title, the instance and the drawing uid.
pub fn parse_file_name(name: &str) -> Option<(&str, &str, u64)> {
let base = name.strip_suffix(&format!(".{EXT}"))?;
let (rest, uid) = base.rsplit_once('_')?;
let (title, instance) = rest.rsplit_once('_')?;
let uid_ok = !uid.is_empty() && uid.bytes().all(|b| b.is_ascii_digit());
(!title.is_empty() && is_instance_id(instance) && uid_ok).then_some(())?;
Some((title, instance, uid.parse().ok()?))
}
/// The instance whose lock file is named `name` (`<instance>.lock`), if it is one of ours.
pub fn parse_lock_name(name: &str) -> Option<&str> {
name.strip_suffix(".lock").filter(|i| is_instance_id(i))
}
/// What to do with the file system.
#[derive(Clone, Debug)]
pub enum Job {
/// Write `doc` (drawing `uid`) to `path`.
Write {
uid: u64,
path: String,
doc: Arc<Drawing>,
},
Delete {
path: String,
},
}
#[derive(Debug, Default)]
struct Tracked {
/// The drawing as last autosaved (a weak pointer: compared, never kept alive).
written: Option<Weak<Drawing>>,
/// The drawing outside the Block Editor as last autosaved (the same as `written` when the
/// editor is closed): saving the editor's changes into the block changes only this.
host: Option<Weak<Drawing>>,
/// Its autosave file, once written (or adopted).
file: Option<String>,
/// When a change not yet autosaved was first seen (seconds).
since: Option<f64>,
}
/// Autosave state for one running app.
pub struct Autosaver {
/// This app instance's id (`<process id>-<start time>`), part of every file name.
pub instance: String,
docs: HashMap<u64, Tracked>,
/// Drawings whose autosave is being written right now.
busy: HashSet<u64>,
/// 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<std::result::Result<(), String>>)>,
#[cfg(not(target_arch = "wasm32"))]
start: std::time::Instant,
#[cfg(not(target_arch = "wasm32"))]
last_beat: Option<f64>,
/// This app's lock files: one per folder, because SAVEFILEPATH can change while files remain
/// in the old folder. Each with the file it holds open for the system lock, or `None` where
/// locks don't work and only the file's age counts.
#[cfg(not(target_arch = "wasm32"))]
locks: Vec<(std::path::PathBuf, Option<std::fs::File>)>,
}
impl Autosaver {
pub fn new(instance: impl Into<String>) -> Self {
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"))]
locks: Vec::new(),
}
}
/// Take over an existing autosave file for drawing `uid` (a drawing opened from Drawing
/// Recovery, whose current state it holds): later autosaves overwrite it, and it is deleted
/// once the drawing is saved or closed.
pub fn adopt(&mut self, s: &Session, uid: u64, path: &str) {
let st = s.docs.iter().find(|d| d.uid == uid);
let (written, host) = (st.map(|d| Arc::downgrade(&d.doc)), st.map(|d| Arc::downgrade(d.drawing())));
self.docs.insert(uid, Tracked { written, host, file: Some(path.to_string()), since: None });
}
/// The autosave files this app has written or adopted.
pub fn files(&self) -> Vec<String> {
self.docs.values().filter_map(|t| t.file.clone()).collect()
}
/// The drawings with no up-to-date recovery copy and why, for the host to show the user.
pub fn failures(&self) -> impl Iterator<Item = (u64, &str)> {
self.failed.iter().map(|(uid, why)| (*uid, why.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.
pub fn plan(&mut self, s: &Session, now: f64, minutes: u32, dir: &str) -> Vec<Job> {
self.plan_with(s, now, minutes, dir, false)
}
/// [`Autosaver::plan`], with `urgent` writing every drawing with changes not yet autosaved
/// now instead of `minutes` after the change (autosave still off when `minutes` is 0).
fn plan_with(&mut self, s: &Session, now: f64, minutes: u32, dir: &str, urgent: bool) -> Vec<Job> {
let mut jobs = Vec::new();
let live: HashSet<u64> = s.docs.iter().map(|d| d.uid).collect();
let busy = &self.busy;
self.docs.retain(|uid, t| {
// A file being written is deleted once the write is done.
if live.contains(uid) || busy.contains(uid) {
return true;
}
if let Some(path) = t.file.take() {
jobs.push(Job::Delete { path });
}
false
});
for st in &s.docs {
if self.busy.contains(&st.uid) {
continue;
}
let t = self.docs.entry(st.uid).or_default();
if !st.is_dirty() {
if let Some(path) = t.file.take() {
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)));
let changed = !same(&t.written, &st.doc) || !same(&t.host, st.drawing());
if !changed {
t.since = None;
continue;
}
let since = *t.since.get_or_insert(now);
if minutes == 0 || dir.trim().is_empty() || (!urgent && now - since < f64::from(minutes) * 60.0) {
continue;
}
// In the Block Editor: the drawing with the editor's unsaved changes in the block. When
// they can't be put there, nothing is written and nothing is marked as covered, so the
// next pass tries again instead of keeping the host without them.
let Ok(doc) = st.recovery_drawing() else {
continue;
};
let path = t
.file
.clone()
.unwrap_or_else(|| std::path::Path::new(dir).join(file_name(&st.title, &self.instance, st.uid)).to_string_lossy().to_string());
t.file = Some(path.clone());
t.written = Some(Arc::downgrade(&st.doc));
t.host = Some(Arc::downgrade(st.drawing()));
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
}
/// Seconds from `now` until the next autosave is due, if one is waiting.
pub fn next_due(&self, now: f64, minutes: u32) -> Option<f64> {
if minutes == 0 {
return None;
}
self.docs.values().filter_map(|t| t.since).map(|since| (since + f64::from(minutes) * 60.0 - now).max(0.0)).reduce(f64::min)
}
}
/// A file Drawing Recovery offers: an autosave left behind by an app that is no longer running.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Recoverable {
pub path: String,
/// The drawing's title when it was autosaved.
pub drawing: String,
/// When it was written, in seconds since 1970.
pub modified: u64,
pub bytes: u64,
}
#[cfg(not(target_arch = "wasm32"))]
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, 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.
pub fn instance_id() -> String {
let t = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
format!("{}-{t:x}", std::process::id())
}
fn lock_path(dir: &Path, instance: &str) -> std::path::PathBuf {
dir.join(format!("{instance}.lock"))
}
/// Whether the app instance that owns a lock file modified `age` ago (`None`: no lock file)
/// may still be running.
pub fn instance_alive(age: Option<Duration>) -> bool {
age.is_some_and(|a| a < Duration::from_secs(STALE_LOCK_SECS))
}
fn lock_age(dir: &Path, instance: &str) -> Option<Duration> {
let m = std::fs::metadata(lock_path(dir, instance)).and_then(|m| m.modified()).ok()?;
// A time in the future (clock changes) counts as fresh.
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> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let mut alive: HashMap<String, bool> = HashMap::new();
let mut out = Vec::new();
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(|| owner_alive(dir, instance)) {
continue;
}
let Ok(meta) = e.metadata() else { continue };
if !meta.is_file() {
continue;
}
let modified = meta.modified().ok().and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok()).map(|d| d.as_secs()).unwrap_or(0);
out.push(Recoverable { path: e.path().to_string_lossy().to_string(), drawing: title.to_string(), modified, bytes: meta.len() });
}
out.sort_by(|a, b| a.modified.cmp(&b.modified).then_with(|| a.path.cmp(&b.path)));
out
}
/// Delete a recovery file the user discarded (or that was recovered and then saved), and lock
/// files of stopped apps that have no files left. Only an autosave file is deleted: a path
/// whose name doesn't parse as one ([`parse_file_name`]) is left alone.
pub fn discard(path: &str) {
let ours = Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).is_some_and(|n| parse_file_name(n).is_some());
if !ours {
log::warn!("autosave: not deleting {path}: not an autosave file");
return;
}
if let Err(e) = std::fs::remove_file(path)
&& e.kind() != std::io::ErrorKind::NotFound
{
log::warn!("autosave: can't delete {path}: {e}");
}
if let Some(dir) = Path::new(path).parent() {
prune_locks(dir);
}
}
/// Delete the lock files of apps that are no longer running and left no autosave files. Only
/// names of the form `<instance id>.lock` are ours ([`parse_lock_name`]): SAVEFILEPATH may
/// point at a folder shared with other programs, whose `Cargo.lock` or `foo.lock` stay.
pub fn prune_locks(dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
let names: Vec<String> = entries.flatten().take(10_000).map(|e| e.file_name().to_string_lossy().to_string()).collect();
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 && !owner_alive(dir, instance) {
let _ = std::fs::remove_file(dir.join(n));
}
}
}
type WriteHook = fn(&Drawing, &str) -> std::result::Result<Vec<u8>, String>;
/// Create the autosave folder `dir` and any missing parents. On Unix the folders made here are
/// owner-only (`0o700`): autosave files are unsaved drawings, and with the default mode other
/// accounts on the machine could read them. A folder that already exists keeps its mode, since
/// SAVEFILEPATH can be any folder the user picked.
fn create_folder(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
fn write_file(write: WriteHook, doc: &Drawing, path: &str) -> std::result::Result<(), String> {
let bytes = write(doc, "autosave.dxf")?;
if let Some(dir) = Path::new(path).parent() {
create_folder(dir).map_err(|e| e.to_string())?;
}
// Create a fresh temporary file exclusively and rename it into place, so a planted
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
}
impl Autosaver {
/// Autosave what is due (writing in the background) and delete files no longer needed.
/// Returns how long until the next autosave is due, so the host can wake up for it.
pub fn tick(&mut self, s: &Session) -> Option<Duration> {
let now = self.start.elapsed().as_secs_f64();
self.reap();
let dir = savefilepath();
let minutes = savetime();
let jobs = self.plan(s, now, minutes, &dir);
self.perform(jobs);
self.heartbeat(&dir, now);
self.next_due(now, minutes).map(Duration::from_secs_f64)
}
/// Autosave every drawing with changes not yet autosaved right away, without waiting for
/// SAVETIME (the app may not survive what just happened, e.g. a GPU error). Returns how
/// many drawings are being written.
pub fn save_now(&mut self, s: &Session) -> usize {
let now = self.start.elapsed().as_secs_f64();
let jobs = self.plan_with(s, now, savetime(), &savefilepath(), true);
let writes = jobs.iter().filter(|j| matches!(j, Job::Write { .. })).count();
self.perform(jobs);
// The lock file marks the files as this running app's (Drawing Recovery skips them).
self.last_beat = None;
self.heartbeat(&savefilepath(), now);
writes
}
/// Whether a background write hasn't finished yet. Its file can exist before it finishes
/// (the folder is synced after the rename), and until [`Autosaver::reap`] takes it in, the
/// drawing is left out of planning.
pub fn writing(&self) -> bool {
self.running.iter().any(|(_, h)| !h.is_finished())
}
/// Whether a write's result hasn't been taken in yet, finished or not: the host needs
/// another [`Autosaver::tick`] to learn whether it failed.
pub fn results_pending(&self) -> bool {
!self.running.is_empty()
}
/// 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, 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.settle(uid, h);
}
}
fn settle(&mut self, uid: u64, h: std::thread::JoinHandle<std::result::Result<(), String>>) {
self.busy.remove(&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),
}
}
/// Write every drawing with changes not yet autosaved and wait until it is done, because
/// the app has gone wrong and may not get another frame. Unlike [`Autosaver::save_now`]
/// this also runs when SAVETIME is 0: unsaved work comes before that preference. Returns
/// how many drawings were written.
pub fn emergency_save(&mut self, s: &Session) -> usize {
for (uid, h) in std::mem::take(&mut self.running) {
self.settle(uid, h);
}
let now = self.start.elapsed().as_secs_f64();
let dir = savefilepath();
let jobs = self.plan_with(s, now, savetime().max(1), &dir, true);
let uids: Vec<u64> = jobs.iter().filter_map(|j| if let Job::Write { uid, .. } = j { Some(*uid) } else { None }).collect();
self.perform(jobs);
self.last_beat = None;
self.heartbeat(&dir, now);
for (uid, h) in std::mem::take(&mut self.running) {
self.settle(uid, h);
}
uids.iter().filter(|uid| !self.failed.contains_key(uid)).count()
}
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.
pub fn perform(&mut self, jobs: Vec<Job>) {
for job in jobs {
match job {
Job::Delete { path } => discard(&path),
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}");
Ok(())
}
Err(e) => {
log::warn!("autosave {path}: {e}");
Err(format!("{path}: {e}"))
}
});
match spawned {
Ok(h) => {
self.busy.insert(uid);
self.running.push((uid, h));
}
Err(e) => {
log::warn!("autosave: {e}");
self.unwritten(uid, e.to_string());
}
}
}
}
}
}
/// Keep this app's lock file fresh while it has autosave files.
fn heartbeat(&mut self, dir: &str, now: f64) {
if dir.is_empty() || self.docs.values().all(|t| t.file.is_none()) || self.last_beat.is_some_and(|b| now - b < HEARTBEAT_SECS) {
return;
}
self.last_beat = Some(now);
let dir = Path::new(dir);
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 this app's lock files, and start one at `path` if there isn't one yet, taking
/// the system lock on it if it can. Without that lock the file still works by its age.
/// Only trouble at `path` is an error: an older folder may be gone by now.
fn refresh_lock(&mut self, path: &Path) -> std::io::Result<()> {
let id = std::process::id();
let mut current = None;
for (held, file) in &self.locks {
let refreshed = match file {
Some(file) => file.set_modified(SystemTime::now()),
None => std::fs::write(held, id.to_string()),
};
if held == path {
current = Some(refreshed);
}
}
if let Some(refreshed) = current {
return refreshed;
}
let mut file = OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
if file.try_lock().is_err() {
self.locks.push((path.to_path_buf(), None));
return std::fs::write(path, id.to_string());
}
file.set_len(0)?;
file.write_all(format!("{id}{LOCK_HELD}").as_bytes())?;
self.locks.push((path.to_path_buf(), Some(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) {
for (_, h) in self.running.drain(..) {
let _ = h.join();
}
self.busy.clear();
for path in self.files() {
discard(&path);
}
self.docs.clear();
for (path, file) in self.locks.drain(..) {
drop(file);
let _ = std::fs::remove_file(path);
}
let dir = savefilepath();
if !dir.is_empty() && is_instance_id(&self.instance) {
let _ = std::fs::remove_file(lock_path(Path::new(&dir), &self.instance));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn file_name_fits_255_bytes_for_multibyte_titles() {
let instance = "12345-abcdef0123456789";
for title in ["图".repeat(100), "😀".repeat(100), "aé图😀".repeat(40), "a".repeat(300)] {
let name = file_name(&title, instance, u64::MAX);
assert!(name.len() <= 255, "{} bytes", name.len());
let (t, i, u) = parse_file_name(&name).expect("round trips");
assert_eq!((i, u), (instance, u64::MAX));
assert!(!t.is_empty());
}
}
}