Skip to main content

fallow_types/
atomic_file.rs

1//! Atomic replacement of a cache file.
2//!
3//! The extraction cache and the graph cache both live in `.fallow/`, and more
4//! than one fallow process can write there at the same time: an editor server
5//! beside a CLI run, or a pre-commit hook beside an editor. A fixed temp name
6//! (`cache.bin.tmp`) let those writers truncate and interleave bytes in one
7//! shared file, so the renamed result could hold parts of two payloads, and a
8//! writer whose temp file another writer had already renamed failed outright.
9//! Each write here gets its own uniquely named temp file in the target
10//! directory, so the rename always moves one complete payload into place.
11
12use std::io::Write as _;
13use std::path::Path;
14
15/// The mode `File::create` requests on Unix, before the umask applies.
16#[cfg(unix)]
17const DEFAULT_FILE_MODE: u32 = 0o666;
18
19/// Replace `path` with `data` atomically.
20///
21/// The data goes to a uniquely named temp file beside `path`, gets a
22/// best-effort `fsync`, and is then renamed over `path`. Readers see the old
23/// file or the new file, never a mix. Concurrent writers each move their own
24/// complete payload into place, and the last rename wins. A failed write
25/// removes its temp file.
26///
27/// # Errors
28///
29/// Returns the IO error when `path` has no parent directory or file name, or
30/// when the temp file cannot be created, written, or renamed into place.
31pub fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
32    let (Some(dir), Some(name)) = (path.parent(), path.file_name()) else {
33        return Err(std::io::Error::new(
34            std::io::ErrorKind::InvalidInput,
35            "cache file path has no parent directory or file name",
36        ));
37    };
38    let dir = if dir.as_os_str().is_empty() {
39        Path::new(".")
40    } else {
41        dir
42    };
43    let mut prefix = name.to_os_string();
44    prefix.push(".");
45    let mut builder = tempfile::Builder::new();
46    builder.prefix(&prefix).suffix(".tmp");
47    // A temp file is private (0600) by default. Ask for the mode that
48    // `File::create` uses, so the umask decides as before and a cache that
49    // another user or a CI container reads stays readable.
50    #[cfg(unix)]
51    {
52        use std::os::unix::fs::PermissionsExt as _;
53        builder.permissions(std::fs::Permissions::from_mode(DEFAULT_FILE_MODE));
54    }
55    let mut tmp = builder.tempfile_in(dir)?;
56    tmp.write_all(data)?;
57    // Best effort: a filesystem without fsync support still gets the rename.
58    let _ = tmp.as_file().sync_all();
59    persist(tmp, path)
60}
61
62/// How often a rename over `path` is tried when another writer holds it.
63#[cfg(windows)]
64const PERSIST_ATTEMPTS: u32 = 20;
65
66/// The pause between two rename attempts.
67#[cfg(windows)]
68const PERSIST_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
69
70#[cfg(not(windows))]
71fn persist(tmp: tempfile::NamedTempFile, path: &Path) -> std::io::Result<()> {
72    tmp.persist(path).map_err(|error| error.error)?;
73    Ok(())
74}
75
76/// `ERROR_ACCESS_DENIED`: another writer replaces the target at this moment.
77#[cfg(windows)]
78const ERROR_ACCESS_DENIED: i32 = 5;
79/// `ERROR_SHARING_VIOLATION`: a reader holds the target open.
80#[cfg(windows)]
81const ERROR_SHARING_VIOLATION: i32 = 32;
82/// `ERROR_LOCK_VIOLATION`: a process holds a lock on part of the target.
83#[cfg(windows)]
84const ERROR_LOCK_VIOLATION: i32 = 33;
85
86/// Whether a failed rename is a short-lived conflict with another process.
87#[cfg(windows)]
88fn is_transient_rename_error(error: &std::io::Error) -> bool {
89    matches!(
90        error.raw_os_error(),
91        Some(ERROR_ACCESS_DENIED | ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION)
92    )
93}
94
95/// Windows refuses a rename over a file that another writer is replacing at
96/// the same moment (`ERROR_ACCESS_DENIED`), or that a reader or a scanner
97/// holds open. Those conflicts end within milliseconds, so a short bounded
98/// retry lets each writer move its payload into place.
99#[cfg(windows)]
100fn persist(mut tmp: tempfile::NamedTempFile, path: &Path) -> std::io::Result<()> {
101    let mut attempt = 1;
102    loop {
103        match tmp.persist(path) {
104            Ok(_) => return Ok(()),
105            Err(error) if is_transient_rename_error(&error.error) && attempt < PERSIST_ATTEMPTS => {
106                tmp = error.file;
107                attempt += 1;
108                std::thread::sleep(PERSIST_RETRY_DELAY);
109            }
110            Err(error) => return Err(error.error),
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn dir_names(dir: &Path) -> Vec<String> {
120        let mut names: Vec<String> = std::fs::read_dir(dir)
121            .unwrap()
122            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
123            .collect();
124        names.sort();
125        names
126    }
127
128    #[cfg_attr(miri, ignore = "the filesystem is blocked by Miri isolation")]
129    #[test]
130    fn write_replaces_the_file_and_leaves_no_temp_file() {
131        let dir = tempfile::tempdir().unwrap();
132        let path = dir.path().join("cache.bin");
133        write_atomic(&path, b"first").unwrap();
134        write_atomic(&path, b"second payload").unwrap();
135        assert_eq!(std::fs::read(&path).unwrap(), b"second payload");
136        assert_eq!(dir_names(dir.path()), ["cache.bin"]);
137    }
138
139    #[cfg_attr(miri, ignore = "the filesystem is blocked by Miri isolation")]
140    #[test]
141    fn concurrent_writers_each_move_one_whole_payload_into_place() {
142        const WRITERS: u8 = 8;
143        const ROUNDS: usize = 50;
144        const PAYLOAD_LEN: usize = 256 * 1024;
145        let dir = tempfile::tempdir().unwrap();
146        let path = dir.path().join("cache.bin");
147        let errors = std::sync::Mutex::new(Vec::<std::io::Error>::new());
148        std::thread::scope(|scope| {
149            for writer in 0..WRITERS {
150                let (path, errors) = (&path, &errors);
151                scope.spawn(move || {
152                    let payload = vec![writer; PAYLOAD_LEN];
153                    for _ in 0..ROUNDS {
154                        if let Err(error) = write_atomic(path, &payload) {
155                            errors.lock().unwrap().push(error);
156                        }
157                    }
158                });
159            }
160        });
161        let errors = errors.into_inner().unwrap();
162        assert!(errors.is_empty(), "writes failed: {errors:?}");
163
164        let data = std::fs::read(&path).unwrap();
165        assert_eq!(data.len(), PAYLOAD_LEN, "the file holds one whole payload");
166        assert!(
167            data.iter().all(|byte| *byte == data[0]),
168            "the file holds bytes from more than one writer"
169        );
170        assert_eq!(dir_names(dir.path()), ["cache.bin"]);
171    }
172
173    /// A cache that another user or a CI container reads must keep the mode a
174    /// plain `File::create` gives, not the private mode of a temp file.
175    #[cfg(unix)]
176    #[cfg_attr(miri, ignore = "the filesystem is blocked by Miri isolation")]
177    #[test]
178    fn the_file_gets_the_mode_that_file_create_gives() {
179        use std::os::unix::fs::PermissionsExt as _;
180        let dir = tempfile::tempdir().unwrap();
181        let reference = dir.path().join("reference");
182        std::fs::File::create(&reference).unwrap();
183        let path = dir.path().join("cache.bin");
184        write_atomic(&path, b"data").unwrap();
185        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
186        assert_eq!(mode(&path), mode(&reference));
187    }
188
189    #[test]
190    fn a_path_without_a_file_name_is_refused() {
191        let error = write_atomic(Path::new("/"), b"data").unwrap_err();
192        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
193    }
194}