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 fn is_held(config_path: &Path) -> std::io::Result<bool> {
133 let file = open(&lock_path_for(config_path))?;
134 match file.try_lock_shared() {
135 Ok(()) => {
136 file.unlock()?;
137 Ok(false)
138 }
139 Err(TryLockError::WouldBlock) => Ok(true),
140 Err(TryLockError::Error(e)) => Err(e),
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 const FAST: Duration = Duration::from_millis(1);
149
150 #[test]
151 fn the_lock_file_sits_next_to_the_config() {
152 assert_eq!(
153 lock_path_for(Path::new("/etc/sw/config.toml")),
154 PathBuf::from("/etc/sw/daemon.lock")
155 );
156 }
157
158 #[test]
159 fn only_one_daemon_holds_the_lock() {
160 let dir = tempfile::tempdir().unwrap();
161 let config = dir.path().join("nested").join("config.toml");
162 assert!(!is_held(&config).unwrap());
163
164 let Acquired::Mine(lock) = acquire_with(&config, 2, FAST).unwrap() else {
165 panic!("the first daemon must get the lock");
166 };
167 assert!(lock.path().ends_with(LOCK_FILE_NAME));
168 assert!(is_held(&config).unwrap());
169 assert!(matches!(
170 acquire_with(&config, 2, FAST).unwrap(),
171 Acquired::HeldElsewhere
172 ));
173 let pid = std::fs::read_to_string(pid_path_for(&config)).unwrap();
176 assert_eq!(pid.trim(), std::process::id().to_string());
177
178 drop(lock);
179 assert!(!is_held(&config).unwrap());
180 assert!(matches!(
181 acquire_with(&config, 1, FAST).unwrap(),
182 Acquired::Mine(_)
183 ));
184 }
185
186 #[test]
187 fn the_outcome_is_logged() {
188 let dir = tempfile::tempdir().unwrap();
189 let config = dir.path().join("config.toml");
190 let logs = crate::test_support::capture(move || {
191 let _held = acquire_with(&config, 1, FAST).unwrap();
192 let _ = acquire_with(&config, 1, FAST).unwrap();
193 });
194 assert!(logs.contains("op=\"daemon_lock\""), "{logs}");
195 assert!(logs.contains("daemon lock taken"), "{logs}");
196 assert!(logs.contains("another daemon is already running"), "{logs}");
197 }
198}