- 1
//! The one way a small durable file is rewritten in place: a read-modify- - 2
//! write held under one process-wide lock from the read to the rename, and - 3
//! published by renaming a temporary file no other write shares. - 4
- 5
use std::path::{Path, PathBuf}; - 6
use std::sync::Mutex; - 7
use std::sync::atomic::{AtomicU64, Ordering}; - 8
- 9
/// Held by every [`update_file`], from the read to the rename. One lock for - 10
/// every path rather than one per path: a file can be reachable under more - 11
/// than one spelling, and a per-path lock would first have to agree which - 12
/// spellings name one file. These writes are rare and small. - 13
static UPDATE_LOCK: Mutex<()> = Mutex::new(()); - 14
- 15
#[derive(Debug, thiserror::Error)] - 16
pub enum UpdateError<E> { - 17
#[error("{path}: {source}")] - 18
Io { - 19
path: PathBuf, - 20
#[source] - 21
source: std::io::Error, - 22
}, - 23
#[error(transparent)] - 24
Edit(E), - 25
} - 26
- 27
/// Read `path` (`None` when it does not exist), let `edit` decide the new - 28
/// contents, and atomically replace the file with them. `edit` returns - 29
/// `None` to leave the file untouched. - 30
/// - 31
/// Holding the lock from the read to the rename is what stops two edits made - 32
/// at the same moment from each rewriting the file from a read taken before - 33
/// the other landed. `edit` must preserve whatever it does not own, and must - 34
/// not call `update_file` itself: the lock is not reentrant. - 35
pub fn update_file<T, E>( - 36
path: &Path, - 37
edit: impl FnOnce(Option<&str>) -> Result<(Option<String>, T), E>, - 38
) -> Result<T, UpdateError<E>> { - 39
let _guard = UPDATE_LOCK - 40
.lock() - 41
.unwrap_or_else(std::sync::PoisonError::into_inner); - 42
let current = match std::fs::read_to_string(path) { - 43
Ok(text) => Some(text), - 44
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, - 45
Err(source) => { - 46
return Err(UpdateError::Io { - 47
path: path.to_path_buf(), - 48
source, - 49
}); - 50
} - 51
}; - 52
let (next, outcome) = edit(current.as_deref()).map_err(UpdateError::Edit)?; - 53
if let Some(next) = next - 54
&& current.as_deref() != Some(next.as_str()) - 55
{ - 56
replace_file(path, &next).map_err(|source| UpdateError::Io { - 57
path: path.to_path_buf(), - 58
source, - 59
})?; - 60
} - 61
Ok(outcome) - 62
} - 63
- 64
/// Write `contents` to a new sibling of `path` and rename it over `path`, so - 65
/// a reader sees the whole old document or the whole new one. The temporary - 66
/// name carries the process id and a per-process sequence number and is - 67
/// created exclusively, so no two writes ever share one; it is removed if - 68
/// the write fails. - 69
pub fn replace_file(path: &Path, contents: &str) -> std::io::Result<()> { - 70
static SEQUENCE: AtomicU64 = AtomicU64::new(0); - 71
let (Some(parent), Some(name)) = (path.parent(), path.file_name()) else { - 72
return Err(std::io::Error::other("path has no parent directory")); - 73
}; - 74
std::fs::create_dir_all(parent)?; - 75
let mut attempts = 0; - 76
let (temp, mut file) = loop { - 77
let temp = parent.join(format!( - 78
".{}.{}.{}.tmp", - 79
name.to_string_lossy(), - 80
std::process::id(), - 81
SEQUENCE.fetch_add(1, Ordering::Relaxed) - 82
)); - 83
match std::fs::OpenOptions::new() - 84
.write(true) - 85
.create_new(true) - 86
.open(&temp) - 87
{ - 88
Ok(file) => break (temp, file), - 89
// Only an earlier process that had this pid can hold a fresh - 90
// name; step past its file rather than write into it. - 91
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists && attempts < 8 => { - 92
attempts += 1; - 93
} - 94
Err(error) => return Err(error), - 95
} - 96
}; - 97
let written = - 98
std::io::Write::write_all(&mut file, contents.as_bytes()).and_then(|()| file.sync_all()); - 99
drop(file); - 100
written - 101
.and_then(|()| std::fs::rename(&temp, path)) - 102
.inspect_err(|_| { - 103
let _ = std::fs::remove_file(&temp); - 104
}) - 105
} - 106
- 107
#[cfg(test)] - 108
#[allow(clippy::unwrap_used, clippy::expect_used)] - 109
mod tests { - 110
use super::*; - 111
- 112
#[test] - 113
fn concurrent_appends_all_land_and_leave_no_temporaries() { - 114
let dir = tempfile::tempdir().unwrap(); - 115
let path = dir.path().join("list.txt"); - 116
std::thread::scope(|scope| { - 117
for writer in 0..16 { - 118
let path = &path; - 119
scope.spawn(move || { - 120
for line in 0..25 { - 121
update_file(path, |current| { - 122
let mut next = current.unwrap_or_default().to_owned(); - 123
next.push_str(&format!("{writer}-{line}\n")); - 124
Ok::<_, std::convert::Infallible>((Some(next), ())) - 125
}) - 126
.unwrap(); - 127
} - 128
}); - 129
} - 130
}); - 131
let text = std::fs::read_to_string(&path).unwrap(); - 132
assert_eq!(text.lines().count(), 16 * 25); - 133
let names: Vec<_> = std::fs::read_dir(dir.path()) - 134
.unwrap() - 135
.map(|entry| entry.unwrap().file_name()) - 136
.collect(); - 137
assert_eq!(names, vec![std::ffi::OsString::from("list.txt")]); - 138
} - 139
- 140
#[test] - 141
fn declined_or_failed_edit_writes_nothing() { - 142
let dir = tempfile::tempdir().unwrap(); - 143
let path = dir.path().join("kept.txt"); - 144
std::fs::write(&path, "original").unwrap(); - 145
update_file(&path, |_| Ok::<_, &str>((None, ()))).unwrap(); - 146
assert!(matches!( - 147
update_file(&path, |_| Err::<(Option<String>, ()), _>("no")), - 148
Err(UpdateError::Edit("no")) - 149
)); - 150
assert_eq!(std::fs::read_to_string(&path).unwrap(), "original"); - 151
} - 152
} - 153
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.