Skip to main content

studio_worker/ui/
single_instance.rs

1//! One tray UI per config directory.
2//!
3//! `ui` holds `<config dir>/ui.lock` exclusively for its whole life; the OS
4//! releases it when the process exits, however it exits.  A second `ui` for
5//! the same config finds the lock taken, leaves a raise request
6//! (`ui.raise`) for the running one and exits, so a config never gets two
7//! tray icons.  The running UI watches for the request and shows its window.
8
9use std::fs::{File, OpenOptions, TryLockError};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13use std::time::Duration;
14
15const TRACE_TARGET: &str = "studio_worker::ui";
16
17/// The UI lock's file name, next to `config.toml`.
18pub const LOCK_FILE_NAME: &str = "ui.lock";
19/// A second launch's request to show the running UI's window.
20pub const RAISE_FILE_NAME: &str = "ui.raise";
21/// How often the running UI looks for a raise request.
22pub const RAISE_POLL: Duration = Duration::from_millis(250);
23/// How long a UI restarting itself for the display waits for the lock its
24/// predecessor still holds: attempts × pause = 5 s.
25pub const RESTART_ATTEMPTS: u32 = 25;
26pub const RESTART_PAUSE: Duration = Duration::from_millis(200);
27
28fn config_dir(config_path: &Path) -> PathBuf {
29    config_path
30        .parent()
31        .unwrap_or_else(|| Path::new("."))
32        .to_path_buf()
33}
34
35/// The UI lock for the config at `config_path`.
36pub fn lock_path_for(config_path: &Path) -> PathBuf {
37    config_dir(config_path).join(LOCK_FILE_NAME)
38}
39
40/// The raise request for the config at `config_path`.
41pub fn raise_path_for(config_path: &Path) -> PathBuf {
42    config_dir(config_path).join(RAISE_FILE_NAME)
43}
44
45/// The held UI lock; dropping it releases the lock.
46#[derive(Debug)]
47pub struct UiLock {
48    _file: File,
49}
50
51/// The outcome of [`acquire`].
52#[derive(Debug)]
53pub enum Instance {
54    /// This process is the tray UI for the config.
55    Primary(UiLock),
56    /// Another tray UI holds the lock.
57    Secondary,
58}
59
60/// Take the UI lock for the config at `config_path`, trying `attempts`
61/// times `pause` apart.  A stale raise request is dropped once the lock is
62/// ours, so it cannot pop the window open later.
63pub fn acquire(config_path: &Path, attempts: u32, pause: Duration) -> std::io::Result<Instance> {
64    let path = lock_path_for(config_path);
65    if let Some(dir) = path.parent() {
66        std::fs::create_dir_all(dir)?;
67    }
68    let file = OpenOptions::new()
69        .create(true)
70        .truncate(false)
71        .read(true)
72        .write(true)
73        .open(&path)?;
74    for attempt in 1..=attempts.max(1) {
75        match file.try_lock() {
76            Ok(()) => {
77                take_raise_request(config_path);
78                tracing::info!(
79                    target: TRACE_TARGET,
80                    op = "single_instance",
81                    path = %path.display(),
82                    pid = std::process::id(),
83                    "ui lock taken"
84                );
85                return Ok(Instance::Primary(UiLock { _file: file }));
86            }
87            Err(TryLockError::WouldBlock) if attempt < attempts => std::thread::sleep(pause),
88            Err(TryLockError::WouldBlock) => {}
89            Err(TryLockError::Error(e)) => return Err(e),
90        }
91    }
92    Ok(Instance::Secondary)
93}
94
95/// Ask the running tray UI to show its window, and say so.
96pub fn hand_over(config_path: &Path) -> std::io::Result<()> {
97    let path = raise_path_for(config_path);
98    let outcome = std::fs::write(&path, format!("{}\n", std::process::id()));
99    match &outcome {
100        Ok(()) => tracing::info!(
101            target: TRACE_TARGET,
102            op = "single_instance",
103            raise = %path.display(),
104            "another tray UI is running for this config; asked it to show its window and exiting"
105        ),
106        Err(e) => tracing::warn!(
107            target: TRACE_TARGET,
108            op = "single_instance",
109            raise = %path.display(),
110            error = %e,
111            "another tray UI is running for this config; could not ask it to show its window"
112        ),
113    }
114    outcome
115}
116
117/// Consume a pending raise request; answers whether there was one.
118pub fn take_raise_request(config_path: &Path) -> bool {
119    std::fs::remove_file(raise_path_for(config_path)).is_ok()
120}
121
122/// Until `stop`, consume raise requests and call `raise` for each, when it
123/// can (`raise` answers whether the window could be reached).
124// The loop only sequences `take_raise_request` (unit-tested) with sleeps.
125#[cfg_attr(coverage_nightly, coverage(off))]
126pub fn watch(config_path: PathBuf, stop: Arc<AtomicBool>, raise: impl Fn() -> bool) {
127    while !stop.load(Ordering::SeqCst) {
128        if raise_path_for(&config_path).exists() && raise() {
129            take_raise_request(&config_path);
130            tracing::info!(
131                target: TRACE_TARGET,
132                op = "raise",
133                "a second launch asked for the window; showing it"
134            );
135        }
136        std::thread::sleep(RAISE_POLL);
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    const FAST: Duration = Duration::from_millis(1);
145
146    #[test]
147    fn the_lock_and_the_raise_request_sit_next_to_the_config() {
148        let config = Path::new("/etc/sw/config.toml");
149        assert_eq!(lock_path_for(config), PathBuf::from("/etc/sw/ui.lock"));
150        assert_eq!(raise_path_for(config), PathBuf::from("/etc/sw/ui.raise"));
151    }
152
153    #[test]
154    fn a_second_ui_for_the_same_config_is_secondary_until_the_first_ends() {
155        let dir = tempfile::tempdir().unwrap();
156        let config = dir.path().join("nested").join("config.toml");
157
158        let Instance::Primary(first) = acquire(&config, 1, FAST).unwrap() else {
159            panic!("the first UI must get the lock");
160        };
161        assert!(matches!(
162            acquire(&config, 2, FAST).unwrap(),
163            Instance::Secondary
164        ));
165
166        drop(first);
167        assert!(matches!(
168            acquire(&config, 1, FAST).unwrap(),
169            Instance::Primary(_)
170        ));
171    }
172
173    #[test]
174    fn uis_for_different_configs_run_side_by_side() {
175        let dir = tempfile::tempdir().unwrap();
176        let _a = acquire(&dir.path().join("a").join("config.toml"), 1, FAST).unwrap();
177        assert!(matches!(
178            acquire(&dir.path().join("b").join("config.toml"), 1, FAST).unwrap(),
179            Instance::Primary(_)
180        ));
181    }
182
183    #[test]
184    fn a_hand_over_leaves_one_raise_request_and_logs_it() {
185        let dir = tempfile::tempdir().unwrap();
186        let config = dir.path().join("config.toml");
187        let logs = crate::test_support::capture({
188            let config = config.clone();
189            move || hand_over(&config).unwrap()
190        });
191        assert!(logs.contains("op=\"single_instance\""), "{logs}");
192        assert!(logs.contains("asked it to show its window"), "{logs}");
193        assert!(take_raise_request(&config));
194        assert!(!take_raise_request(&config), "a request is consumed once");
195    }
196
197    #[test]
198    fn a_hand_over_that_cannot_write_is_logged_and_reported() {
199        let dir = tempfile::tempdir().unwrap();
200        let config = dir.path().join("missing-dir").join("config.toml");
201        let logs = crate::test_support::capture(move || {
202            assert!(hand_over(&config).is_err());
203        });
204        assert!(logs.contains("could not ask it"), "{logs}");
205    }
206
207    #[test]
208    fn taking_the_lock_drops_a_stale_raise_request() {
209        let dir = tempfile::tempdir().unwrap();
210        let config = dir.path().join("config.toml");
211        std::fs::write(raise_path_for(&config), "1\n").unwrap();
212        let _lock = acquire(&config, 1, FAST).unwrap();
213        assert!(!raise_path_for(&config).exists());
214    }
215}