Skip to main content

studio_worker/
daemon_lock.rs

1//! One daemon per config directory.
2//!
3//! `run` holds `<config dir>/daemon.lock` exclusively for its whole life;
4//! the OS releases it when the process exits, however it exits.  The tray UI
5//! uses [`is_held`] to tell "no daemon" (start one) from "a daemon is
6//! starting" (wait).
7
8use 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
15/// The lock file's name, next to `config.toml`.
16pub const LOCK_FILE_NAME: &str = "daemon.lock";
17/// Where the daemon writes its pid, next to the lock.  Separate from the
18/// lock file because Windows locks are mandatory: a locked file cannot be
19/// read, even by the process that holds it through another handle.
20pub const PID_FILE_NAME: &str = "daemon.pid";
21
22/// How often [`acquire`] tries before concluding another daemon runs, and
23/// the pause between tries.  Covers the instant the tray UI's
24/// [`is_held`] probe holds the lock shared.
25pub const ACQUIRE_ATTEMPTS: u32 = 10;
26pub const ACQUIRE_PAUSE: Duration = Duration::from_millis(200);
27
28/// The lock file for the config at `config_path`.
29pub 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
36/// The pid file for the config at `config_path`.
37pub fn pid_path_for(config_path: &Path) -> PathBuf {
38    lock_path_for(config_path).with_file_name(PID_FILE_NAME)
39}
40
41/// The held daemon lock; dropping it releases the lock.
42#[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/// The outcome of [`acquire`].
55#[derive(Debug)]
56pub enum Acquired {
57    /// This process is the daemon for the config.
58    Mine(DaemonLock),
59    /// Another process holds the lock.
60    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
75/// Take the daemon lock for the config at `config_path`, logging the
76/// outcome.
77pub fn acquire(config_path: &Path) -> std::io::Result<Acquired> {
78    acquire_with(config_path, ACQUIRE_ATTEMPTS, ACQUIRE_PAUSE)
79}
80
81/// [`acquire`] with an explicit retry budget.
82pub 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                // Informational only: operators can see which pid is the daemon.
93                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
131/// How often a waiting daemon retries the lock.  Safe range 0.5..=10 s.
132pub const WAIT_POLL: Duration = Duration::from_secs(2);
133
134/// Block until this process holds the daemon lock (`run --wait-for-lock`,
135/// for a supervised daemon).  Logs once when it starts waiting.
136pub 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
159/// Whether a daemon holds the lock for the config at `config_path`.
160pub 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        // Readable while the lock is held (Windows locks are mandatory, so
229        // the pid cannot live in the locked file itself).
230        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}