Skip to main content

linsight_core/
atomic_write.rs

1// SPDX-FileCopyrightText: 2026 VisorCraft LLC
2// SPDX-License-Identifier: GPL-3.0-only
3
4//! Atomic JSON file writes shared by every on-disk config the project
5//! maintains (`preferences.json`, `hardware.json`, per-dashboard files
6//! under `dashboards/`).
7//!
8//! The pattern is the same every time:
9//!
10//!   1. `mkdir -p` the parent.
11//!   2. Open a per-process-unique sibling `.tmp.<pid>.<counter>` with
12//!      `O_CREAT|O_EXCL` so two writers can't trample each other.
13//!   3. Write the serialized body, then `fsync(2)` so the bytes hit
14//!      stable storage before the rename swaps the inode.
15//!   4. `rename(2)` onto the final path. On rename failure clean up
16//!      the tmp sibling so it doesn't accumulate.
17//!
18//! Living in `linsight-core` means the daemon's `NicknameStore`, the
19//! GUI's `PreferencesModel`, and the dashboards storage all share one
20//! tested implementation — diverging behavior (e.g., one of them
21//! missing the fsync, another missing the cleanup) caused minor bugs
22//! before this lift.
23
24use std::fs::OpenOptions;
25use std::io::{Result, Write};
26use std::path::Path;
27use std::sync::atomic::{AtomicU64, Ordering};
28
29/// Process-wide monotonic counter for tmp-file suffixes. Combined with
30/// `process::id()` it guarantees per-process uniqueness across threads
31/// AND across overlapping processes (two GUI instances writing the
32/// same dashboards dir on shared XDG_CONFIG_HOME).
33static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
34
35/// Serialize `value` as pretty JSON and write it to `path` atomically.
36/// Errors propagate as `std::io::Error` (serialization errors are
37/// wrapped via `Error::other`). Use `?` to surface them; the caller
38/// decides whether to log / retry / abort.
39pub fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
40    if let Some(parent) = path.parent() {
41        std::fs::create_dir_all(parent)?;
42    }
43    let suffix = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
44    let pid = std::process::id();
45    let tmp = path.with_extension(format!("json.tmp.{pid}.{suffix}"));
46    let body = serde_json::to_string_pretty(value).map_err(std::io::Error::other)?;
47    {
48        let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp)?;
49        f.write_all(body.as_bytes())?;
50        f.sync_all()?;
51    }
52    if let Err(e) = std::fs::rename(&tmp, path) {
53        let _ = std::fs::remove_file(&tmp);
54        return Err(e);
55    }
56    Ok(())
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62    use serde::Serialize;
63
64    #[derive(Serialize)]
65    struct Sample {
66        name: String,
67        value: u32,
68    }
69
70    #[test]
71    fn writes_pretty_json_to_target_path() {
72        let dir = tempfile::TempDir::new().unwrap();
73        let path = dir.path().join("out.json");
74        let s = Sample { name: "x".into(), value: 7 };
75        atomic_write_json(&path, &s).unwrap();
76        let body = std::fs::read_to_string(&path).unwrap();
77        assert!(body.contains("\"name\""));
78        assert!(body.contains("\"value\": 7"));
79    }
80
81    #[test]
82    fn creates_parent_dir_if_missing() {
83        let dir = tempfile::TempDir::new().unwrap();
84        let nested = dir.path().join("a/b/c/out.json");
85        let s = Sample { name: "x".into(), value: 1 };
86        atomic_write_json(&nested, &s).unwrap();
87        assert!(nested.exists());
88    }
89
90    #[test]
91    fn leaves_no_tmp_sibling_after_success() {
92        let dir = tempfile::TempDir::new().unwrap();
93        let path = dir.path().join("out.json");
94        let s = Sample { name: "x".into(), value: 1 };
95        atomic_write_json(&path, &s).unwrap();
96        let entries: Vec<_> =
97            std::fs::read_dir(dir.path()).unwrap().flatten().map(|e| e.file_name()).collect();
98        // Only "out.json"; no "out.json.tmp.*" sibling.
99        assert_eq!(entries.len(), 1, "stray tmp file: {entries:?}");
100    }
101
102    #[test]
103    fn cleans_up_tmp_when_rename_fails() {
104        // Force rename to fail by making `path` an existing directory.
105        let dir = tempfile::TempDir::new().unwrap();
106        let path = dir.path().join("out.json");
107        std::fs::create_dir(&path).unwrap();
108        let s = Sample { name: "x".into(), value: 1 };
109        let err = atomic_write_json(&path, &s).expect_err("rename onto a dir must fail");
110        // No leftover `.tmp.<pid>.<counter>` siblings.
111        let tmp_count = std::fs::read_dir(dir.path())
112            .unwrap()
113            .flatten()
114            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
115            .count();
116        assert_eq!(tmp_count, 0, "leaked tmp file after rename failure: {err}");
117    }
118}