mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 12:46:53 -06:00
Merge PR #793
This commit is contained in:
+174
-40
@@ -11,6 +11,10 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! A folder given to `file.recovery.save` that is not this run's own may hold other runs' entries
|
||||
//! under the same uids. This run writes there only entries it finds free, replaces and removes
|
||||
//! only those it wrote or recovered, and refuses the folder of a run whose lock is still held.
|
||||
//!
|
||||
//! 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
|
||||
@@ -192,10 +196,11 @@ fn lock_marker(file: &std::fs::File) -> std::io::Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// A run's folder that was removed while it runs is made again by the next save: so is its lock.
|
||||
/// Returns whether `dir` is this run's folder.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn relock(dir: &Path, created: bool) -> Result<()> {
|
||||
fn relock(dir: &Path, created: bool) -> Result<bool> {
|
||||
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(()) };
|
||||
let Some(run) = runs.iter_mut().find(|r| r.dir == dir) else { return Ok(false) };
|
||||
validate_recovery_dir(&run.root)?;
|
||||
#[cfg(unix)]
|
||||
let directory = std::fs::symlink_metadata(dir).map_err(|e| EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
@@ -219,7 +224,7 @@ fn relock(dir: &Path, created: bool) -> Result<()> {
|
||||
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),
|
||||
Ok(_) => return validate_lock(dir, &run.lock).map(|()| true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
}
|
||||
@@ -242,7 +247,93 @@ fn relock(dir: &Path, created: bool) -> Result<()> {
|
||||
{
|
||||
run.directory = replacement_directory;
|
||||
}
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Entries this process wrote or recovered in folders that are not its run's own: (canonical
|
||||
/// folder, uid). Document uids start at 1 in every process, so a shared folder's entry under
|
||||
/// one of this run's uids may be another run's.
|
||||
static WRITTEN: std::sync::Mutex<std::collections::BTreeSet<(PathBuf, u64)>> = std::sync::Mutex::new(std::collections::BTreeSet::new());
|
||||
|
||||
/// What this run may do in a recovery folder.
|
||||
struct Access {
|
||||
/// This run's own folder (`run_dir`): every entry in it is this run's.
|
||||
own: bool,
|
||||
key: PathBuf,
|
||||
}
|
||||
|
||||
impl Access {
|
||||
fn owns(&self, uid: u64) -> bool {
|
||||
self.own || WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).contains(&(self.key.clone(), uid))
|
||||
}
|
||||
|
||||
fn record(&self, uid: u64) {
|
||||
if !self.own {
|
||||
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert((self.key.clone(), uid));
|
||||
}
|
||||
}
|
||||
|
||||
fn forget(&self, uid: u64) {
|
||||
WRITTEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&(self.key.clone(), uid));
|
||||
}
|
||||
|
||||
/// Refuse an entry of another run before anything is written: this run never replaces it.
|
||||
fn check_free(&self, dir: &Path, uid: u64) -> Result<()> {
|
||||
if self.owns(uid) {
|
||||
return Ok(());
|
||||
}
|
||||
let (doc, meta) = files(dir, uid);
|
||||
for path in [doc, meta] {
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
_ => {
|
||||
return Err(EngineError::Other(format!(
|
||||
"{}: recovery data this run did not write is there; recover it or choose another recovery folder",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Check what this run may do in `dir`. A folder that is not this run's own may be the folder
|
||||
/// of another running app, whose lock that app holds: this run leaves it alone.
|
||||
fn access(dir: &Path, created: bool) -> Result<Access> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let own = relock(dir, created)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !own {
|
||||
check_unheld(dir)?;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let own = {
|
||||
let _ = created;
|
||||
false
|
||||
};
|
||||
Ok(Access { own, key: directory_key(dir)? })
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn check_unheld(dir: &Path) -> Result<()> {
|
||||
let path = dir.join(LOCK);
|
||||
let in_use =
|
||||
|| EngineError::Other(format!("{}: another running DesignCraft keeps its recovery data here; choose another recovery folder", dir.display()));
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(metadata) if is_link_like(&metadata) || !metadata.file_type().is_file() => return Err(in_use()),
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(EngineError::Other(format!("{}: {e}", path.display()))),
|
||||
}
|
||||
// Opened for writing: over NFS an exclusive lock on a file opened for reading is refused.
|
||||
let lock = std::fs::OpenOptions::new().read(true).write(true).open(&path).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
match lock.try_lock() {
|
||||
// Free: the run that held it has ended. Dropping the handle releases it again.
|
||||
Ok(()) => Ok(()),
|
||||
Err(std::fs::TryLockError::WouldBlock) => Err(in_use()),
|
||||
Err(std::fs::TryLockError::Error(e)) => Err(EngineError::Other(format!("{}: {e}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move into `own`, this run's folder, what is left to recover beside it: the entries of runs
|
||||
@@ -329,15 +420,21 @@ fn files(dir: &Path, uid: u64) -> (PathBuf, PathBuf) {
|
||||
(dir.join(format!("{uid}.designcraft")), dir.join(format!("{uid}.json")))
|
||||
}
|
||||
|
||||
/// Remove a document's recovery entry (it was saved or closed).
|
||||
/// Remove a document's recovery entry (it was saved or closed). In a folder that is not this
|
||||
/// run's own, only an entry this run wrote or recovered is removed.
|
||||
pub fn discard(dir: &Path, uid: u64) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if validate_recovery_dir(dir).is_err() || relock(dir, false).is_err() {
|
||||
if validate_recovery_dir(dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(access) = access(dir, false) else { return };
|
||||
if !access.owns(uid) {
|
||||
return;
|
||||
}
|
||||
let (d, m) = files(dir, uid);
|
||||
let _ = std::fs::remove_file(d);
|
||||
let _ = std::fs::remove_file(m);
|
||||
access.forget(uid);
|
||||
}
|
||||
|
||||
/// Consume this document's checkpoint provenance after Save/Close/Revert. A default-directory
|
||||
@@ -385,29 +482,29 @@ fn protect_retained_sources(s: &Session, dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_save(s: &Session, dir: &Path) -> Result<()> {
|
||||
fn prepare_save(s: &Session, dir: &Path) -> Result<Access> {
|
||||
// Check before any write or deletion: a failed republish can retain a source whose uid
|
||||
// coincides with an unrelated live document in a manually configured recovery directory.
|
||||
protect_retained_sources(s, dir)?;
|
||||
let created = secure_recovery_dir(dir)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
relock(dir, created)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = created;
|
||||
Ok(())
|
||||
let access = access(dir, created)?;
|
||||
for document in s.docs.iter().filter(|d| d.is_dirty()) {
|
||||
access.check_free(dir, document.uid)?;
|
||||
}
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Write recovery entries for dirty documents and drop clean checkpoints. Returns how many
|
||||
/// were written. Command callers use the tracked variant without configuring automatic recovery.
|
||||
pub fn save(s: &Session, dir: &Path) -> Result<usize> {
|
||||
prepare_save(s, dir)?;
|
||||
let access = prepare_save(s, dir)?;
|
||||
let mut n = 0;
|
||||
for d in &s.docs {
|
||||
if !d.is_dirty() {
|
||||
discard(dir, d.uid);
|
||||
continue;
|
||||
}
|
||||
write_entry(dir, d)?;
|
||||
write_entry(&access, dir, d)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
@@ -424,7 +521,7 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
|
||||
));
|
||||
}
|
||||
}
|
||||
prepare_save(s, dir)?;
|
||||
let access = prepare_save(s, dir)?;
|
||||
let mut n = 0;
|
||||
for index in 0..s.docs.len() {
|
||||
if s.docs.get(index).is_some_and(|document| !document.is_dirty()) {
|
||||
@@ -435,25 +532,30 @@ pub(crate) fn save_tracked(s: &mut Session, dir: &Path) -> Result<usize> {
|
||||
let (doc, meta) = files(dir, document.uid);
|
||||
let bytes = designcraft_format::save(document.preview_stash.as_deref().unwrap_or(document.doc.as_ref()))
|
||||
.map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
write_recovery_file(&doc, &bytes)?;
|
||||
// An entry this run did not write is never replaced, even one made since it checked.
|
||||
let replace = access.owns(document.uid);
|
||||
publish_recovery_file(&doc, &bytes, replace)?;
|
||||
access.record(document.uid);
|
||||
// The primary is useful even if optional metadata fails next. Track it immediately.
|
||||
if !has_checkpoint(document, dir) {
|
||||
document.recovery_entries.push((dir.to_path_buf(), document.uid));
|
||||
}
|
||||
let metadata = json!({"path": document.path, "title": document.title(), "saved": designcraft_doc::vars::now()});
|
||||
write_recovery_file(&meta, metadata.to_string().as_bytes())?;
|
||||
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Publish a document's recovery entry before its optional metadata.
|
||||
fn write_entry(dir: &Path, d: &DocState) -> Result<()> {
|
||||
fn write_entry(access: &Access, dir: &Path, d: &DocState) -> Result<()> {
|
||||
let (doc, meta) = files(dir, d.uid);
|
||||
let bytes = designcraft_format::save(d.preview_stash.as_deref().unwrap_or(d.doc.as_ref())).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
write_recovery_file(&doc, &bytes)?;
|
||||
let replace = access.owns(d.uid);
|
||||
publish_recovery_file(&doc, &bytes, replace)?;
|
||||
access.record(d.uid);
|
||||
let metadata = json!({"path": d.path, "title": d.title(), "saved": designcraft_doc::vars::now()});
|
||||
write_recovery_file(&meta, metadata.to_string().as_bytes())
|
||||
publish_recovery_file(&meta, metadata.to_string().as_bytes(), replace)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -499,11 +601,6 @@ fn secure_recovery_dir(_dir: &Path) -> Result<bool> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_recovery_file(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
publish_recovery_file(path, bytes, true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn publish_recovery_file(path: &Path, bytes: &[u8], replace: bool) -> Result<()> {
|
||||
let Some(dir) = path.parent() else {
|
||||
@@ -581,11 +678,6 @@ fn sync_recovery_dir(dir: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_recovery_file(_path: &Path, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn publish_recovery_file(_path: &Path, _bytes: &[u8], _replace: bool) -> Result<()> {
|
||||
Err(EngineError::Other("recovery files are unavailable on the web".into()))
|
||||
@@ -661,18 +753,13 @@ pub fn open(s: &mut Session, dir: &Path) -> Result<Vec<usize>> {
|
||||
|
||||
fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Path, &[u8]) -> Result<()>) -> Result<Vec<usize>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
validate_recovery_dir(dir)?;
|
||||
relock(dir, false)?;
|
||||
}
|
||||
validate_recovery_dir(dir)?;
|
||||
let source = access(dir, false)?;
|
||||
let destination = s.recovery_dir.clone().unwrap_or_else(|| dir.to_path_buf());
|
||||
let created = secure_recovery_dir(&destination)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
relock(&destination, created)?;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = created;
|
||||
let source_key = directory_key(dir)?;
|
||||
let destination_key = directory_key(&destination)?;
|
||||
let target = access(&destination, created)?;
|
||||
let source_key = source.key.clone();
|
||||
let destination_key = target.key.clone();
|
||||
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"))]
|
||||
@@ -705,6 +792,8 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
|
||||
let mut st = DocState::new(d, path);
|
||||
reserve_uid(&mut st, &mut occupied)?;
|
||||
st.recovery_entries.push((dir.to_path_buf(), uid));
|
||||
// Recovered, the entry is this run's: Save and Close of its document may remove it.
|
||||
source.record(uid);
|
||||
st.fonts = fonts;
|
||||
// Unsaved: the copy on disk (if any) is older than what was recovered.
|
||||
st.saved_doc = std::sync::Arc::new((*st.doc).clone());
|
||||
@@ -721,6 +810,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat
|
||||
.and_then(|bytes| publish(&document, &bytes));
|
||||
match published {
|
||||
Ok(()) => {
|
||||
target.record(state.uid);
|
||||
state.recovery_entries.push((destination.clone(), state.uid));
|
||||
let meta = json!({"path": state.path, "title": state.title(), "saved": designcraft_doc::vars::now()});
|
||||
match publish(&metadata, meta.to_string().as_bytes()) {
|
||||
@@ -1477,6 +1567,50 @@ mod tests {
|
||||
assert!(!left.join(LOCK).exists());
|
||||
assert_eq!(std::fs::read(left.join("1.designcraft")).unwrap(), b"left with no lock");
|
||||
}
|
||||
|
||||
/// Another run's entry under uid 7 in a chosen folder, with a document of this run numbered
|
||||
/// 7 too: document uids start at 1 in every process.
|
||||
fn chosen_folder_with_another_runs_entry() -> (tempfile::TempDir, PathBuf, PathBuf) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let (doc, meta) = files(root.path(), 7);
|
||||
std::fs::write(&doc, b"another run's document").unwrap();
|
||||
std::fs::write(&meta, b"{\"title\": \"theirs\"}").unwrap();
|
||||
(root, doc, meta)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_document_leaves_another_runs_entry_in_a_chosen_folder() {
|
||||
let (root, doc, meta) = chosen_folder_with_another_runs_entry();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.docs[0].uid = 7;
|
||||
assert!(!s.docs[0].is_dirty());
|
||||
s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).unwrap();
|
||||
s.execute("file.close", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read(doc).unwrap(), b"another run's document");
|
||||
assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dirty_document_does_not_overwrite_another_runs_entry_in_a_chosen_folder() {
|
||||
let (root, doc, meta) = chosen_folder_with_another_runs_entry();
|
||||
let mut s = unsaved("this run's work");
|
||||
s.docs[0].uid = 7;
|
||||
assert!(s.execute("file.recovery.save", &json!({"dir": root.path().to_string_lossy()})).is_err());
|
||||
assert!(save(&s, root.path()).is_err());
|
||||
assert_eq!(std::fs::read(doc).unwrap(), b"another run's document");
|
||||
assert_eq!(std::fs::read(meta).unwrap(), b"{\"title\": \"theirs\"}");
|
||||
}
|
||||
|
||||
/// A chosen folder that is a running app's run folder is that app's.
|
||||
#[test]
|
||||
fn a_folder_another_running_app_holds_is_refused() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let held = new_lock(root.path()).unwrap();
|
||||
held.try_lock().unwrap();
|
||||
assert!(save(&dirty_session(), root.path()).is_err());
|
||||
assert!(list(root.path()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user