Skip to main content

mermaid_runtime/
atomic.rs

1//! Atomic file writes.
2//!
3//! Plain `fs::write` truncates the target to zero length and then writes the
4//! new contents in place. A crash / kill / disk-full between those two steps
5//! leaves the file empty or half-written — catastrophic for session,
6//! checkpoint, and plugin-lockfile state that is rewritten in full on every
7//! save. [`write_atomic`] writes to a temp sibling, fsyncs it, then renames over
8//! the target, so a reader always sees either the old complete file or the new
9//! complete file.
10
11use std::fs::{self, File};
12use std::io::Write;
13use std::path::Path;
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, SystemTime};
16
17static COUNTER: AtomicU64 = AtomicU64::new(0);
18
19/// A sibling temp file untouched for at least this long is treated as orphaned by
20/// a crashed writer and swept on the next write to the same target. The window is
21/// deliberately generous: `write_atomic` rewrites small session/checkpoint/
22/// lockfile state in a single pass, so a legitimate in-flight temp (ours or
23/// another live writer's) is always far younger than this and is never collected.
24const STALE_TEMP_SECS: u64 = 3600;
25
26/// Write `bytes` to `path` atomically: temp file in the same directory →
27/// `sync_all` → rename over the destination. The rename is atomic on the same
28/// filesystem (and replaces an existing target on both Unix and Windows).
29pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
30    let parent = path.parent().unwrap_or_else(|| Path::new("."));
31    fs::create_dir_all(parent)?;
32
33    let stem = path.file_name().and_then(|n| n.to_str()).unwrap_or("tmp");
34
35    // Best-effort: clear temp siblings stranded by a previous crashed write to
36    // this same target. A crash between the `File::create(tmp)` and `rename`
37    // below leaves `.<stem>.<pid>.<n>.tmp` behind forever (cleanup otherwise runs
38    // only on rename-error or success), so without this repeated crashes would
39    // litter the directory. The sweep only removes clearly abandoned (stale) temps
40    // and never the destination or a fresh/in-flight temp.
41    sweep_stale_temps(parent, stem, Duration::from_secs(STALE_TEMP_SECS));
42
43    let n = COUNTER.fetch_add(1, Ordering::Relaxed);
44    let tmp = parent.join(format!(".{}.{}.{}.tmp", stem, std::process::id(), n));
45
46    {
47        let mut f = File::create(&tmp)?;
48        f.write_all(bytes)?;
49        f.sync_all()?;
50    }
51
52    if let Err(e) = fs::rename(&tmp, path) {
53        let _ = fs::remove_file(&tmp);
54        return Err(e);
55    }
56
57    // Best-effort durability of the rename itself. Opening a directory as a
58    // File is not supported on Windows, so this is a silent no-op there.
59    if let Ok(dir) = File::open(parent) {
60        let _ = dir.sync_all();
61    }
62    Ok(())
63}
64
65/// Best-effort sweep of orphaned temp siblings for the target named `stem` in
66/// `parent`, left behind when a writer crashed between creating the temp and
67/// renaming it over the destination. Only files matching THIS target's temp
68/// pattern (`.{stem}.{pid}.{n}.tmp`) and older than `max_age` are removed.
69///
70/// Safety: the destination is named exactly `stem`, which can never start with
71/// the dotted `.{stem}.` prefix, so it is structurally unmatched; and a live,
72/// in-flight temp (ours or another concurrent writer's) is younger than
73/// `max_age` and so is never collected. Every error is swallowed — a sweep
74/// failure must not fail the write.
75fn sweep_stale_temps(parent: &Path, stem: &str, max_age: Duration) {
76    let prefix = format!(".{stem}.");
77    let Ok(entries) = fs::read_dir(parent) else {
78        return;
79    };
80    let now = SystemTime::now();
81    for entry in entries.flatten() {
82        let name = entry.file_name();
83        let Some(name) = name.to_str() else {
84            continue;
85        };
86        if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
87            continue;
88        }
89        let stale = entry
90            .metadata()
91            .and_then(|m| m.modified())
92            .ok()
93            .and_then(|mtime| now.duration_since(mtime).ok())
94            .map(|age| age >= max_age)
95            .unwrap_or(false);
96        if stale {
97            let _ = fs::remove_file(entry.path());
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn atomic_write_replaces_existing_and_no_temp_left() {
108        let dir = std::env::temp_dir().join(format!("mermaid_atomic_{}", std::process::id()));
109        let _ = fs::create_dir_all(&dir);
110        let target = dir.join("conv.json");
111        write_atomic(&target, b"first").unwrap();
112        assert_eq!(fs::read_to_string(&target).unwrap(), "first");
113        write_atomic(&target, b"second").unwrap();
114        assert_eq!(fs::read_to_string(&target).unwrap(), "second");
115        // No leftover temp files.
116        let leftovers = fs::read_dir(&dir)
117            .unwrap()
118            .flatten()
119            .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
120            .count();
121        assert_eq!(leftovers, 0);
122        let _ = fs::remove_dir_all(&dir);
123    }
124
125    #[test]
126    fn sweep_removes_only_matching_stale_temps() {
127        let dir = std::env::temp_dir().join(format!("mermaid_atomic_sweep_{}", std::process::id()));
128        let _ = fs::remove_dir_all(&dir);
129        let _ = fs::create_dir_all(&dir);
130
131        let target = dir.join("conv.json");
132        write_atomic(&target, b"live").unwrap();
133
134        // An orphaned temp for THIS target (a crashed prior write).
135        let orphan = dir.join(".conv.json.99999.0.tmp");
136        fs::write(&orphan, b"half-written").unwrap();
137        // A temp for a DIFFERENT target — must be left alone.
138        let other = dir.join(".other.json.99999.0.tmp");
139        fs::write(&other, b"someone else").unwrap();
140        // An unrelated regular file — must be left alone.
141        let unrelated = dir.join("notes.txt");
142        fs::write(&unrelated, b"keep me").unwrap();
143
144        // max_age = ZERO ⇒ any matching temp qualifies as stale.
145        sweep_stale_temps(&dir, "conv.json", Duration::ZERO);
146
147        assert!(!orphan.exists(), "matching stale temp must be swept");
148        assert!(other.exists(), "a different target's temp must survive");
149        assert!(unrelated.exists(), "unrelated files must survive");
150        assert!(target.exists(), "the destination must never be swept");
151        assert_eq!(fs::read_to_string(&target).unwrap(), "live");
152
153        let _ = fs::remove_dir_all(&dir);
154    }
155
156    #[test]
157    fn sweep_preserves_fresh_in_flight_temps() {
158        let dir = std::env::temp_dir().join(format!("mermaid_atomic_fresh_{}", std::process::id()));
159        let _ = fs::remove_dir_all(&dir);
160        let _ = fs::create_dir_all(&dir);
161
162        // A freshly created temp stands in for a concurrent, in-flight write.
163        let fresh = dir.join(".conv.json.12345.7.tmp");
164        fs::write(&fresh, b"being written").unwrap();
165
166        // A long window must never collect a just-created temp.
167        sweep_stale_temps(&dir, "conv.json", Duration::from_secs(STALE_TEMP_SECS));
168
169        assert!(fresh.exists(), "a fresh/in-flight temp must not be swept");
170        let _ = fs::remove_dir_all(&dir);
171    }
172}