mermaid_runtime/
atomic.rs1use 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
19const STALE_TEMP_SECS: u64 = 3600;
25
26pub 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 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 if let Ok(dir) = File::open(parent) {
60 let _ = dir.sync_all();
61 }
62 Ok(())
63}
64
65fn 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 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 let orphan = dir.join(".conv.json.99999.0.tmp");
136 fs::write(&orphan, b"half-written").unwrap();
137 let other = dir.join(".other.json.99999.0.tmp");
139 fs::write(&other, b"someone else").unwrap();
140 let unrelated = dir.join("notes.txt");
142 fs::write(&unrelated, b"keep me").unwrap();
143
144 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 let fresh = dir.join(".conv.json.12345.7.tmp");
164 fs::write(&fresh, b"being written").unwrap();
165
166 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}