fix(recovery): isolate recovery data between app instances
Keep each app's recovery data in an owned run folder. Validate current folder and lock ownership and disable recovery when isolation fails instead of using the shared root. Fixes #496. Co-authored-by: noah1400 <48360809+noah1400@users.noreply.github.com> Co-authored-by: skylar taylor-barrick <hi@skylar.cc>
This commit is contained in:
parent
5cdcac4ace
commit
328b4d5a52
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (atomic engine saves and exports) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (isolated recovery for each running app) · **Target:** Adobe InDesign 2026
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
|
||||
format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
|
|
@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
|||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-12** · Each running app keeps recovery data in its own locked folder; current folder and lock ownership are checked before writes and cleanup. Failed isolation disables recovery with a warning instead of sharing document IDs. No readiness percentage change.
|
||||
- **2026-10-12** · Native engine saves and exports replace files atomically; failed writes retain the original, private staging preserves Unix permissions, and valid long filenames work. Desktop Services and CLI raster exports remain separate follow-ups. No readiness percentage change.
|
||||
- **2026-10-12** · Words in capitals hyphenate like capitalized words (FERRUM COM-MODI), as in InDesign; Hyphenate Capitalized Words off keeps them whole. No readiness percentage change.
|
||||
- **2026-10-11** · `transform.move {copy}`, Edit › Duplicate and Step and Repeat return the copies' ids, so a script can go on with them. No readiness percentage change.
|
||||
|
|
|
|||
|
|
@ -22,7 +22,19 @@ 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).and_then(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) -> Option<PathBuf> {
|
||||
match designcraft_engine::recovery::run_dir(&root) {
|
||||
Ok(dir) => Some(dir),
|
||||
Err(e) => {
|
||||
log::warn!("recovery is unavailable because this run cannot be isolated: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recovery_dir_with(root: Option<OsString>, default: impl FnOnce() -> Option<PathBuf>) -> Option<PathBuf> {
|
||||
|
|
@ -68,4 +80,29 @@ 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()).as_deref().and_then(|dir| dir.parent()), Some(root.as_path()));
|
||||
// Failure must not fall back to the shared root.
|
||||
let blocked = dir.join("blocked");
|
||||
std::fs::write(&blocked, "a file where the folder should be").unwrap();
|
||||
assert_eq!(own_run(blocked.join("Recovery")), None, "isolation failure must not select the shared root");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exhausted_run_namespace_never_falls_back_to_shared_recovery() {
|
||||
let root = std::env::temp_dir().join(format!("designcraft-profile-exhausted-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
for n in 0..64 {
|
||||
std::fs::create_dir(root.join(format!("run-{}-{n}", std::process::id()))).unwrap();
|
||||
}
|
||||
assert_eq!(own_run(root.clone()), None, "a shared root would restore inter-process UID collisions");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,295 @@ 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(unix)]
|
||||
directory: 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.
|
||||
#[cfg(unix)]
|
||||
let made = {
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.mode(0o700).create(&dir)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let made = std::fs::create_dir(&dir);
|
||||
match made {
|
||||
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(()) => {
|
||||
validate_lock(&dir, &lock)?;
|
||||
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)) => {
|
||||
#[cfg(unix)]
|
||||
let directory = std::fs::File::open(&dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
runs.push(Run {
|
||||
root: root.to_path_buf(),
|
||||
dir: dir.clone(),
|
||||
lock,
|
||||
#[cfg(unix)]
|
||||
directory,
|
||||
});
|
||||
return Ok(dir);
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
// Never unlink a lock that another starting run may now own.
|
||||
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.read(true).write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
new.mode(0o600);
|
||||
}
|
||||
let mut file = new.open(dir.join(LOCK))?;
|
||||
// A marker distinguishes the held handle from a replacement at the same pathname on
|
||||
// platforms without portable file-identity metadata. Empty older-version locks remain valid.
|
||||
static NEXT_LOCK: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(std::io::Error::other)?.as_nanos();
|
||||
let marker = format!("{}:{}:{now}", std::process::id(), NEXT_LOCK.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
|
||||
file.write_all(marker.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
/// Check the current lock name without accepting a symlink or a lock held through an unlinked
|
||||
/// handle. An advisory lock on an old handle does not protect a replacement at the same name.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn validate_lock(dir: &Path, lock: &std::fs::File) -> Result<()> {
|
||||
let path = dir.join(LOCK);
|
||||
let fail = || EngineError::Other(format!("{}: this run no longer owns its recovery lock", path.display()));
|
||||
let before = std::fs::symlink_metadata(&path).map_err(|_| fail())?;
|
||||
if is_link_like(&before) || !before.file_type().is_file() {
|
||||
return Err(fail());
|
||||
}
|
||||
let named = std::fs::File::open(&path).map_err(|_| fail())?;
|
||||
let held = lock.metadata().map_err(|_| fail())?;
|
||||
let current = named.metadata().map_err(|_| fail())?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if held.dev() != before.dev() || held.ino() != before.ino() || held.dev() != current.dev() || held.ino() != current.ino() {
|
||||
return Err(fail());
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = (held, current);
|
||||
if lock_marker(lock).map_err(|_| fail())? != lock_marker(&named).map_err(|_| fail())? {
|
||||
return Err(fail());
|
||||
}
|
||||
let after = std::fs::symlink_metadata(&path).map_err(|_| fail())?;
|
||||
if is_link_like(&after) || !after.file_type().is_file() {
|
||||
return Err(fail());
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if held.dev() != after.dev() || held.ino() != after.ino() {
|
||||
return Err(fail());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn lock_marker(file: &std::fs::File) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::{Read, Seek};
|
||||
let mut file = file.try_clone()?;
|
||||
file.seek(std::io::SeekFrom::Start(0))?;
|
||||
let mut bytes = Vec::new();
|
||||
file.take(129).read_to_end(&mut bytes)?;
|
||||
if bytes.len() > 128 {
|
||||
return Err(std::io::Error::other("invalid recovery lock marker"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// 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, created: bool) -> Result<()> {
|
||||
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 Ok(()) };
|
||||
validate_recovery_dir(&run.root)?;
|
||||
#[cfg(unix)]
|
||||
let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
// Reclaim a newly recreated folder only when this operation actually created it. Existing
|
||||
// replacements, including ones without a lock, may contain somebody else's documents.
|
||||
let same_directory = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let held = run.directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
held.dev() == directory.dev() && held.ino() == directory.ino()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Without stable directory identity, a valid held lock is the proof of ownership.
|
||||
// A missing lock cannot authorize an existing folder, even on coarse-timestamp disks.
|
||||
validate_lock(dir, &run.lock).is_ok()
|
||||
}
|
||||
};
|
||||
if !created && !same_directory {
|
||||
return Err(EngineError::Other(format!("{}: this run no longer owns its recovery folder", dir.display())));
|
||||
}
|
||||
match std::fs::symlink_metadata(dir.join(LOCK)) {
|
||||
Ok(_) => return validate_lock(dir, &run.lock),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
}
|
||||
let lock = new_lock(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
lock.try_lock().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
validate_lock(dir, &lock)?;
|
||||
#[cfg(unix)]
|
||||
let replacement_directory = std::fs::File::open(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let held = replacement_directory.metadata().map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
if held.dev() != directory.dev() || held.ino() != directory.ino() {
|
||||
return Err(EngineError::Other(format!("{}: recovery folder changed while restoring its lock", dir.display())));
|
||||
}
|
||||
}
|
||||
validate_lock(dir, &lock)?;
|
||||
run.lock = lock;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
run.directory = replacement_directory;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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(metadata) = std::fs::symlink_metadata(dir.join(LOCK)) else { continue };
|
||||
if is_link_like(&metadata) || !metadata.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(lock) = std::fs::OpenOptions::new().read(true).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;
|
||||
}
|
||||
}
|
||||
if let Err(e) = validate_lock(&dir, &lock) {
|
||||
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")))
|
||||
}
|
||||
|
|
@ -38,7 +332,7 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
|||
/// Remove a document's recovery entry (it was saved or closed).
|
||||
pub fn discard(dir: &Path, uid: u64) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if validate_recovery_dir(dir).is_err() {
|
||||
if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() {
|
||||
return;
|
||||
}
|
||||
let (d, m) = files(dir, uid);
|
||||
|
|
@ -49,7 +343,11 @@ pub fn discard(dir: &Path, uid: u64) {
|
|||
/// Write recovery entries for the dirty documents (and drop those of clean ones). Returns how
|
||||
/// many were written.
|
||||
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
secure_recovery_dir(dir)?;
|
||||
let created = secure_recovery_dir(dir)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
relock(dir, created)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = created;
|
||||
let mut n = 0;
|
||||
for d in &s.docs {
|
||||
if !d.is_dirty() {
|
||||
|
|
@ -68,35 +366,45 @@ pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
|||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn secure_recovery_dir(dir: &Path) -> Result<()> {
|
||||
fn secure_recovery_dir(dir: &Path) -> Result<bool> {
|
||||
match std::fs::symlink_metadata(dir) {
|
||||
Ok(_) => return validate_recovery_dir(dir),
|
||||
Ok(_) => return validate_recovery_dir(dir).map(|()| false),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
}
|
||||
|
||||
// Set the mode while creating directories so existing caller-owned directories are never
|
||||
// chmodded. DirBuilder applies this mode only to components it creates; the process umask may
|
||||
// make it more restrictive.
|
||||
// Create parent directories privately without changing existing caller-owned permissions.
|
||||
// Create the leaf separately so a creation race can never be mistaken for our own folder.
|
||||
#[cfg(unix)]
|
||||
let create_result = {
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
builder.recursive(true).mode(0o700).create(dir)
|
||||
if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(parent)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::DirBuilder::new().mode(0o700).create(dir)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let create_result = std::fs::create_dir_all(dir);
|
||||
|
||||
create_result.map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
|
||||
// Re-check after creation. This also fails closed if another process won a creation race with
|
||||
// a symlink or, on Windows, any reparse-point leaf.
|
||||
validate_recovery_dir(dir)
|
||||
let create_result = {
|
||||
if let Some(parent) = dir.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(parent).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::create_dir(dir)
|
||||
};
|
||||
let created = match create_result {
|
||||
Ok(()) => true,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
};
|
||||
validate_recovery_dir(dir)?;
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn secure_recovery_dir(_dir: &Path) -> Result<()> {
|
||||
fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
}
|
||||
|
||||
|
|
@ -185,7 +493,7 @@ fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> {
|
|||
pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let mut out = Vec::new();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if validate_recovery_dir(dir).is_err() {
|
||||
if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() {
|
||||
return out;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
|
|
@ -211,8 +519,14 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
|||
/// writes its own). Returns the opened documents' indices.
|
||||
pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
validate_recovery_dir(dir)?;
|
||||
{
|
||||
validate_recovery_dir(dir)?;
|
||||
relock(dir, false)?;
|
||||
}
|
||||
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)?;
|
||||
|
|
@ -350,6 +664,293 @@ 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)));
|
||||
}
|
||||
|
||||
/// Replacing the name of a live run's lock must not authorize writes under the old handle.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn save_rejects_a_run_whose_lock_was_replaced() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
std::fs::remove_file(own.join(LOCK)).unwrap();
|
||||
let replacement = new_lock(&own).unwrap();
|
||||
replacement.try_lock().unwrap();
|
||||
assert!(save(&dirty_session(), &own).is_err());
|
||||
assert!(list(&own).is_empty(), "another owner's folder must not receive our recovery data");
|
||||
}
|
||||
|
||||
/// A blocked attempt to recreate a run's lock must stop the save before it writes documents.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn save_rejects_a_run_whose_lock_cannot_be_recreated() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
std::fs::remove_file(own.join(LOCK)).unwrap();
|
||||
std::fs::create_dir(own.join(LOCK)).unwrap();
|
||||
assert!(save(&dirty_session(), &own).is_err());
|
||||
assert!(list(&own).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn discarding_a_document_does_not_touch_a_replaced_runs_entries() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
std::fs::remove_file(own.join(LOCK)).unwrap();
|
||||
let replacement = new_lock(&own).unwrap();
|
||||
replacement.try_lock().unwrap();
|
||||
let (doc, meta) = files(&own, 1);
|
||||
std::fs::write(&doc, b"the replacement owner's document").unwrap();
|
||||
std::fs::write(&meta, b"{}").unwrap();
|
||||
discard(&own, 1);
|
||||
assert_eq!(std::fs::read(doc).unwrap(), b"the replacement owner's document");
|
||||
assert!(meta.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn saving_a_run_rejects_a_symlinked_lock() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
std::fs::remove_file(own.join(LOCK)).unwrap();
|
||||
let unrelated = root.path().join("unrelated");
|
||||
std::fs::write(&unrelated, b"unrelated file").unwrap();
|
||||
std::os::unix::fs::symlink(&unrelated, own.join(LOCK)).unwrap();
|
||||
assert!(save(&dirty_session(), &own).is_err());
|
||||
assert_eq!(std::fs::read(unrelated).unwrap(), b"unrelated file");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_replaced_run_directory_is_not_claimed_or_overwritten() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let own = run_dir(root.path()).unwrap();
|
||||
let session = dirty_session();
|
||||
let uid = session.docs[0].uid;
|
||||
std::fs::remove_dir_all(&own).unwrap();
|
||||
std::fs::create_dir(&own).unwrap();
|
||||
let (doc, _) = files(&own, uid);
|
||||
std::fs::write(&doc, b"another owner's document").unwrap();
|
||||
assert!(save(&session, &own).is_err());
|
||||
assert_eq!(std::fs::read(doc).unwrap(), b"another owner's document");
|
||||
assert!(!own.join(LOCK).exists());
|
||||
}
|
||||
|
||||
#[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)]
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
|||
- 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).
|
||||
- Native engine saves, Save a Copy, snippets, books, libraries, swatches and engine/MCP exports use same-folder atomic replacement. Failed writes before replacement retain the original; existing Unix permissions apply before staging document bytes. Desktop Services writes and CLI raster exports still need this routing. Parent-folder flushing is best effort, so this is not a power-loss durability guarantee.
|
||||
- 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 every 30 s into a locked folder owned by that running app and reopened (unsaved, remembering their files) after a crash. Startup takes entries from ended runs and leaves live runs alone; writes and cleanup validate current folder and lock ownership. Failed isolation disables recovery with a warning. Concurrent older versions still use unlocked root entries; mixed-version isolation remains open. 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.
|
||||
- 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).
|
||||
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
|
||||
|
|
|
|||
Loading…
Reference in New Issue