// autosave.rs --- periodic recovery copies + crash recovery (Arc 3 phase 3). //! Every modified file buffer is periodically written to a private //! recovery file under `$XDG_STATE_HOME/pmacs/autosave/`. If pmacs dies, //! the next session notices the copy and offers `M-x recover-file`. //! Emacs's `auto-save-mode` + `recover-file`. //! //! This module owns the parts Lua cannot do: enumerating **all** buffers' //! paths (Lua has no per-buffer path getter), and the `FileMeta` //! external-change guard (`FileMeta` is neither Lua-visible nor serde). //! `builtin/runtime/autosave.lua` owns the cadence, the configurable //! interval, and the recovery UX. //! //! Recovery files are written `0600` under a `0700` directory //! (Q#AS11) — they hold *unsaved file contents*, a different class of //! secret from saveplace's cursor offsets. //! //! Framing: docs/autosave-recovery-framing.md. use std::cell::RefCell; use std::collections::HashMap; use std::path::Path; use mlua::Lua; use serde::{Deserialize, Serialize}; use crate::buffer::BufferId; use crate::file_io::FileMeta; use crate::hash::sha256_hex; use crate::lua_bindings::{SharedCore, StateDir}; /// Bump when the envelope shape changes incompatibly. A recovery file /// with an unrecognized version reads as [`RecoveryStatus::Corrupt`] — /// never silently applied. pub const AUTOSAVE_VERSION: u32 = 1; /// The header of a recovery file: one line of JSON, then `\n`, then the /// raw buffer bytes (Q#AS4). One atomic write, so a crash can never leave /// a torn header/contents pair. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct Header { version: u32, /// The buffer's path, for provenance and orphan inspection. path: String, /// The origin file's identity when this copy was taken. /// /// **Nullable**: a `[new file]` buffer (a path that does not exist on /// disk yet) has no `file_meta`, and its unsaved contents are exactly /// the work most worth recovering. `None` means "there was no file on /// disk when this was autosaved". origin: Option, } /// `FileMeta` hand-serialized — it is not serde, and `SystemTime` has no /// stable wire form. Stored as an offset from the Unix epoch so the /// comparison is exact; we never reconstruct a `SystemTime`, only compare /// these parts. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] struct Origin { mtime_secs: i64, mtime_nanos: u32, size: u64, } impl Origin { fn from_meta(m: &FileMeta) -> Self { let (mtime_secs, mtime_nanos) = match m.mtime.duration_since(std::time::UNIX_EPOCH) { Ok(d) => ( i64::try_from(d.as_secs()).unwrap_or(i64::MAX), d.subsec_nanos(), ), // Pre-epoch mtimes are exotic but representable. Err(e) => { let d = e.duration(); ( i64::try_from(d.as_secs()).map_or(i64::MIN, |s| -s), d.subsec_nanos(), ) } }; Self { mtime_secs, mtime_nanos, size: m.size, } } } /// What a recovery file means for a given path (Q#AS5). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RecoveryStatus { /// No recovery file. None, /// The on-disk file is unchanged since the copy was taken (or is /// still absent, for a `[new file]`), so the recovery is strictly /// newer. The only status that is announced. Fresh, /// The file changed underneath us — externally edited, deleted, or /// (for a `[new file]`) created by someone else. Never auto-offered: /// silently clobbering it is the one unrecoverable mistake here. Stale, /// Unparseable or unrecognized version. Never offered, never errors; /// `discard-recovery` removes it. Corrupt, } impl RecoveryStatus { /// The lowercase name Lua sees. #[must_use] pub fn as_str(self) -> &'static str { match self { RecoveryStatus::None => "none", RecoveryStatus::Fresh => "fresh", RecoveryStatus::Stale => "stale", RecoveryStatus::Corrupt => "corrupt", } } } /// The `pmacs.state` key a path's recovery file lives under. #[must_use] pub fn key_for(path: &Path) -> String { format!("autosave/{}", sha256_hex(&path.display().to_string())) } /// Per-session autosave bookkeeping. #[derive(Default)] pub struct AutosaveCache(RefCell); #[derive(Default)] struct CacheInner { /// Skip cache: `BufferId → (path_hash, revision)` (Q#AS8). /// /// Keyed on the **path hash as well as the revision**, not the /// revision alone: a buffer keeps its `BufferId` across a path change /// (an LSP `WorkspaceEdit` rename calls `set_buffer_path`), so a /// revision-only cache would skip the write, never create the /// recovery file under the new key, and orphan the old one. It also /// remembers *where a buffer's recovery currently lives*, which is /// what makes cleanup work after a rename. written: HashMap, /// Which buffer owns each recovery slot: `path_hash → BufferId` /// (Q#AS12, Q#AS13). /// /// Two roles in one map: /// /// * **Absent** = the recovery file at that hash (if any) is /// *unclaimed crash data* — this session did not write it. Sweeping /// would overwrite the crash copy with the current buffer, /// destroying exactly what autosave protects. So it blocks the /// sweep until `recover-file` adopts it or `discard-recovery` /// removes it, and neither save nor kill may delete it. /// * **Present** = the slot belongs to exactly *one* buffer. A /// recovery file is keyed by path (a later session knows only /// paths, never old `BufferId`s), but `pmacs.buffer.from_file` can /// open a *second* buffer on the same path. Both cannot be /// protected under one key: the later write would win on disk while /// both buffers believed themselves saved. So the first modified /// buffer claims the slot and any other buffer on that path is /// reported as conflicted, not silently mis-protected. owner: HashMap, } /// Encode a header + contents into the one-file envelope. fn encode(header: &Header, contents: &[u8]) -> Result, String> { // serde_json's compact form never contains a raw newline, so the // first `\n` unambiguously ends the header. let mut out = serde_json::to_vec(header).map_err(|e| e.to_string())?; debug_assert!(!out.contains(&b'\n')); out.push(b'\n'); out.extend_from_slice(contents); Ok(out) } /// Split an envelope at its **first** newline. Contents may contain /// newlines and arbitrary non-UTF-8 bytes, so only the first one counts. /// Returns `None` for anything malformed — the caller maps that to /// [`RecoveryStatus::Corrupt`]. fn decode(bytes: &[u8]) -> Option<(Header, &[u8])> { let nl = bytes.iter().position(|&b| b == b'\n')?; let header: Header = serde_json::from_slice(&bytes[..nl]).ok()?; if header.version != AUTOSAVE_VERSION { return None; } Some((header, &bytes[nl + 1..])) } /// The configured state dir, if any (absent under tests / no HOME). fn base_dir(lua: &Lua) -> Option { lua.app_data_ref::().map(|d| d.0.clone()) } /// Classify the recovery file for `path` (Q#AS5's table). #[must_use] pub fn status(base: &Path, path: &Path) -> RecoveryStatus { let key = key_for(path); let Ok(Some(bytes)) = crate::state::read_bytes(base, &key) else { return RecoveryStatus::None; }; let Some((header, _)) = decode(&bytes) else { return RecoveryStatus::Corrupt; }; let on_disk = crate::file_io::current_meta(path).ok(); match (header.origin, on_disk) { // Had an origin, file still there: fresh iff identity matches. (Some(o), Some(cur)) if o == Origin::from_meta(&cur) => RecoveryStatus::Fresh, // `[new file]`: fresh while it is still absent. (None, None) => RecoveryStatus::Fresh, // Everything else changed underneath us: the file was edited // externally, deleted, or (for a `[new file]`) created by someone // else. Never auto-offered. _ => RecoveryStatus::Stale, } } /// The recovered contents for `path`, if a parseable recovery exists. /// Returns bytes for `Stale` too — the command warns and confirms. #[must_use] pub fn recover_bytes(base: &Path, path: &Path) -> Option> { let bytes = crate::state::read_bytes(base, &key_for(path)) .ok() .flatten()?; let (_, contents) = decode(&bytes)?; Some(contents.to_vec()) } /// Delete the recovery file for `path` (idempotent). pub fn discard(base: &Path, path: &Path) -> bool { crate::state::remove(base, &key_for(path)).is_ok() } /// A buffer that needs a recovery copy written, gathered under the core /// borrow so all IO happens after it is released. struct Pending { id: BufferId, path: String, path_hash: String, revision: u64, origin: Option, contents: Vec, } /// One autosave pass: write a recovery copy of every modified file /// buffer whose contents changed since its last copy. Returns how many /// were written. /// /// Runs on the main thread; the two skips in Q#AS8 keep that bounded. /// Unlike desktop-save this is **not** daemon-gated — autosave is /// per-buffer, not per-frontend, and a daemon holds the unsaved work. /// /// Returns `(written, blocked, conflicted)`: /// /// * `blocked` — an **unclaimed** recovery file already sits at the /// buffer's key (Q#AS12): crash data this session did not write. /// * `conflicted` — another buffer already owns that path's recovery /// slot (Q#AS13): two buffers visit the same file and only one can be /// protected under a path-keyed recovery file. /// /// # Errors /// A state-write failure. Individual buffers never abort the pass. pub fn sweep(lua: &Lua) -> Result<(usize, usize, usize), String> { let Some(base) = base_dir(lua) else { return Ok((0, 0, 0)); }; let core = lua .app_data_ref::() .ok_or("no editor core")? .clone(); let gathered = gather(lua, &core, &base)?; let Gathered { writes, orphans, live, blocked, conflicted, } = gathered; let mut written = 0usize; { let cache = lua .app_data_ref::() .ok_or("no autosave cache")?; let mut cache = cache.0.borrow_mut(); // A buffer whose path moved leaves its old recovery behind. for old in orphans { let _ = crate::state::remove(&base, &format!("autosave/{old}")); cache.owner.remove(&old); } // GC: a buffer that left the registry (killed) takes its recovery // copy with it. This is the backstop that covers `[new file]` // buffers, which fire no `after-load` and so never get a // per-buffer removal callback registered. Only the slot's owner // may retire it. let dead: Vec<(BufferId, String)> = cache .written .iter() .filter(|(id, _)| !live.contains(id)) .map(|(id, (hash, _))| (*id, hash.clone())) .collect(); for (id, hash) in dead { if cache.owner.get(&hash) == Some(&id) { let _ = crate::state::remove(&base, &format!("autosave/{hash}")); cache.owner.remove(&hash); } cache.written.remove(&id); } for p in writes { let header = Header { version: AUTOSAVE_VERSION, path: p.path, origin: p.origin, }; let bytes = encode(&header, &p.contents)?; crate::state::write_private(&base, &format!("autosave/{}", p.path_hash), &bytes) .map_err(|e| e.to_string())?; cache.owner.insert(p.path_hash.clone(), p.id); cache.written.insert(p.id, (p.path_hash, p.revision)); written += 1; } } Ok((written, blocked, conflicted)) } /// What one pass of the registry decided, before any IO. struct Gathered { writes: Vec, /// Recovery keys left behind by buffers whose path moved. orphans: Vec, /// Every buffer still in the registry (drives the dead-buffer GC). live: Vec, blocked: usize, conflicted: usize, } /// Walk the registry under a single borrow and decide what to write. /// All IO happens in [`sweep`] after this returns, because a recovery /// write must not run while the core is borrowed. fn gather(lua: &Lua, core: &SharedCore, base: &Path) -> Result { let mut writes: Vec = Vec::new(); let mut orphans: Vec = Vec::new(); let mut live: Vec = Vec::new(); let mut blocked = 0usize; let mut conflicted = 0usize; // Slots claimed earlier in *this* pass. `owner` is only updated in the // write loop, so without this two dirty duplicates of one path would // both queue a write to the same key. let mut queued: HashMap = HashMap::new(); { let cache = lua .app_data_ref::() .ok_or("no autosave cache")?; let cache = cache.0.borrow(); let c = core.borrow(); let reg = c.registry.borrow(); for &id in reg.ids() { let Ok(buf) = reg.get(id) else { continue }; live.push(id); // Skips scratch / *special* (no path). Includes `[new file]` // buffers: path set, `file_meta` absent. let Some(path) = buf.file_path() else { continue; }; if !buf.is_modified() { continue; } let path_s = path.display().to_string(); let path_hash = sha256_hex(&path_s); let revision = buf.revision(); // Exactly one buffer may own a path's recovery slot (Q#AS13): // the file is keyed by path, so a second buffer on the same // path cannot also be protected — the later write would win on // disk while both believed themselves saved. let slot_owner = cache .owner .get(&path_hash) .or_else(|| queued.get(&path_hash)); match slot_owner { Some(&owner_id) if owner_id != id => { conflicted += 1; continue; } Some(_) => {} // we already own the slot None => { // Unowned. Never clobber unclaimed crash data (Q#AS12): // a recovery file this session did not write is the // crash copy the user has not recovered yet. if crate::state::exists(base, &format!("autosave/{path_hash}")).unwrap_or(false) { blocked += 1; continue; } } } if let Some((prev_hash, prev_rev)) = cache.written.get(&id) { if prev_hash == &path_hash && *prev_rev == revision { continue; // unchanged since its last copy } if prev_hash != &path_hash { // The path moved: the old key is now an orphan. orphans.push(prev_hash.clone()); } } queued.insert(path_hash.clone(), id); let len = buf.len(); let mut contents = vec![0u8; usize::try_from(len).unwrap_or(0)]; if len > 0 { buf.snapshot_rope().slice(0, len, &mut contents); } writes.push(Pending { id, path: path_s, path_hash, revision, origin: buf.file_meta().map(Origin::from_meta), contents, }); } } Ok(Gathered { writes, orphans, live, blocked, conflicted, }) } /// Claim `buffer`'s recovery file for this session (Q#AS12). Called by /// `recover-file` once the contents are installed in the buffer — the /// crash data now lives in the buffer, so the copy is no longer /// irreplaceable. /// /// It records a `written` entry as well as the ownership, because after a /// recover the file's contents *are* the buffer's contents. That makes /// two things right at once: the skip cache correctly declines to rewrite /// it, and `discard_buffer` can find and retire it — including from a /// removal callback that fires *after* the buffer is gone, when there is /// no path left to read (finding). pub fn adopt(lua: &Lua, id: BufferId) { let Some(core) = lua.app_data_ref::() else { return; }; let entry = { let c = core.borrow(); let reg = c.registry.borrow(); let Ok(buf) = reg.get(id) else { return }; let Some(p) = buf.file_path() else { return }; (sha256_hex(&p.display().to_string()), buf.revision()) }; drop(core); if let Some(cache) = lua.app_data_ref::() { let mut cache = cache.0.borrow_mut(); let (hash, revision) = entry; // Recovering into this buffer makes it the slot's owner — its // contents are now what the file holds. Any previous owner of the // slot (a duplicate buffer on the same path) loses the claim and // will report as conflicted on the next sweep, which is truthful: // the file no longer corresponds to it. // // Dropping the old owner's skip-cache entry maintains the // invariant `written[id] ⟹ owner[hash] == id` (finding). Without // it: adopt into B, then kill B without saving. `discard_buffer` // frees the slot and deletes the file, but A's stale // `written[A] = (hash, revA)` survives — so the next sweep sees A // dirty at an unchanged revision, calls it "unchanged since its // last copy", and leaves it unprotected until its next edit. cache .written .retain(|&other, (h, _)| other == id || h != &hash); cache.owner.insert(hash.clone(), id); cache.written.insert(id, (hash, revision)); } } /// Delete the recovery file for `path` and drop every claim and skip-cache /// entry pointing at it. /// /// This is the **explicit** release path (`discard-recovery`), so it /// ignores ownership — the user asked. Clearing the matching `written` /// entries matters (finding): otherwise a still-dirty buffer would hit the /// unchanged-`(path_hash, revision)` fast path on the next sweep and go /// unprotected until its next edit. pub fn discard_path(lua: &Lua, path: &Path) -> bool { let Some(base) = base_dir(lua) else { return false; }; let hash = sha256_hex(&path.display().to_string()); if let Some(cache) = lua.app_data_ref::() { let mut cache = cache.0.borrow_mut(); cache.owner.remove(&hash); cache.written.retain(|_, (h, _)| h != &hash); } discard(&base, path) } /// Retire the recovery copy of a specific **buffer** (Q#AS12). /// /// Keyed by `BufferId`, not by the path captured when the buffer loaded: /// it considers both the buffer's *current* path key (if it is still /// live) and the key its last recovery was actually **written** under. /// Those differ after a rename — an LSP `WorkspaceEdit` changes the path /// while the `BufferId` stays — and a path-captured callback would leave /// the real recovery file behind. /// /// **Only keys this session owns are removed** (Q#AS12, finding). Saving /// or killing a buffer you reopened after a crash must *not* destroy the /// unclaimed recovery copy sitting at its path — you never recovered it. /// Only `recover-file` (which adopts) or an explicit `discard-recovery` /// releases unclaimed crash data. pub fn discard_buffer(lua: &Lua, id: BufferId) { let Some(base) = base_dir(lua) else { return; }; let mut keys: Vec = Vec::new(); // The key the last sweep (or an adopt) recorded for this buffer. This // is the only source that still works once the buffer is gone — a // removal callback fires after it has left the registry. if let Some(cache) = lua.app_data_ref::() && let Some((hash, _)) = cache.0.borrow().written.get(&id) { keys.push(hash.clone()); } // The buffer's current path, which may have moved since that write. if let Some(core) = lua.app_data_ref::() { let c = core.borrow(); let reg = c.registry.borrow(); if let Ok(buf) = reg.get(id) && let Some(p) = buf.file_path() { keys.push(sha256_hex(&p.display().to_string())); } } let Some(cache) = lua.app_data_ref::() else { return; }; let mut cache = cache.0.borrow_mut(); // Retire only slots **this buffer** owns. Two guards in one check: // * an unowned slot is unclaimed crash data — saving or killing the // buffer you reopened after a crash must not destroy it (Q#AS12); // * a slot owned by a *different* buffer belongs to that buffer's // recovery — a duplicate buffer on the same path must not retire // it (Q#AS13). keys.retain(|h| cache.owner.get(h) == Some(&id)); for hash in &keys { let _ = crate::state::remove(&base, &format!("autosave/{hash}")); cache.owner.remove(hash); } // This buffer's own bookkeeping goes regardless: it is being saved or // killed, so any skip-cache entry for it is spent. cache.written.remove(&id); } /// Every open file buffer that has a recovery file, with its status /// (Q#AS6). Enumerating in Rust is what makes this cover argv /// `[new file]` buffers, which fire no hook at all — the Lua reporter /// never has to know they exist. /// /// Returns `(fresh_paths, corrupt_count)`. #[must_use] pub fn pending(lua: &Lua) -> (Vec, usize) { let mut fresh = Vec::new(); let mut corrupt = 0usize; let (Some(base), Some(core)) = (base_dir(lua), lua.app_data_ref::()) else { return (fresh, corrupt); }; // Collect paths first so the guard drops before any IO re-entrancy. let paths: Vec = { let c = core.borrow(); let reg = c.registry.borrow(); reg.ids() .iter() .filter_map(|&id| reg.get(id).ok()?.file_path().map(Path::to_path_buf)) .collect() }; for p in paths { match status(&base, &p) { RecoveryStatus::Fresh => fresh.push(p.display().to_string()), RecoveryStatus::Corrupt => corrupt += 1, RecoveryStatus::None | RecoveryStatus::Stale => {} } } (fresh, corrupt) } #[cfg(test)] mod tests { use super::*; fn header(origin: Option) -> Header { Header { version: AUTOSAVE_VERSION, path: "/tmp/a.rs".into(), origin, } } #[test] fn envelope_round_trips_arbitrary_bytes() { // Contents with newlines AND invalid UTF-8 — the reason we split // at the first newline and read bytes, not a String. let contents = [0xffu8, b'\n', b'a', 0x00, b'\n']; let h = header(Some(Origin { mtime_secs: 5, mtime_nanos: 7, size: 5, })); let bytes = encode(&h, &contents).unwrap(); let (got_h, got_c) = decode(&bytes).unwrap(); assert_eq!(got_h, h); assert_eq!(got_c, &contents[..]); } #[test] fn envelope_round_trips_null_origin() { // A `[new file]` buffer: no origin meta. let h = header(None); let bytes = encode(&h, b"draft").unwrap(); let (got_h, got_c) = decode(&bytes).unwrap(); assert!(got_h.origin.is_none()); assert_eq!(got_c, b"draft"); } #[test] fn decode_rejects_malformed_and_wrong_version() { assert!(decode(b"no newline at all").is_none()); assert!(decode(b"{not json}\nbody").is_none()); assert!(decode(b"\nbody").is_none(), "empty header"); let bad_version = br#"{"version":999,"path":"/x","origin":null}"#; let mut bytes = bad_version.to_vec(); bytes.push(b'\n'); assert!(decode(&bytes).is_none(), "unrecognized version → corrupt"); } #[test] fn key_is_a_valid_state_key() { let k = key_for(Path::new("/home/u/a b.rs")); assert!(k.starts_with("autosave/")); assert!(crate::state::validate_name(&k).is_ok()); } #[test] fn status_none_when_no_recovery_file() { let dir = std::env::temp_dir().join(format!("pmacs-as-none-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); assert_eq!( status(&dir, Path::new("/tmp/nonexistent.rs")), RecoveryStatus::None ); std::fs::remove_dir_all(&dir).ok(); } #[test] fn status_corrupt_for_garbage_envelope() { let dir = std::env::temp_dir().join(format!("pmacs-as-corrupt-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let target = Path::new("/tmp/whatever.rs"); crate::state::write_private(&dir, &key_for(target), b"garbage, no newline").unwrap(); assert_eq!(status(&dir, target), RecoveryStatus::Corrupt); // And it is discardable. assert!(discard(&dir, target)); assert_eq!(status(&dir, target), RecoveryStatus::None); std::fs::remove_dir_all(&dir).ok(); } #[test] fn new_file_status_is_fresh_until_the_file_appears() { let dir = std::env::temp_dir().join(format!("pmacs-as-newfile-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let target = dir.join("draft.rs"); // origin: null — a `[new file]` buffer. let bytes = encode( &Header { version: AUTOSAVE_VERSION, path: target.display().to_string(), origin: None, }, b"unsaved draft", ) .unwrap(); crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap(); assert_eq!(status(&dir, &target), RecoveryStatus::Fresh, "file absent"); assert_eq!( recover_bytes(&dir, &target).as_deref(), Some(&b"unsaved draft"[..]) ); // Someone created the file meanwhile → stale, never auto-offered. std::fs::write(&target, b"someone else's content").unwrap(); assert_eq!(status(&dir, &target), RecoveryStatus::Stale); std::fs::remove_dir_all(&dir).ok(); } #[test] fn existing_file_fresh_until_it_changes_on_disk() { let dir = std::env::temp_dir().join(format!("pmacs-as-exist-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let target = dir.join("a.rs"); std::fs::write(&target, b"on disk").unwrap(); let meta = crate::file_io::current_meta(&target).unwrap(); let bytes = encode( &Header { version: AUTOSAVE_VERSION, path: target.display().to_string(), origin: Some(Origin::from_meta(&meta)), }, b"unsaved edits", ) .unwrap(); crate::state::write_private(&dir, &key_for(&target), &bytes).unwrap(); assert_eq!(status(&dir, &target), RecoveryStatus::Fresh); // Touch the file (different size ⇒ different identity). std::fs::write(&target, b"changed underneath us").unwrap(); assert_eq!(status(&dir, &target), RecoveryStatus::Stale); // Delete it entirely → still stale (the base is gone). std::fs::remove_file(&target).unwrap(); assert_eq!(status(&dir, &target), RecoveryStatus::Stale); std::fs::remove_dir_all(&dir).ok(); } }