zsh/extensions/atomic_write.rs
1//! Crash-safe atomic file replacement for the rkyv shard caches.
2//!
3//! **zshrs-original — no C counterpart.** zsh has no persistent bytecode
4//! store, so nothing here mirrors a `Src/` function.
5//!
6//! Both shard caches replace their file the same way: serialize into a
7//! sibling temp file, `fsync`, `rename` over the target. The temp name
8//! embeds the writing pid (`<file>.tmp.<pid>.<nanos>`), which is what
9//! lets a later writer decide whether an abandoned temp is safe to
10//! delete.
11//!
12//! Abandonment was not hypothetical. One `~/.zshrs` accumulated 18
13//! orphaned `autoloads.rkyv.tmp.<pid>.<nanos>` files totalling 517 MB.
14//! Two holes produced them, and both are closed here:
15//!
16//! * an error return between `File::create` and `rename` left the temp
17//! on disk — [`TempFileGuard`] now unlinks it on every exit path,
18//! including the `?` returns and a panic;
19//! * a process killed inside that window never reached the rename at
20//! all — [`reap_orphan_temps`] unlinks temps whose recorded pid no
21//! longer exists.
22//!
23//! A temp owned by a **live** pid is never touched: it belongs to a
24//! sibling shell that is still mid-write, and deleting it would corrupt
25//! that write. Same reasoning as the `.git/index.lock` rule — a lock (or
26//! a temp) you did not create is not yours to remove.
27
28use std::fs::File;
29use std::io::Write as IoWrite;
30use std::path::{Path, PathBuf};
31use std::time::{SystemTime, UNIX_EPOCH};
32
33/// Unlinks the temp file unless [`TempFileGuard::disarm`] was called
34/// after a successful rename.
35///
36/// The rename is the commit point: once it succeeds the temp path no
37/// longer exists and the guard must not fire (a same-named temp from a
38/// later write would be the victim). Every path that does NOT reach the
39/// rename — an I/O error, an early return, a panic while serializing —
40/// leaves the guard armed, so the partial file is removed.
41struct TempFileGuard {
42 /// Path to remove while armed.
43 path: PathBuf,
44 /// Cleared by [`TempFileGuard::disarm`] once the rename committed.
45 armed: bool,
46}
47
48impl TempFileGuard {
49 /// Arm cleanup for `path`.
50 fn new(path: PathBuf) -> Self {
51 Self { path, armed: true }
52 }
53
54 /// Stop tracking the temp file — the rename has taken ownership.
55 fn disarm(&mut self) {
56 self.armed = false;
57 }
58}
59
60impl Drop for TempFileGuard {
61 fn drop(&mut self) {
62 if self.armed {
63 let _ = std::fs::remove_file(&self.path);
64 }
65 }
66}
67
68/// The temp path a write of `path` uses: `<file>.tmp.<pid>.<nanos>`.
69///
70/// The pid is the ownership record [`reap_orphan_temps`] reads back; the
71/// nanosecond stamp keeps two writes from the same process distinct.
72fn temp_path_for(path: &Path) -> PathBuf {
73 let parent = path.parent().unwrap_or_else(|| Path::new("."));
74 let file = path
75 .file_name()
76 .and_then(|s| s.to_str())
77 .unwrap_or("shard.rkyv");
78 let nanos = SystemTime::now()
79 .duration_since(UNIX_EPOCH)
80 .map(|d| d.as_nanos())
81 .unwrap_or(0);
82 parent.join(format!("{}.tmp.{}.{}", file, std::process::id(), nanos))
83}
84
85/// Replace `path` with `bytes` atomically, leaving no temp file behind
86/// on any exit path, and reap temps abandoned by processes that are gone.
87///
88/// The caller is expected to already hold whatever lock serializes
89/// writers of `path` (both shard caches take an `flock` first); this
90/// function only guarantees that a reader never sees a half-written
91/// file and that a failed write does not leak one.
92pub fn write_bytes_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
93 let parent = path.parent().unwrap_or_else(|| Path::new("."));
94 let _ = std::fs::create_dir_all(parent);
95
96 let tmp_path = temp_path_for(path);
97 let mut guard = TempFileGuard::new(tmp_path.clone());
98 {
99 let mut f = File::create(&tmp_path).map_err(|e| e.to_string())?;
100 f.write_all(bytes).map_err(|e| e.to_string())?;
101 f.sync_all().map_err(|e| e.to_string())?;
102 }
103 std::fs::rename(&tmp_path, path).map_err(|e| e.to_string())?;
104 guard.disarm();
105
106 let reaped = reap_orphan_temps(path);
107 if reaped > 0 {
108 tracing::info!(
109 path = %path.display(),
110 reaped,
111 "shard write: removed temp files left by dead processes"
112 );
113 }
114 Ok(())
115}
116
117/// Delete `<file>.tmp.<pid>.<nanos>` siblings of `path` whose `<pid>` is
118/// no longer running. Returns how many were removed.
119///
120/// Skips this process's own temps (one may be in flight above us on the
121/// stack) and anything owned by a pid that still exists — a sibling
122/// shell writing the same shard right now.
123pub fn reap_orphan_temps(path: &Path) -> usize {
124 let Some(parent) = path.parent() else {
125 return 0;
126 };
127 let Some(file) = path.file_name().and_then(|s| s.to_str()) else {
128 return 0;
129 };
130 let prefix = format!("{}.tmp.", file);
131 let me = std::process::id() as i32;
132 let Ok(dir) = std::fs::read_dir(parent) else {
133 return 0;
134 };
135 let mut removed = 0usize;
136 for entry in dir.flatten() {
137 let Ok(name) = entry.file_name().into_string() else {
138 continue;
139 };
140 let Some(rest) = name.strip_prefix(&prefix) else {
141 continue;
142 };
143 // `<pid>.<nanos>` — both all-digits, or this is not one of ours.
144 let Some((pid_str, nanos)) = rest.split_once('.') else {
145 continue;
146 };
147 if nanos.is_empty() || !nanos.bytes().all(|b| b.is_ascii_digit()) {
148 continue;
149 }
150 // Parsed as i32 because that is what `kill(2)` takes: a value
151 // that does not fit is not a pid this shell ever wrote, and a
152 // negative one would address a process GROUP.
153 let Ok(pid) = pid_str.parse::<i32>() else {
154 continue;
155 };
156 if pid <= 0 || pid == me || pid_is_alive(pid) {
157 continue;
158 }
159 if std::fs::remove_file(entry.path()).is_ok() {
160 removed += 1;
161 }
162 }
163 removed
164}
165
166/// Does a process with this pid exist? `kill(pid, 0)` is the portable
167/// existence probe: `EPERM` means it exists but belongs to another user
168/// (still alive — hands off), `ESRCH` means it is gone.
169fn pid_is_alive(pid: i32) -> bool {
170 match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None) {
171 Ok(()) => true,
172 Err(nix::errno::Errno::EPERM) => true,
173 Err(_) => false,
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use tempfile::tempdir;
181
182 #[test]
183 fn a_successful_write_leaves_no_temp_behind() {
184 let dir = tempdir().unwrap();
185 let path = dir.path().join("shard.rkyv");
186 write_bytes_atomic(&path, b"payload").unwrap();
187 assert_eq!(std::fs::read(&path).unwrap(), b"payload");
188 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
189 .unwrap()
190 .flatten()
191 .map(|e| e.file_name().into_string().unwrap())
192 .filter(|n| n.contains(".tmp."))
193 .collect();
194 assert!(
195 leftovers.is_empty(),
196 "temp files left behind: {leftovers:?}"
197 );
198 }
199
200 #[test]
201 fn a_dead_pids_temp_is_reaped_and_a_live_pids_is_not() {
202 let dir = tempdir().unwrap();
203 let path = dir.path().join("shard.rkyv");
204 // pid 1 (launchd/init) always exists; kill(1, 0) answers EPERM
205 // for a non-root caller, which counts as alive.
206 let live = dir.path().join("shard.rkyv.tmp.1.123");
207 // A pid that cannot be running: well past every Unix pid_max
208 // (macOS caps at 99998, Linux at 2^22), so `kill` answers ESRCH.
209 let dead = dir.path().join("shard.rkyv.tmp.2147483646.456");
210 // Not a temp of ours — different base name, must survive.
211 let other = dir.path().join("other.rkyv.tmp.2147483646.789");
212 std::fs::write(&live, b"x").unwrap();
213 std::fs::write(&dead, b"x").unwrap();
214 std::fs::write(&other, b"x").unwrap();
215
216 assert_eq!(reap_orphan_temps(&path), 1);
217 assert!(live.exists(), "a live process's temp was deleted");
218 assert!(!dead.exists(), "a dead process's temp was not reaped");
219 assert!(other.exists(), "an unrelated file was deleted");
220 }
221}