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}