mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 03:06:56 -06:00
Merge #506: Recovery: each running app keeps its own recovery data
This commit is contained in:
@@ -22,7 +22,16 @@ fn config_dir_with(os: &str, env: impl Fn(&str) -> Option<OsString>) -> Option<P
|
||||
}
|
||||
|
||||
pub fn recovery_dir() -> Option<PathBuf> {
|
||||
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir)
|
||||
recovery_dir_with(std::env::var_os("DESIGNCRAFT_CONFIG_DIR"), designcraft_engine::recovery::default_dir).map(own_run)
|
||||
}
|
||||
|
||||
/// This run's folder in the recovery folder `root`. A second running DesignCraft has its own, so
|
||||
/// neither writes over the other's documents nor takes them for a crash's.
|
||||
fn own_run(root: PathBuf) -> PathBuf {
|
||||
designcraft_engine::recovery::run_dir(&root).unwrap_or_else(|e| {
|
||||
log::warn!("recovery data is shared with other running apps: {e}");
|
||||
root
|
||||
})
|
||||
}
|
||||
|
||||
fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
@@ -68,4 +77,17 @@ mod tests {
|
||||
assert_eq!(config_dir_with("linux", |key| if key == "HOME" { environment(key) } else { None }), Some("/user/.config/designcraft".into()));
|
||||
assert_eq!(recovery_dir_with(None, || Some("/existing/recovery".into())), Some("/existing/recovery".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_has_its_own_recovery_folder_when_one_can_be_made() {
|
||||
let dir = std::env::temp_dir().join(format!("designcraft-profile-run-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let root = dir.join("Recovery");
|
||||
assert_eq!(own_run(root.clone()).parent(), Some(root.as_path()));
|
||||
// Nothing can be made under a file: the recovery folder itself, as before.
|
||||
let blocked = dir.join("blocked");
|
||||
std::fs::write(&blocked, "a file where the folder should be").unwrap();
|
||||
assert_eq!(own_run(blocked.join("Recovery")), blocked.join("Recovery"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
//! A recovery entry is `<uid>.designcraft` (the document) plus `<uid>.json` (`{"path", "title",
|
||||
//! "saved"}`: the original file, the title, and when it was written).
|
||||
//!
|
||||
//! Document uids start at 1 in every process, so each running app keeps its entries in a folder
|
||||
//! of its own in the recovery folder (`run_dir`) and holds the lock in it for as long as it runs.
|
||||
//! An app that starts moves into its own folder what apps that have ended left in theirs, and
|
||||
//! leaves the folder of one still running alone.
|
||||
//!
|
||||
//! Each file is replaced atomically, but the pair is not a transaction. The document is published
|
||||
//! and made durable before its metadata so a crash can leave metadata missing or stale, never a
|
||||
//! listed metadata record without a complete primary document. Listing and opening recovery data
|
||||
@@ -31,6 +36,170 @@ pub fn default_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).or_else(|| home.map(|h| h.join(".local/share"))).map(|d| d.join("designcraft/recovery"))
|
||||
}
|
||||
|
||||
/// This process's folder in a recovery folder, with the lock that says it is still running.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct Run {
|
||||
root: PathBuf,
|
||||
dir: PathBuf,
|
||||
lock: std::fs::File,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static RUNS: std::sync::Mutex<Vec<Run>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// The lock in a run's folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const LOCK: &str = "lock";
|
||||
|
||||
/// This run's own folder in the recovery folder `root`, made on the first call. Document uids
|
||||
/// start at 1 in every process, so two running apps writing to one folder wrote over each
|
||||
/// other's entries. The folder's lock is held until the process ends: an app that starts leaves
|
||||
/// the folder of one that is still running alone.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn run_dir(root: &Path) -> Result<PathBuf> {
|
||||
let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(run) = runs.iter().find(|r| r.root == root) {
|
||||
return Ok(run.dir.clone());
|
||||
}
|
||||
secure_recovery_dir(root)?;
|
||||
for n in 0..64 {
|
||||
let dir = root.join(format!("run-{}-{n}", std::process::id()));
|
||||
// Only a folder this run makes is its own. One that is there is another app's, or was
|
||||
// left by a run that ended under this process id.
|
||||
let mut new = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
new.mode(0o700);
|
||||
}
|
||||
match new.create(&dir) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
}
|
||||
let locked = validate_recovery_dir(&dir).and_then(|()| {
|
||||
let lock = new_lock(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
match lock.try_lock() {
|
||||
Ok(()) => Ok(Some(lock)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Ok(None),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
}
|
||||
});
|
||||
match locked {
|
||||
// An app that is starting can take a folder this new for one a run left, and remove
|
||||
// it before the lock is held: then the lock is gone, or that app holds it.
|
||||
Ok(Some(lock)) if std::fs::symlink_metadata(dir.join(LOCK)).is_ok() => {
|
||||
runs.push(Run { root: root.to_path_buf(), dir: dir.clone(), lock });
|
||||
return Ok(dir);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(&dir);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(EngineError::Other(format!("{}: no free folder for this run", root.display())))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn new_lock(dir: &Path) -> std::io::Result<std::fs::File> {
|
||||
let mut new = std::fs::OpenOptions::new();
|
||||
new.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
new.mode(0o600);
|
||||
}
|
||||
new.open(dir.join(LOCK))
|
||||
}
|
||||
|
||||
/// A run's folder that was removed while it runs is made again by the next save: so is its lock.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn relock(dir: &Path) {
|
||||
let mut runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return };
|
||||
if std::fs::symlink_metadata(dir.join(LOCK)).is_err()
|
||||
&& let Ok(lock) = new_lock(dir)
|
||||
&& lock.try_lock().is_ok()
|
||||
{
|
||||
run.lock = lock;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs
|
||||
/// that have ended, and those written straight into the recovery folder before runs had folders.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn take_over(own: &Path) {
|
||||
let root = {
|
||||
let runs = RUNS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let Some(run) = runs.iter().find(|r| r.dir == own) else { return };
|
||||
run.root.clone()
|
||||
};
|
||||
move_entries(&root, own);
|
||||
let Ok(rd) = std::fs::read_dir(&root) else { return };
|
||||
for e in rd.flatten() {
|
||||
let dir = e.path();
|
||||
if dir == own || !e.file_name().to_string_lossy().starts_with("run-") || validate_recovery_dir(&dir).is_err() {
|
||||
continue;
|
||||
}
|
||||
// Opened for writing: over NFS an exclusive lock on a file opened for reading is refused.
|
||||
let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue };
|
||||
match lock.try_lock() {
|
||||
// Free: the run has ended. Holding it keeps another starting app out.
|
||||
Ok(()) => {}
|
||||
Err(std::fs::TryLockError::WouldBlock) => continue,
|
||||
Err(std::fs::TryLockError::Error(e)) => {
|
||||
log::warn!("recovery data in {} is left there: {e}", dir.display());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
move_entries(&dir, own);
|
||||
remove_emptied(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the folder of a run that ended, with what the app left in it beside documents. A
|
||||
/// document still in it, or a file the app did not write, keeps the folder as it is.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn remove_emptied(dir: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
let mut left = Vec::new();
|
||||
for e in rd {
|
||||
let Ok(e) = e else { return };
|
||||
let path = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
let numbered = path.file_stem().and_then(|s| s.to_str()).is_some_and(|s| s.parse::<u64>().is_ok());
|
||||
// Metadata whose document has gone, and a file a crash left half written.
|
||||
if (numbered && name.ends_with(".json")) || (name.starts_with(".designcraft-recovery-") && name.ends_with(".tmp")) {
|
||||
left.push(path);
|
||||
} else if name != LOCK {
|
||||
return;
|
||||
}
|
||||
}
|
||||
for path in left {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
// The lock last: until then the folder is still that of a run that ended.
|
||||
let _ = std::fs::remove_file(dir.join(LOCK));
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn move_entries(from: &Path, own: &Path) {
|
||||
for (uid, _) in list(from) {
|
||||
let (doc, meta) = files(from, uid);
|
||||
// Under a uid no document of this run has or gets.
|
||||
let (own_doc, own_meta) = files(own, crate::NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
// The document first: its metadata is optional.
|
||||
if std::fs::rename(&doc, &own_doc).is_ok() {
|
||||
let _ = std::fs::rename(&meta, &own_meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
(dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json")))
|
||||
}
|
||||
@@ -50,6 +219,8 @@ pub fn discard(dir: &Path, uid: u64) {
|
||||
/// many were written.
|
||||
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
secure_recovery_dir(dir)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
relock(dir);
|
||||
let mut n = 0;
|
||||
for d in &s.docs {
|
||||
if !d.is_dirty() {
|
||||
@@ -220,6 +391,9 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
validate_recovery_dir(dir)?;
|
||||
let mut opened = Vec::new();
|
||||
// For this run's own folder, first bring in what runs that ended left in theirs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
take_over(dir);
|
||||
for (uid, meta) in list(dir) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
validate_recovery_dir(dir)?;
|
||||
@@ -373,6 +547,223 @@ mod tests {
|
||||
assert!(open(&mut Session::new(), &link).is_err());
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_has_a_locked_folder_of_its_own() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let dir = root.path().join("recovery");
|
||||
let own = run_dir(&dir).unwrap();
|
||||
assert_eq!(own.parent(), Some(dir.as_path()));
|
||||
assert_eq!(run_dir(&dir).unwrap(), own, "one for the whole run");
|
||||
let lock = std::fs::File::open(own.join(LOCK)).unwrap();
|
||||
assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)), "held while the run lives");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(std::fs::metadata(&own).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
assert_eq!(std::fs::metadata(own.join(LOCK)).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recovery folder was cleared while the app runs: the next save makes the run's folder
|
||||
/// again, and its lock with it.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_removed_run_folder_is_made_again_with_its_lock() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
std::fs::remove_dir_all(&own).unwrap();
|
||||
assert_eq!(save(&dirty_session(), &own).unwrap(), 1);
|
||||
let lock = std::fs::File::open(own.join(LOCK)).unwrap();
|
||||
assert!(matches!(lock.try_lock(), Err(std::fs::TryLockError::WouldBlock)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_run_gets_no_folder_in_a_symlinked_recovery_folder() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let destination = root.path().join("destination");
|
||||
let link = root.path().join("recovery");
|
||||
std::fs::create_dir(&destination).unwrap();
|
||||
symlink(&destination, &link).unwrap();
|
||||
|
||||
assert!(run_dir(&link).is_err());
|
||||
assert!(std::fs::read_dir(&destination).unwrap().next().is_none());
|
||||
}
|
||||
|
||||
fn unsaved(text: &str) -> Session {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
session.execute("frame.create", &json!({"rect": [10, 10, 100, 100], "content": "text", "text": text})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
fn texts(s: &Session) -> Vec<String> {
|
||||
let mut texts: Vec<String> = s.documents().iter().flat_map(|d| d.doc.stories.values().map(|st| st.text.clone())).collect();
|
||||
texts.sort();
|
||||
texts
|
||||
}
|
||||
|
||||
/// What a run that has ended leaves: its folder with its lock, free now, and the entry of
|
||||
/// one unsaved document, numbered `uid` as that process numbered it.
|
||||
fn run_left(root: &Path, name: &str, text: &str, uid: u64) -> PathBuf {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
let mut s = unsaved(text);
|
||||
s.docs[0].uid = uid;
|
||||
assert_eq!(save(&s, &dir).unwrap(), 1);
|
||||
dir
|
||||
}
|
||||
|
||||
/// Every process numbers its first document 1: two running apps both wrote `1.designcraft`
|
||||
/// into the one recovery folder, and only the document written last was recovered.
|
||||
#[test]
|
||||
fn documents_of_two_apps_that_ended_are_both_recovered() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let first = run_left(root.path(), "run-7-0", "first app", 1);
|
||||
let second = run_left(root.path(), "run-8-0", "second app", 1);
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &own).unwrap().len(), 2);
|
||||
assert_eq!(texts(&s), ["first app", "second app"]);
|
||||
assert!(s.documents().iter().all(DocState::is_dirty));
|
||||
assert!(!first.exists() && !second.exists(), "nothing is left of the runs that ended");
|
||||
}
|
||||
|
||||
/// An app that started reopened the unsaved documents of one still running, and removed
|
||||
/// its entries.
|
||||
#[test]
|
||||
fn a_running_apps_recovery_data_is_left_alone() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let running = run_left(root.path(), "run-7-0", "still open", 1);
|
||||
let lock = std::fs::File::open(running.join(LOCK)).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert!(open(&mut s, &own).unwrap().is_empty());
|
||||
assert_eq!(list(&running).len(), 1);
|
||||
// Once it has ended, its document is recovered.
|
||||
drop(lock);
|
||||
assert_eq!(open(&mut s, &own).unwrap().len(), 1);
|
||||
assert_eq!(texts(&s), ["still open"]);
|
||||
assert!(!running.exists());
|
||||
}
|
||||
|
||||
/// A process id comes round again: the folder a run left under it is not this run's.
|
||||
#[test]
|
||||
fn a_folder_left_under_this_process_id_is_not_reused() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let left = run_left(root.path(), &format!("run-{}-0", std::process::id()), "left behind", 1);
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
assert_ne!(own, left);
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &own).unwrap().len(), 1);
|
||||
assert_eq!(texts(&s), ["left behind"]);
|
||||
}
|
||||
|
||||
/// Entries written straight into the recovery folder, as before runs had folders.
|
||||
#[test]
|
||||
fn recovery_data_of_an_earlier_version_is_recovered() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
assert_eq!(save(&unsaved("earlier version"), root.path()).unwrap(), 1);
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &own).unwrap().len(), 1);
|
||||
assert_eq!(texts(&s), ["earlier version"]);
|
||||
assert!(list(root.path()).is_empty());
|
||||
}
|
||||
|
||||
/// An entry that can't be opened stays on disk, in this run's folder now.
|
||||
#[test]
|
||||
fn an_unreadable_entry_of_a_run_that_ended_is_kept() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let left = run_left(root.path(), "run-7-0", "readable", 1);
|
||||
std::fs::write(left.join("2.designcraft"), b"not a document").unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &own).unwrap().len(), 1);
|
||||
assert!(list(&own).iter().any(|e| std::fs::read(files(&own, e.0).0).unwrap() == b"not a document"));
|
||||
assert!(!left.exists());
|
||||
}
|
||||
|
||||
/// Only a folder named as a run's, with a lock that is free, is a run that ended.
|
||||
#[test]
|
||||
fn what_is_no_run_that_ended_is_passed_over() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("run-1-0"), b"a file").unwrap();
|
||||
// An app that is starting has made its folder and not yet its lock.
|
||||
let starting = root.path().join("run-2-0");
|
||||
std::fs::create_dir(&starting).unwrap();
|
||||
let other = run_left(root.path(), "backup", "not a run", 1);
|
||||
#[cfg(unix)]
|
||||
let (_elsewhere, linked) = {
|
||||
let elsewhere = tempfile::tempdir().unwrap();
|
||||
let linked = run_left(elsewhere.path(), "run-3-0", "behind a link", 1);
|
||||
std::os::unix::fs::symlink(&linked, root.path().join("run-3-0")).unwrap();
|
||||
(elsewhere, linked)
|
||||
};
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert!(open(&mut s, &own).unwrap().is_empty());
|
||||
assert!(starting.exists());
|
||||
assert_eq!(list(&other).len(), 1);
|
||||
#[cfg(unix)]
|
||||
assert!(list(&linked).len() == 1 && root.path().join("run-3-0").is_symlink());
|
||||
}
|
||||
|
||||
/// The metadata goes with its document: a document taken over remembers its file.
|
||||
#[test]
|
||||
fn a_document_taken_over_remembers_its_file() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let file = root.path().join("layout.designcraft");
|
||||
let recovery = root.path().join("recovery");
|
||||
let left = recovery.join("run-7-0");
|
||||
std::fs::create_dir_all(&left).unwrap();
|
||||
std::fs::write(left.join(LOCK), b"").unwrap();
|
||||
let mut a = unsaved("saved once");
|
||||
a.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap();
|
||||
a.execute("frame.create", &json!({"rect": [10, 10, 100, 100]})).unwrap();
|
||||
assert_eq!(save(&a, &left).unwrap(), 1);
|
||||
let own = run_dir(&recovery).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &own).unwrap().len(), 1);
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(file.to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
/// The folder of a run that ended goes with what the app left in it. A file the app did not
|
||||
/// write keeps the folder, and is not deleted.
|
||||
#[test]
|
||||
fn only_what_the_app_left_goes_with_the_folder_of_a_run_that_ended() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let emptied = run_left(root.path(), "run-7-0", "moved", 1);
|
||||
std::fs::write(emptied.join("9.json"), b"{}").unwrap();
|
||||
std::fs::write(emptied.join(".designcraft-recovery-abc.tmp"), b"half written").unwrap();
|
||||
let kept = run_left(root.path(), "run-8-0", "moved too", 1);
|
||||
std::fs::write(kept.join("copy.designcraft"), b"a copy someone put here").unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
let mut s = Session::new();
|
||||
assert_eq!(open(&mut s, &own).unwrap().len(), 2);
|
||||
assert!(!emptied.exists());
|
||||
assert_eq!(std::fs::read(kept.join("copy.designcraft")).unwrap(), b"a copy someone put here");
|
||||
assert!(kept.join(LOCK).exists(), "still the folder of a run that ended");
|
||||
}
|
||||
|
||||
/// A folder that is there is another app's or was left behind, lock or no lock: a run's own
|
||||
/// is one it made itself.
|
||||
#[test]
|
||||
fn a_folder_that_is_there_is_not_made_this_runs() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let left = root.path().join(format!("run-{}-0", std::process::id()));
|
||||
std::fs::create_dir(&left).unwrap();
|
||||
std::fs::write(left.join("1.designcraft"), b"left with no lock").unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
assert_ne!(own, left);
|
||||
assert!(!left.join(LOCK).exists());
|
||||
assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -288,7 +288,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
- Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`.
|
||||
- Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts.
|
||||
- Damage-region repaint: after an edit the canvas re-renders only the frames whose composed text (or the items that) changed and patches its texture — pixel-identical to a full render; typing in a frame repaints in under a millisecond instead of a full-viewport render (22 ms measured on the sample).
|
||||
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Crash recovery: unsaved documents are written to the recovery folder every 30 s and reopened (unsaved, remembering their files) after a crash; each running app writes to a folder of its own there, and an app that starts takes over only what apps that have ended left; File › Revert and Save a Copy…; `file.recovery.*`, `file.revert`, `file.saveACopy`.
|
||||
- Graphics backend chosen before the window exists (DirectX 12 on Windows, Vulkan on Linux, Metal on macOS, OpenGL as the fallback; `WGPU_BACKEND` overrides) with a start-up fallback: a backend whose start never showed a frame is skipped at the next start and the status bar says so (`gpu.json`). Found on an AMD hybrid-graphics laptop whose Vulkan driver faulted at the first present.
|
||||
- Graphics adapter chosen by the app: the GPU that drives the primary display (Windows `EnumDisplayDevices`, Linux sysfs), then the power preference (`WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` override). An adapter that fails or hangs while the window starts is left out and the app starts again on the next one, then on the next backend, with software renderers last; wgpu's indirect-call check is off (it lost the Metal device on an Intel Mac, #334); why the app stopped is logged, also when the window never opened (#273). Ported from VectorCraft.
|
||||
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
|
||||
|
||||
Reference in New Issue
Block a user