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}