Skip to main content

shep_core/
file_lock.rs

1//! One exclusive advisory lock, keyed on the file it guards.
2//!
3//! Every store under `$SHEP_HOME` that publishes a new value by `rename`
4//! holds this across its whole read-modify-write: `kv.json`,
5//! `overrides.json`, `secrets.json`, `barks.jsonl`, `shep.toml` and
6//! `dogs.toml`. `flock(2)` on unix, an exclusive `share_mode(0)` open on
7//! Windows.
8//!
9//! Separate from [`crate::atomic_file`], which is the replace itself. This
10//! is what keeps two of those replaces apart, and it is held across reads
11//! the replace never sees.
12
13use std::path::{Path, PathBuf};
14
15/// An exclusive advisory lock over one file, held for as long as the value
16/// lives and released when it drops, including on an early `?` and by the
17/// kernel if the process dies holding it.
18///
19/// The lock is on a sibling `<name>.lock`, never on the guarded file
20/// itself, and that is the whole design decision: a store finishes its
21/// write by `rename`ing a new file over the old one, which replaces the
22/// inode. A lock on the store would guard an inode the next successful
23/// write unlinks, and the writer after that would open the new inode, find
24/// it unlocked, and exclude nothing. The lock file is never renamed,
25/// rewritten or read; it is an inode with a stable identity, left on disk
26/// between writes so every writer keeps agreeing on which one it is.
27///
28/// Two are held at once only over `shep.toml` and `dogs.toml`, in that
29/// order, which is what keeps the callers that nest them from deadlocking;
30/// `commands::dog_migration` says so where it nests them.
31///
32/// Derives `Debug`: the fields are a held OS lock handle (a `flock(2)`
33/// wrapper on unix, a bare `File` on Windows), never a secret.
34#[derive(Debug)]
35pub struct FileLock {
36    /// `flock(2)` is released by this handle's `Drop`. Named with a
37    /// leading underscore because it is held, never read.
38    #[cfg(unix)]
39    _flock: nix::fcntl::Flock<std::fs::File>,
40    /// The lock file, opened with `share_mode(0)` so no other handle,
41    /// same-process or not, can open it while this one is live. Released
42    /// by `Drop`, the same role `_flock` plays on unix, and named with a
43    /// leading underscore for the same reason.
44    #[cfg(windows)]
45    _handle: std::fs::File,
46}
47
48impl FileLock {
49    /// Blocks until this process holds `path`'s lock exclusively.
50    ///
51    /// # Errors
52    /// The lock file could not be created beside `path`, or `flock` failed
53    /// for a reason other than contention (contention blocks rather than
54    /// failing).
55    #[cfg(unix)]
56    pub fn acquire(path: &Path) -> std::io::Result<Self> {
57        use std::os::unix::fs::OpenOptionsExt as _;
58
59        use nix::fcntl::{Flock, FlockArg};
60
61        let file = std::fs::OpenOptions::new()
62            .write(true)
63            .create(true)
64            .truncate(false)
65            .mode(crate::atomic_file::OWNER_ONLY_FILE_MODE)
66            .open(lock_path(path))?;
67
68        // `LockExclusive` blocks; the non-blocking variant would need a
69        // retry loop and a deadline, and a writer that waits its turn
70        // behind another is exactly the behaviour wanted here.
71        Flock::lock(file, FlockArg::LockExclusive)
72            .map(|flock| Self { _flock: flock })
73            .map_err(|(_file, errno)| std::io::Error::from(errno))
74    }
75
76    /// Blocks until this process holds `path`'s lock exclusively.
77    ///
78    /// `share_mode(0)` denies every other open, in this process or another,
79    /// giving the same exclusivity as unix `flock` through a different
80    /// door. It gives no blocking wait, though: a contended open fails at
81    /// once with `ERROR_SHARING_VIOLATION`, so this polls on a short sleep
82    /// until it succeeds.
83    ///
84    /// # Errors
85    /// The lock file could not be created beside `path`, or the open failed
86    /// for a reason other than sharing contention (contention retries
87    /// rather than failing).
88    #[cfg(windows)]
89    pub fn acquire(path: &Path) -> std::io::Result<Self> {
90        use std::os::windows::fs::OpenOptionsExt as _;
91
92        /// Windows' `ERROR_SHARING_VIOLATION`: another handle already holds
93        /// share access this open's `share_mode(0)` denies. Hardcoded
94        /// rather than pulled from `windows-sys`, since this crate has no
95        /// other Windows-only dependency.
96        const ERROR_SHARING_VIOLATION: i32 = 32;
97
98        /// How long a contended retry sleeps before trying again. Short
99        /// enough that a lock held for one write's duration (a handful of
100        /// small file operations) costs this loop only a few iterations,
101        /// long enough not to spin the CPU while it waits.
102        const RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(2);
103
104        let lock_path = lock_path(path);
105        loop {
106            match std::fs::OpenOptions::new()
107                .write(true)
108                .create(true)
109                .truncate(false)
110                .share_mode(0)
111                .open(&lock_path)
112            {
113                Ok(handle) => return Ok(Self { _handle: handle }),
114                Err(error) if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION) => {
115                    std::thread::sleep(RETRY_INTERVAL);
116                }
117                Err(error) => return Err(error),
118            }
119        }
120    }
121}
122
123/// The lock file that guards `path`: its own name with `.lock` appended, so
124/// it sits in `$SHEP_HOME` next to the file it guards and inherits that
125/// directory's `0700`.
126fn lock_path(path: &Path) -> PathBuf {
127    let mut name = path
128        .file_name()
129        .map(std::ffi::OsStr::to_os_string)
130        .unwrap_or_default();
131    name.push(".lock");
132    path.parent().unwrap_or_else(|| Path::new(".")).join(name)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn the_lock_file_is_a_sibling_of_the_file_it_guards() {
141        let path = Path::new("/var/lib/shep/kv.json");
142        assert_eq!(lock_path(path), Path::new("/var/lib/shep/kv.json.lock"));
143    }
144
145    #[test]
146    fn a_second_acquire_on_the_same_path_blocks_until_the_first_drops() {
147        let dir = tempfile::tempdir().unwrap();
148        let path = dir.path().join("dogs.toml");
149        let first = FileLock::acquire(&path).unwrap();
150        let path2 = path.clone();
151        let (tx, rx) = std::sync::mpsc::channel();
152        let t = std::thread::spawn(move || {
153            let _second = FileLock::acquire(&path2).unwrap();
154            tx.send(()).unwrap();
155        });
156        assert!(
157            rx.recv_timeout(std::time::Duration::from_millis(200))
158                .is_err(),
159            "must block"
160        );
161        drop(first);
162        rx.recv_timeout(std::time::Duration::from_secs(5))
163            .expect("must proceed once released");
164        t.join().unwrap();
165    }
166
167    #[test]
168    fn two_different_paths_do_not_exclude_each_other() {
169        let dir = tempfile::tempdir().unwrap();
170        let _kv = FileLock::acquire(&dir.path().join("kv.json")).unwrap();
171        let _secrets = FileLock::acquire(&dir.path().join("secrets.json")).unwrap();
172    }
173}