studio_worker/ui/
single_instance.rs1use 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
17pub const LOCK_FILE_NAME: &str = "ui.lock";
19pub const RAISE_FILE_NAME: &str = "ui.raise";
21pub const RAISE_POLL: Duration = Duration::from_millis(250);
23pub 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
35pub fn lock_path_for(config_path: &Path) -> PathBuf {
37 config_dir(config_path).join(LOCK_FILE_NAME)
38}
39
40pub fn raise_path_for(config_path: &Path) -> PathBuf {
42 config_dir(config_path).join(RAISE_FILE_NAME)
43}
44
45#[derive(Debug)]
47pub struct UiLock {
48 _file: File,
49}
50
51#[derive(Debug)]
53pub enum Instance {
54 Primary(UiLock),
56 Secondary,
58}
59
60pub 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
95pub 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
117pub fn take_raise_request(config_path: &Path) -> bool {
119 std::fs::remove_file(raise_path_for(config_path)).is_ok()
120}
121
122#[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}