studio_worker/
daemon_lock.rs1use std::fs::{File, OpenOptions, TryLockError};
9use std::io::Write as _;
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13const TRACE_TARGET: &str = "studio_worker::daemon";
14
15pub const LOCK_FILE_NAME: &str = "daemon.lock";
17pub const PID_FILE_NAME: &str = "daemon.pid";
21
22pub const ACQUIRE_ATTEMPTS: u32 = 10;
26pub const ACQUIRE_PAUSE: Duration = Duration::from_millis(200);
27
28pub fn lock_path_for(config_path: &Path) -> PathBuf {
30 config_path
31 .parent()
32 .unwrap_or_else(|| Path::new("."))
33 .join(LOCK_FILE_NAME)
34}
35
36pub fn pid_path_for(config_path: &Path) -> PathBuf {
38 lock_path_for(config_path).with_file_name(PID_FILE_NAME)
39}
40
41#[derive(Debug)]
43pub struct DaemonLock {
44 _file: File,
45 path: PathBuf,
46}
47
48impl DaemonLock {
49 pub fn path(&self) -> &Path {
50 &self.path
51 }
52}
53
54#[derive(Debug)]
56pub enum Acquired {
57 Mine(DaemonLock),
59 HeldElsewhere,
61}
62
63fn open(path: &Path) -> std::io::Result<File> {
64 if let Some(dir) = path.parent() {
65 std::fs::create_dir_all(dir)?;
66 }
67 OpenOptions::new()
68 .create(true)
69 .truncate(false)
70 .read(true)
71 .write(true)
72 .open(path)
73}
74
75pub fn acquire(config_path: &Path) -> std::io::Result<Acquired> {
78 acquire_with(config_path, ACQUIRE_ATTEMPTS, ACQUIRE_PAUSE)
79}
80
81pub fn acquire_with(
83 config_path: &Path,
84 attempts: u32,
85 pause: Duration,
86) -> std::io::Result<Acquired> {
87 let path = lock_path_for(config_path);
88 let file = open(&path)?;
89 for attempt in 1..=attempts.max(1) {
90 match file.try_lock() {
91 Ok(()) => {
92 let pid_path = path.with_file_name(PID_FILE_NAME);
94 if let Err(e) = write_pid(&pid_path) {
95 tracing::warn!(
96 target: TRACE_TARGET,
97 op = "daemon_lock",
98 path = %pid_path.display(),
99 error = %e,
100 "could not write the daemon pid file"
101 );
102 }
103 tracing::info!(
104 target: TRACE_TARGET,
105 op = "daemon_lock",
106 path = %path.display(),
107 pid = std::process::id(),
108 "daemon lock taken"
109 );
110 return Ok(Acquired::Mine(DaemonLock { _file: file, path }));
111 }
112 Err(TryLockError::WouldBlock) if attempt < attempts => std::thread::sleep(pause),
113 Err(TryLockError::WouldBlock) => {}
114 Err(TryLockError::Error(e)) => return Err(e),
115 }
116 }
117 tracing::info!(
118 target: TRACE_TARGET,
119 op = "daemon_lock",
120 path = %path.display(),
121 "another daemon is already running for this config"
122 );
123 Ok(Acquired::HeldElsewhere)
124}
125
126fn write_pid(path: &Path) -> std::io::Result<()> {
127 let mut file = File::create(path)?;
128 writeln!(file, "{}", std::process::id())
129}
130
131pub const WAIT_POLL: Duration = Duration::from_secs(2);
133
134pub fn wait_until_acquired(config_path: &Path, poll: Duration) -> std::io::Result<DaemonLock> {
137 let mut said = false;
138 loop {
139 match acquire_with(config_path, 1, poll)? {
140 Acquired::Mine(lock) => return Ok(lock),
141 Acquired::HeldElsewhere => {
142 if !said {
143 let holder =
144 std::fs::read_to_string(pid_path_for(config_path)).unwrap_or_default();
145 tracing::info!(
146 target: TRACE_TARGET,
147 op = "daemon_lock",
148 holder = holder.trim(),
149 "waiting for the daemon lock; taking over when the other daemon ends"
150 );
151 said = true;
152 }
153 std::thread::sleep(poll);
154 }
155 }
156 }
157}
158
159pub fn is_held(config_path: &Path) -> std::io::Result<bool> {
161 let file = open(&lock_path_for(config_path))?;
162 match file.try_lock_shared() {
163 Ok(()) => {
164 file.unlock()?;
165 Ok(false)
166 }
167 Err(TryLockError::WouldBlock) => Ok(true),
168 Err(TryLockError::Error(e)) => Err(e),
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 const FAST: Duration = Duration::from_millis(1);
177
178 #[test]
179 fn the_lock_file_sits_next_to_the_config() {
180 assert_eq!(
181 lock_path_for(Path::new("/etc/sw/config.toml")),
182 PathBuf::from("/etc/sw/daemon.lock")
183 );
184 }
185
186 #[test]
187 fn a_waiting_daemon_takes_the_lock_once_it_is_free() {
188 let dir = tempfile::tempdir().unwrap();
189 let config = dir.path().join("config.toml");
190 let Acquired::Mine(first) = acquire_with(&config, 1, FAST).unwrap() else {
191 panic!("first");
192 };
193 let waiter = {
194 let config = config.clone();
195 std::thread::spawn(move || {
196 crate::test_support::capture(move || {
197 let lock = wait_until_acquired(&config, Duration::from_millis(10)).unwrap();
198 assert!(lock.path().ends_with(LOCK_FILE_NAME));
199 })
200 })
201 };
202 std::thread::sleep(Duration::from_millis(80));
203 drop(first);
204 let logs = waiter.join().unwrap();
205 assert_eq!(
206 logs.matches("waiting for the daemon lock").count(),
207 1,
208 "{logs}"
209 );
210 assert!(logs.contains("daemon lock taken"), "{logs}");
211 }
212
213 #[test]
214 fn only_one_daemon_holds_the_lock() {
215 let dir = tempfile::tempdir().unwrap();
216 let config = dir.path().join("nested").join("config.toml");
217 assert!(!is_held(&config).unwrap());
218
219 let Acquired::Mine(lock) = acquire_with(&config, 2, FAST).unwrap() else {
220 panic!("the first daemon must get the lock");
221 };
222 assert!(lock.path().ends_with(LOCK_FILE_NAME));
223 assert!(is_held(&config).unwrap());
224 assert!(matches!(
225 acquire_with(&config, 2, FAST).unwrap(),
226 Acquired::HeldElsewhere
227 ));
228 let pid = std::fs::read_to_string(pid_path_for(&config)).unwrap();
231 assert_eq!(pid.trim(), std::process::id().to_string());
232
233 drop(lock);
234 assert!(!is_held(&config).unwrap());
235 assert!(matches!(
236 acquire_with(&config, 1, FAST).unwrap(),
237 Acquired::Mine(_)
238 ));
239 }
240
241 #[test]
242 fn the_outcome_is_logged() {
243 let dir = tempfile::tempdir().unwrap();
244 let config = dir.path().join("config.toml");
245 let logs = crate::test_support::capture(move || {
246 let _held = acquire_with(&config, 1, FAST).unwrap();
247 let _ = acquire_with(&config, 1, FAST).unwrap();
248 });
249 assert!(logs.contains("op=\"daemon_lock\""), "{logs}");
250 assert!(logs.contains("daemon lock taken"), "{logs}");
251 assert!(logs.contains("another daemon is already running"), "{logs}");
252 }
253}