collective

Browse / github.com/storytold/soundcraft / patch

Autosave: preserve equal-time recovery until a successful save

Autosave: preserve equal-time recovery until a successful save When the saved session and its autosave have the same modification timestamp, `leftovers()` deletes the recovery folder even if it contains unsaved audio. This draft preserves equal-time copies, creates collision-safe folders, retains incomplete recovery audio on metadata errors, and retires a recovered copy only after a successful native save. Failed saves leave the copy intact. ### Coordination Refs #579 and #594. switch87's #594 already addresses same-millisecond folder collisions and separates test timestamps; this draft overlaps that directory fix and deliberately changes production equ

Status
open
Score
0.00
Adopters
0 other operators
Author
jackcanon (imported from pull request #810, open)
Base
25d52c646aea780c8076ff966076f994ad256221
Size
+122 −5 in 4 files
Review
pending
Shared
2026-10-28
Id
cac80790b2bf90a1b256bc30

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/autosave.rs crates/engine/src/io.rs crates/engine/src/lib.rs crates/ui-egui/tests/recovery.rs

diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs--- a/crates/engine/src/autosave.rs+++ b/crates/engine/src/autosave.rs@@ -179,8 +179,24 @@ impl Autosave {     /// A new folder under `root` for the session called `name`.     fn create(root: &Path, name: &str) -> Result<Autosave> {         let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();-        let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id()));-        let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?;+        Self::create_at(root, name, stamp)+    }++    fn create_at(root: &Path, name: &str, stamp: u128) -> Result<Autosave> {+        std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?;+        let stem = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());+        let mut suffix = 0u64;+        let dir = loop {+            let dir = root.join(if suffix == 0 { stem.clone() } else { format!("{stem}-{suffix}") });+            match std::fs::create_dir(&dir) {+                Ok(()) => break dir,+                Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {+                    suffix = suffix.checked_add(1).ok_or_else(|| EngineError::Io("autosave folder names exhausted".into()))?;+                }+                Err(err) => return Err(io_error(&dir, err)),+            }+        };+        let lock = std::fs::File::create(dir.join(LOCK)).map_err(|err| io_error(&dir, err))?;         lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;         Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })     }@@ -257,7 +273,9 @@ pub struct Leftover { }  /// Leftover autosaves under `root`, newest first: folders no open session holds, written after the-/// session file they came from or holding a recording. Empty and outdated ones are deleted.+/// session file they came from or holding a recording. Equal timestamps remain recoverable:+/// filesystems can round distinct writes to the same time. Outdated copies are deleted;+/// metadata failures leave folders untouched, including incomplete first writes. pub fn leftovers(root: &Path) -> Vec<Leftover> {     let mut out = Vec::new();     for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) {@@ -271,15 +289,16 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {         let recording = dir.join(RECORDING).is_file();         drop(lock);         match saved {-            Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {+            Ok(saved) if recording || origin_saved.is_none_or(|o| saved >= o) => {                 let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();                 let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));                 let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());                 out.push(Leftover { dir, name, origin, saved, recording });             }-            _ => {+            Ok(_) => {                 let _ = std::fs::remove_dir_all(&dir);             }+            Err(err) => log::warn!("could not inspect autosave {}: {err}", dir.display()),         }     }     out.sort_by_key(|l| std::cmp::Reverse(l.saved));@@ -306,6 +325,7 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {     e.replace_session(Session::default());     *e.session_mut() = s;     e.path = left.origin.clone();+    e.recovered_autosave = Some(left.dir.clone());     if let Some((plan, takes)) = unfinished {         crate::io::place_recording(e, &plan, takes);     }@@ -590,6 +610,86 @@ mod tests {         let _ = std::fs::remove_dir_all(&dir);     } +    #[test]+    fn sessions_with_identical_names_and_timestamps_get_distinct_folders() {+        let root = folder("autosave-collisions");+        let mut first = Autosave::create_at(&root, "Song", 123).unwrap();+        let mut e = Engine::default();+        record(&mut e, 0.5);+        first.write(e.session(), None, None).unwrap();+        let first_dir = first.dir.clone();+        drop(first);+        let mut others = Vec::new();+        for _ in 0..20 {+            let a = Autosave::create_at(&root, "Song", 123).unwrap();+            assert_ne!(a.dir, first_dir);+            assert!(others.iter().all(|b: &Autosave| b.dir != a.dir));+            others.push(a);+        }+        let left = leftovers(&root);+        assert_eq!(left.len(), 1);+        let mut recovered = Engine::default();+        recover(&mut recovered, &left[0]).unwrap();+        assert_eq!(levels(&recovered), [0.5]);+        drop(others);+        let _ = std::fs::remove_dir_all(root);+    }++    #[test]+    fn a_missing_manifest_keeps_partial_recovery_audio() {+        let root = folder("autosave-missing-manifest");+        let mut e = Engine::default();+        record(&mut e, 0.5);+        let mut a = Autosave::create(&root, "Song").unwrap();+        a.write(e.session(), None, None).unwrap();+        let dir = a.dir.clone();+        let audio = std::fs::read_dir(dir.join("Audio Files")).unwrap().next().unwrap().unwrap().path();+        let bytes = std::fs::read(&audio).unwrap();+        // A crash during the first write can leave audio without a published session manifest.+        std::fs::remove_file(dir.join(SESSION)).unwrap();+        drop(a);+        assert!(leftovers(&root).is_empty());+        assert!(dir.exists());+        assert_eq!(std::fs::read(audio).unwrap(), bytes);+        let _ = std::fs::remove_dir_all(root);+    }++    fn set_modified(path: &Path, time: SystemTime) {+        std::fs::File::options().write(true).open(path).unwrap().set_times(std::fs::FileTimes::new().set_modified(time)).unwrap();+    }++    #[test]+    fn equal_timestamps_preserve_audio_until_a_successful_recovery_save() {+        let dir = folder("autosave-equal-time");+        let path = dir.join("Song.scraft").to_string_lossy().into_owned();+        let root = dir.join("Autosave");+        let mut e = Engine::default();+        crate::io::save_session(&mut e, &path).unwrap();+        record(&mut e, 0.5);+        let mut a = Autosave::create(&root, "Song").unwrap();+        a.write(e.session(), Some(&path), None).unwrap();+        let time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000);+        set_modified(Path::new(&path), time);+        set_modified(&a.dir.join(SESSION), time);+        drop(a);+        let left = leftovers(&root);+        assert_eq!(left.len(), 1, "equal timestamps cannot prove an autosave obsolete");+        let mut r = Engine::default();+        recover(&mut r, &left[0]).unwrap();+        assert_eq!(levels(&r), [0.5]);+        let blocker = dir.join("not-a-directory");+        std::fs::write(&blocker, b"block").unwrap();+        assert!(crate::io::save_session(&mut r, &blocker.join("Song.scraft").to_string_lossy()).is_err());+        assert!(left[0].dir.exists(), "failed saves must retain the recovery audio");+        crate::io::save_session(&mut r, &path).unwrap();+        set_modified(Path::new(&path), time);+        assert!(leftovers(&root).is_empty(), "successful recovery save explicitly retires its copy");+        let mut reopened = Engine::default();+        open_session(&mut reopened, &path).unwrap();+        assert_eq!(levels(&reopened), [0.5]);+        let _ = std::fs::remove_dir_all(dir);+    }+     #[test]     fn unsaved_work_is_offered_once_its_session_closes() {         let dir = folder("autosave-leftovers");diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs--- a/crates/engine/src/io.rs+++ b/crates/engine/src/io.rs@@ -910,6 +910,13 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {     *e.session_mut() = fresh;     e.path = Some(p.to_string_lossy().into_owned());     e.mark_clean();+    if let Some(dir) = e.recovered_autosave.take()+        && let Err(err) = std::fs::remove_dir_all(&dir)+        && err.kind() != std::io::ErrorKind::NotFound+    {+        log::warn!("could not remove saved recovery copy {}: {err}", dir.display());+        e.recovered_autosave = Some(dir);+    }     Ok(written) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -123,6 +123,8 @@ pub struct Engine {     pub journal: Vec<(String, Value)>,     /// Path of the session file, if saved.     pub path: Option<String>,+    /// Recovery copy retired only after this document is successfully saved.+    recovered_autosave: Option<std::path::PathBuf>,     /// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).     pub revision: u64,     dirty: bool,@@ -162,6 +164,7 @@ impl Engine {             transport: TransportStatus::default(),             journal: Vec::new(),             path: None,+            recovered_autosave: None,             revision: 1,             dirty: false,             dirty_at: Cell::new((0, false)),@@ -195,6 +198,8 @@ impl Engine {      /// Replace the whole document (open / new); clears history.     pub fn replace_session(&mut self, s: Session) {+        // Replacing unsaved recovered work must leave its copy available for a later launch.+        self.recovered_autosave = None;         self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };         self.transport_requests.clear();         self.transport_requests.push(TransportRequest::ResetSession);diff --git a/crates/ui-egui/tests/recovery.rs b/crates/ui-egui/tests/recovery.rs--- a/crates/ui-egui/tests/recovery.rs+++ b/crates/ui-egui/tests/recovery.rs@@ -55,6 +55,11 @@ fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() {     first.run("track.new", json!({"name": "Vocal"})).unwrap();     first.close_autosave();     drop(first);+    // Distinct writes can have equal timestamps on a coarse-resolution filesystem.+    let time = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);+    for file in std::iter::once(path.clone()).chain(std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join("session.scraft"))) {+        std::fs::File::options().write(true).open(file).unwrap().set_times(std::fs::FileTimes::new().set_modified(time)).unwrap();+    }      let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(         |ui, app: &mut SoundApp| {