Skip to main content

shep_core/
config_lock.rs

1//! The exclusive advisory lock a config file's writers hold across their
2//! read-modify-write, and the staging file they write the new value
3//! through.
4//!
5//! Lives in `shep-core`, not `shep-cli`, because `dogs.toml` is gaining a
6//! daemon-side writer and a type the daemon cannot name is a type it
7//! cannot hold. `shep-cli`'s three existing writers of `shep.toml` and
8//! `dogs.toml` keep using this one, imported back in through a
9//! `pub(super) use`.
10//!
11//! Deliberately not consolidated with [`crate::overrides`]'s own
12//! `OverridesLock`, which is the same `flock(2)`/`share_mode(0)` shape
13//! against a different lock file. The two crates that hold a [`ConfigLock`]
14//! already agree on its ordering (`shep.toml` outer, `dogs.toml` inner, per
15//! `dog_migration.rs`'s own header), and unifying the two lock types is a
16//! separate, later change.
17
18use std::path::{Path, PathBuf};
19
20/// Creates the staging file a config is written through, in `parent` so
21/// the later `rename` stays within one filesystem.
22///
23/// The create-at-mode reasoning lives with
24/// [`crate::atomic_file::create_staging_file`], which four stores now
25/// share. What is left here is the pair of names, and this wrapper is
26/// where they stay: `commands::dog_migration` writes `dogs.toml` through
27/// the same staging name as `shep.toml`, and two call sites spelling that
28/// pair out separately is how the two would drift.
29///
30/// # Errors
31/// The staging file could not be created in `parent`.
32pub fn create_config_file(parent: &Path) -> std::io::Result<tempfile::NamedTempFile> {
33    crate::atomic_file::create_staging_file(parent, "shep", ".toml.tmp")
34}
35
36/// An exclusive advisory lock over one config file, held for as long as
37/// the value lives and released when it drops (including on an early `?`,
38/// and by the kernel if the process dies holding it).
39///
40/// Keyed on the path it is given rather than on `shep.toml` specifically:
41/// `shep-cli`'s `ShepToml::edit` takes one over `shep.toml`, and
42/// `commands::dog_migration` takes one over `dogs.toml`, which has two
43/// writers of its own. Whenever both are held at once, `shep.toml`'s is
44/// taken first, which is the whole of what keeps the two orderings from
45/// deadlocking; `migrate_dog_sections` is the one caller that holds both,
46/// and it says so at the point it nests them.
47///
48/// The lock is on a sibling `<name>.lock`, never on the config itself,
49/// and that is the whole design decision, the same one `barks::RingLock`
50/// records: `ShepToml::save` finishes by `rename`ing a new file over the
51/// config, which replaces the inode. A lock taken on the config would be a
52/// lock on an inode the very next successful save unlinks; the next writer
53/// would open the *new* inode, find it unlocked, and the two would be
54/// excluding nothing. The lock file is never renamed, never rewritten and
55/// never read; it exists only to be an inode with a stable identity, and
56/// it is left on disk between edits on purpose so both writers keep
57/// agreeing on which one it is.
58///
59/// Derives `Debug` rather than opting out: the fields are a held OS lock
60/// handle (a `flock(2)` wrapper on unix, a bare `File` on Windows), never a
61/// secret, so there is nothing here for a redacted impl to protect.
62#[derive(Debug)]
63pub struct ConfigLock {
64    /// `flock(2)` is released by this handle's `Drop`. Named with a
65    /// leading underscore because it is held, never read.
66    #[cfg(unix)]
67    _flock: nix::fcntl::Flock<std::fs::File>,
68    /// The lock file, opened with `share_mode(0)`. The same primitive and
69    /// the same sibling-file shape [`crate::kv`] and [`crate::barks`] use;
70    /// see either for the full argument.
71    #[cfg(windows)]
72    _handle: std::fs::File,
73}
74
75impl ConfigLock {
76    /// Blocks until this process holds `path`'s lock exclusively.
77    ///
78    /// # Errors
79    /// The single open that creates the lock file beside `path` and takes
80    /// exclusive share access on it failed for a reason other than
81    /// contention. A sharing violation is retried, not returned.
82    #[cfg(windows)]
83    pub fn acquire(path: &Path) -> std::io::Result<Self> {
84        use std::os::windows::fs::OpenOptionsExt as _;
85
86        /// Another handle already holds share access this open denies.
87        const ERROR_SHARING_VIOLATION: i32 = 32;
88        /// How long a contended retry sleeps. The unix arm blocks in the
89        /// kernel; this polls, for the reason `shep_core::kv` documents.
90        const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
91
92        let lock_path = lock_path(path);
93        loop {
94            match std::fs::OpenOptions::new()
95                .write(true)
96                .create(true)
97                .truncate(false)
98                .share_mode(0)
99                .open(&lock_path)
100            {
101                Ok(handle) => return Ok(Self { _handle: handle }),
102                Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
103                    std::thread::sleep(RETRY_INTERVAL);
104                }
105                Err(error) => return Err(error),
106            }
107        }
108    }
109
110    /// Blocks until this process holds `path`'s lock exclusively.
111    ///
112    /// # Errors
113    /// The lock file could not be created beside `path`, or `flock` failed
114    /// for a reason other than contention (contention blocks rather than
115    /// failing).
116    #[cfg(unix)]
117    pub fn acquire(path: &Path) -> std::io::Result<Self> {
118        use std::os::unix::fs::OpenOptionsExt as _;
119
120        use nix::fcntl::{Flock, FlockArg};
121
122        let file = std::fs::OpenOptions::new()
123            .write(true)
124            .create(true)
125            .truncate(false)
126            .mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
127            .open(lock_path(path))?;
128
129        // `LockExclusive` blocks; the non-blocking variant would need a
130        // retry loop and a deadline, and a `shep enable` that waits its
131        // turn behind a concurrent `shep adopt` is exactly the behaviour
132        // wanted here.
133        Flock::lock(file, FlockArg::LockExclusive)
134            .map(|flock| Self { _flock: flock })
135            .map_err(|(_file, errno)| std::io::Error::from(errno))
136    }
137}
138
139/// The lock file that guards `path`: its own name with `.lock` appended,
140/// so it sits in `$SHEP_HOME` next to the config and inherits that
141/// directory's `0700`.
142fn lock_path(path: &Path) -> PathBuf {
143    let mut name = path
144        .file_name()
145        .map(std::ffi::OsStr::to_os_string)
146        .unwrap_or_default();
147    name.push(".lock");
148    path.parent().unwrap_or_else(|| Path::new(".")).join(name)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn a_second_acquire_on_the_same_path_blocks_until_the_first_drops() {
157        let dir = tempfile::tempdir().unwrap();
158        let path = dir.path().join("dogs.toml");
159        let first = ConfigLock::acquire(&path).unwrap();
160        let path2 = path.clone();
161        let (tx, rx) = std::sync::mpsc::channel();
162        let t = std::thread::spawn(move || {
163            let _second = ConfigLock::acquire(&path2).unwrap();
164            tx.send(()).unwrap();
165        });
166        assert!(
167            rx.recv_timeout(std::time::Duration::from_millis(200))
168                .is_err(),
169            "must block"
170        );
171        drop(first);
172        rx.recv_timeout(std::time::Duration::from_secs(5))
173            .expect("must proceed once released");
174        t.join().unwrap();
175    }
176
177    #[test]
178    fn a_staged_config_file_is_owner_only_named_for_the_pair_and_lands_where_asked() {
179        let dir = tempfile::tempdir().unwrap();
180        let tmp = create_config_file(dir.path()).unwrap();
181        assert_eq!(tmp.path().parent(), Some(dir.path()));
182        let name = tmp.path().file_name().unwrap().to_str().unwrap();
183        assert!(name.starts_with("shep"), "{name}");
184        assert!(name.ends_with(".toml.tmp"), "{name}");
185        #[cfg(unix)]
186        {
187            use std::os::unix::fs::PermissionsExt;
188            let mode = tmp.as_file().metadata().unwrap().permissions().mode() & 0o777;
189            assert_eq!(mode, 0o600);
190        }
191    }
192}