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/// Whether a daemon holds the lock for the config at `config_path`.
132pub 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        // Readable while the lock is held (Windows locks are mandatory, so
174        // the pid cannot live in the locked file itself).
175        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}