Skip to main content

ralph/fsutil/
atomic.rs

1//! Purpose: Atomic file-write helpers for local filesystem persistence.
2//!
3//! Responsibilities:
4//! - Persist file contents atomically through same-directory temp files.
5//! - Flush and sync temp files before replacement.
6//! - Best-effort sync parent directories after successful replacement.
7//!
8//! Scope:
9//! - Local atomic write orchestration only; temp cleanup policy and safeguard dumps live elsewhere.
10//!
11//! Usage:
12//! - Used by queue, config, session, undo, migration, and runtime persistence paths.
13//!
14//! Invariants/Assumptions:
15//! - Atomic writes require a parent directory.
16//! - Temp files are created in the destination directory so `persist` remains local.
17//! - Directory syncing is best-effort and Unix-only.
18//! - Persist failures must drop the temp file handle to avoid leaving temp files behind.
19
20use anyhow::{Context, Result};
21use std::fs;
22use std::io::Write;
23use std::path::Path;
24
25pub fn write_atomic(path: &Path, contents: &[u8]) -> Result<()> {
26    log::debug!("atomic write: {}", path.display());
27    let dir = path
28        .parent()
29        .context("atomic write requires a parent directory")?;
30    fs::create_dir_all(dir).with_context(|| format!("create directory {}", dir.display()))?;
31
32    let mut tmp = tempfile::NamedTempFile::new_in(dir)
33        .with_context(|| format!("create temp file in {}", dir.display()))?;
34    tmp.write_all(contents).context("write temp file")?;
35    tmp.flush().context("flush temp file")?;
36    tmp.as_file().sync_all().context("sync temp file")?;
37
38    match tmp.persist(path) {
39        Ok(_) => {
40            // Atomic replacement succeeded; no additional cleanup is needed here.
41        }
42        Err(err) => {
43            // Explicitly drop the temp file to ensure cleanup on persist failure.
44            // PersistError contains both the error and the NamedTempFile handle;
45            // we must extract and drop the file handle to prevent temp file leaks.
46            let _temp_file = err.file;
47            drop(_temp_file);
48            return Err(err.error).with_context(|| format!("persist {}", path.display()));
49        }
50    }
51
52    sync_dir_best_effort(dir);
53    Ok(())
54}
55
56pub(crate) fn sync_dir_best_effort(dir: &Path) {
57    #[cfg(unix)]
58    {
59        log::debug!("syncing directory: {}", dir.display());
60        if let Ok(file) = fs::File::open(dir) {
61            let _ = file.sync_all();
62        }
63    }
64
65    #[cfg(not(unix))]
66    {
67        let _ = dir;
68    }
69}