Skip to main content

capulus/
lock.rs

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}