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