Skip to main content

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}