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 // `crate::atexit_teardown::active()` — this function runs from
108 // `autoload_cache::atexit_flush_pending`, a libc `atexit` hook, where
109 // `tracing`'s thread-local format buffer may already be destroyed. Emitting
110 // there panics (AccessError), the panic hook prints to the user's terminal
111 // as the shell exits, and the unwind abandons the remaining shard writes.
112 // Only this branch logs, so only a run that actually reaped saw it.
113 if reaped > 0 && !crate::atexit_teardown::active() {
114 tracing::info!(
115 path = %path.display(),
116 reaped,
117 "shard write: removed temp files left by dead processes"
118 );
119 }
120 Ok(())
121}
122
123/// Delete `<file>.tmp.<pid>.<nanos>` siblings of `path` whose `<pid>` is
124/// no longer running. Returns how many were removed.
125///
126/// Skips this process's own temps (one may be in flight above us on the
127/// stack) and anything owned by a pid that still exists — a sibling
128/// shell writing the same shard right now.
129pub fn reap_orphan_temps(path: &Path) -> usize {
130 let Some(parent) = path.parent() else {
131 return 0;
132 };
133 let Some(file) = path.file_name().and_then(|s| s.to_str()) else {
134 return 0;
135 };
136 let prefix = format!("{}.tmp.", file);
137 let me = std::process::id() as i32;
138 let Ok(dir) = std::fs::read_dir(parent) else {
139 return 0;
140 };
141 let mut removed = 0usize;
142 for entry in dir.flatten() {
143 let Ok(name) = entry.file_name().into_string() else {
144 continue;
145 };
146 let Some(rest) = name.strip_prefix(&prefix) else {
147 continue;
148 };
149 // `<pid>.<nanos>` — both all-digits, or this is not one of ours.
150 let Some((pid_str, nanos)) = rest.split_once('.') else {
151 continue;
152 };
153 if nanos.is_empty() || !nanos.bytes().all(|b| b.is_ascii_digit()) {
154 continue;
155 }
156 // Parsed as i32 because that is what `kill(2)` takes: a value
157 // that does not fit is not a pid this shell ever wrote, and a
158 // negative one would address a process GROUP.
159 let Ok(pid) = pid_str.parse::<i32>() else {
160 continue;
161 };
162 if pid <= 0 || pid == me || pid_is_alive(pid) {
163 continue;
164 }
165 if std::fs::remove_file(entry.path()).is_ok() {
166 removed += 1;
167 }
168 }
169 removed
170}
171
172/// Does a process with this pid exist? `kill(pid, 0)` is the portable
173/// existence probe: `EPERM` means it exists but belongs to another user
174/// (still alive — hands off), `ESRCH` means it is gone.
175fn pid_is_alive(pid: i32) -> bool {
176 match nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None) {
177 Ok(()) => true,
178 Err(nix::errno::Errno::EPERM) => true,
179 Err(_) => false,
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use tempfile::tempdir;
187
188 #[test]
189 fn a_successful_write_leaves_no_temp_behind() {
190 let dir = tempdir().unwrap();
191 let path = dir.path().join("shard.rkyv");
192 write_bytes_atomic(&path, b"payload").unwrap();
193 assert_eq!(std::fs::read(&path).unwrap(), b"payload");
194 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
195 .unwrap()
196 .flatten()
197 .map(|e| e.file_name().into_string().unwrap())
198 .filter(|n| n.contains(".tmp."))
199 .collect();
200 assert!(
201 leftovers.is_empty(),
202 "temp files left behind: {leftovers:?}"
203 );
204 }
205
206 #[test]
207 fn a_dead_pids_temp_is_reaped_and_a_live_pids_is_not() {
208 let dir = tempdir().unwrap();
209 let path = dir.path().join("shard.rkyv");
210 // pid 1 (launchd/init) always exists; kill(1, 0) answers EPERM
211 // for a non-root caller, which counts as alive.
212 let live = dir.path().join("shard.rkyv.tmp.1.123");
213 // A pid that cannot be running: well past every Unix pid_max
214 // (macOS caps at 99998, Linux at 2^22), so `kill` answers ESRCH.
215 let dead = dir.path().join("shard.rkyv.tmp.2147483646.456");
216 // Not a temp of ours — different base name, must survive.
217 let other = dir.path().join("other.rkyv.tmp.2147483646.789");
218 std::fs::write(&live, b"x").unwrap();
219 std::fs::write(&dead, b"x").unwrap();
220 std::fs::write(&other, b"x").unwrap();
221
222 assert_eq!(reap_orphan_temps(&path), 1);
223 assert!(live.exists(), "a live process's temp was deleted");
224 assert!(!dead.exists(), "a dead process's temp was not reaped");
225 assert!(other.exists(), "an unrelated file was deleted");
226 }
227}