1use std::env;
2use std::ffi::OsString;
3use std::fs::{self, File, OpenOptions};
4use std::io::{self, Seek, SeekFrom, Write};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7use std::sync::OnceLock;
8use std::thread;
9use std::time::Duration;
10
11use fs2::FileExt;
12use indicatif::ProgressBar;
13
14use crate::ui::{print_notice, spinner, stderr_is_interactive};
15
16const BYPASS_ENV_VAR: &str = "CAPULUS_SINGLE_INSTANCE_BYPASS";
17const LOCK_WAIT_POLL_INTERVAL: Duration = Duration::from_millis(100);
18
19static ACTIVE_BYPASS_TOKEN: OnceLock<OsString> = OnceLock::new();
20
21#[derive(Debug)]
22pub struct InvocationLock {
23 _file: Option<File>,
24 path: PathBuf,
25}
26
27impl InvocationLock {
28 pub fn path(&self) -> &Path {
29 &self.path
30 }
31}
32
33#[derive(Debug, thiserror::Error)]
34pub enum LockError {
35 #[error("another `{tool}` invocation is already running (lockfile: {path})")]
36 AlreadyRunning { tool: String, path: String },
37
38 #[error("failed to create lock directory for `{tool}` at {path}: {source}")]
39 CreateDir {
40 tool: String,
41 path: String,
42 #[source]
43 source: io::Error,
44 },
45
46 #[error("failed to open lockfile for `{tool}` at {path}: {source}")]
47 Open {
48 tool: String,
49 path: String,
50 #[source]
51 source: io::Error,
52 },
53
54 #[error("failed to update lockfile for `{tool}` at {path}: {source}")]
55 Update {
56 tool: String,
57 path: String,
58 #[source]
59 source: io::Error,
60 },
61}
62
63pub fn acquire(tool: &str, wait: bool) -> Result<InvocationLock, LockError> {
64 let tool = sanitize_component(tool);
65 let token = bypass_token(&tool);
66 let _ = ACTIVE_BYPASS_TOKEN.set(token.clone());
67 if env::var_os(BYPASS_ENV_VAR).as_ref() == Some(&token) {
68 let path = lock_path(&tool);
69 return Ok(InvocationLock { _file: None, path });
70 }
71 acquire_sanitized(&tool, wait)
72}
73
74pub fn acquire_named(name: &str, wait: bool) -> Result<InvocationLock, LockError> {
75 acquire_sanitized(&sanitize_component(name), wait)
76}
77
78pub fn configure_child_command(command: &mut Command) {
79 if let Some(token) = ACTIVE_BYPASS_TOKEN.get() {
80 command.env(BYPASS_ENV_VAR, token);
81 }
82}
83
84pub fn configure_privileged_child_command(command: &mut Command, program: &str) {
85 if ACTIVE_BYPASS_TOKEN.get().is_none() {
86 return;
87 }
88 match program {
89 "doas" => {
90 command.arg("-E");
91 }
92 "sudo" => {
93 command.arg(format!("--preserve-env={BYPASS_ENV_VAR}"));
94 }
95 _ => {}
96 }
97 configure_child_command(command);
98}
99
100fn write_lock_metadata(tool: &str, path: &Path, file: &mut File) -> Result<(), LockError> {
101 file.set_len(0).map_err(|source| LockError::Update {
102 tool: tool.to_owned(),
103 path: path.display().to_string(),
104 source,
105 })?;
106 file.seek(SeekFrom::Start(0))
107 .map_err(|source| LockError::Update {
108 tool: tool.to_owned(),
109 path: path.display().to_string(),
110 source,
111 })?;
112 writeln!(file, "{}", std::process::id()).map_err(|source| LockError::Update {
113 tool: tool.to_owned(),
114 path: path.display().to_string(),
115 source,
116 })?;
117 file.sync_data().map_err(|source| LockError::Update {
118 tool: tool.to_owned(),
119 path: path.display().to_string(),
120 source,
121 })?;
122 Ok(())
123}
124
125fn acquire_sanitized(tool: &str, wait: bool) -> Result<InvocationLock, LockError> {
126 let path = lock_path(tool);
127 if let Some(parent) = path.parent() {
128 fs::create_dir_all(parent).map_err(|source| LockError::CreateDir {
129 tool: tool.to_owned(),
130 path: parent.display().to_string(),
131 source,
132 })?;
133 }
134
135 let mut file = OpenOptions::new()
136 .create(true)
137 .read(true)
138 .write(true)
139 .truncate(false)
140 .open(&path)
141 .map_err(|source| LockError::Open {
142 tool: tool.to_owned(),
143 path: path.display().to_string(),
144 source,
145 })?;
146
147 let mut wait_ui = LockWaitUi::new(tool, &path);
148 loop {
149 match file.try_lock_exclusive() {
150 Ok(()) => {
151 let result = write_lock_metadata(tool, &path, &mut file);
152 wait_ui.clear();
153 result?;
154 break;
155 }
156 Err(source) if source.kind() == io::ErrorKind::WouldBlock => {
157 if !wait {
158 return Err(LockError::AlreadyRunning {
159 tool: tool.to_owned(),
160 path: path.display().to_string(),
161 });
162 }
163 wait_ui.show();
164 thread::sleep(LOCK_WAIT_POLL_INTERVAL);
165 }
166 Err(source) => {
167 wait_ui.clear();
168 return Err(LockError::Open {
169 tool: tool.to_owned(),
170 path: path.display().to_string(),
171 source,
172 });
173 }
174 }
175 }
176
177 Ok(InvocationLock {
178 _file: Some(file),
179 path,
180 })
181}
182
183fn lock_path(tool: &str) -> PathBuf {
184 lock_root().join(format!("{tool}.lock"))
185}
186
187fn lock_root() -> PathBuf {
188 if let Some(path) = nonempty_env_var_os("XDG_RUNTIME_DIR") {
189 return PathBuf::from(path).join("capulus");
190 }
191 if let Some(path) = nonempty_env_var_os("HOME").or_else(|| nonempty_env_var_os("USERPROFILE")) {
192 return PathBuf::from(path).join(".capulus").join("locks");
193 }
194 env::temp_dir().join("capulus")
195}
196
197fn nonempty_env_var_os(key: &str) -> Option<OsString> {
198 let value = env::var_os(key)?;
199 if value.is_empty() { None } else { Some(value) }
200}
201
202fn bypass_token(tool: &str) -> OsString {
203 OsString::from(format!("capulus:{tool}"))
204}
205
206fn sanitize_component(value: &str) -> String {
207 let mut output = String::with_capacity(value.len());
208 for ch in value.chars() {
209 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
210 output.push(ch.to_ascii_lowercase());
211 } else {
212 output.push('_');
213 }
214 }
215 if output.is_empty() {
216 "tool".to_owned()
217 } else {
218 output
219 }
220}
221
222struct LockWaitUi {
223 message: String,
224 progress: Option<ProgressBar>,
225 notice_printed: bool,
226}
227
228impl LockWaitUi {
229 fn new(tool: &str, path: &Path) -> Self {
230 Self {
231 message: format!(
232 "Waiting for another `{tool}` invocation to finish ({})",
233 path.display()
234 ),
235 progress: None,
236 notice_printed: false,
237 }
238 }
239
240 fn show(&mut self) {
241 if let Some(progress) = &self.progress {
242 progress.tick();
243 return;
244 }
245 if stderr_is_interactive() {
246 self.progress = Some(spinner(&self.message));
247 return;
248 }
249 if !self.notice_printed {
250 print_notice(&self.message);
251 self.notice_printed = true;
252 }
253 }
254
255 fn clear(&mut self) {
256 if let Some(progress) = self.progress.take() {
257 progress.finish_and_clear();
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use std::fs;
265 use std::sync::mpsc;
266 use std::time::{Duration, SystemTime, UNIX_EPOCH};
267
268 use super::{LockError, acquire, acquire_named};
269
270 fn unique_tool_name() -> String {
271 let now = SystemTime::now()
272 .duration_since(UNIX_EPOCH)
273 .expect("current time after unix epoch")
274 .as_nanos();
275 format!("capulus-lock-test-{}-{now}", std::process::id())
276 }
277
278 #[test]
279 fn acquire_without_wait_returns_already_running() {
280 let tool = unique_tool_name();
281 let first = acquire(&tool, false).expect("acquire first lock");
282 let first_path = first.path().to_path_buf();
283 let second = acquire(&tool, false).expect_err("second acquisition should fail");
284 assert!(matches!(second, LockError::AlreadyRunning { .. }));
285 drop(first);
286 fs::remove_file(first_path).ok();
287 }
288
289 #[test]
290 fn acquire_with_wait_blocks_until_previous_holder_releases() {
291 let tool = unique_tool_name();
292 let first = acquire(&tool, false).expect("acquire first lock");
293 let (done_tx, done_rx) = mpsc::channel();
294 let waiting_tool = tool.clone();
295
296 let handle = std::thread::spawn(move || {
297 let second = acquire(&waiting_tool, true).expect("waiting acquisition succeeds");
298 done_tx
299 .send(second.path().to_path_buf())
300 .expect("send path");
301 second
302 });
303
304 assert!(
305 done_rx.recv_timeout(Duration::from_millis(250)).is_err(),
306 "waiting acquisition should still be blocked"
307 );
308 drop(first);
309
310 let second_path = done_rx
311 .recv_timeout(Duration::from_secs(3))
312 .expect("waiting acquisition completes");
313 let second = handle.join().expect("join waiter");
314 drop(second);
315 fs::remove_file(second_path).ok();
316 }
317
318 #[test]
319 fn acquire_named_uses_the_same_lock_namespace_without_bypass() {
320 let tool = unique_tool_name();
321 let first = acquire_named(&tool, false).expect("acquire first named lock");
322 let second = acquire_named(&tool, false).expect_err("second named acquisition should fail");
323 assert!(matches!(second, LockError::AlreadyRunning { .. }));
324 let first_path = first.path().to_path_buf();
325 drop(first);
326 fs::remove_file(first_path).ok();
327 }
328}