Skip to main content

isb_core/
lock.rs

1//! Per-sandbox lock, so two concurrent `up`/`ensure` calls do not both create.
2//!
3//! An flock on `$XDG_RUNTIME_DIR/isb/<project>/<name>.lock`. The fd is opened
4//! close-on-exec (Rust's default), so it never leaks into a child process: a
5//! long-lived child such as a dev server holding it would block every later task.
6
7use std::fs::{File, OpenOptions};
8use std::path::PathBuf;
9use std::time::{Duration, Instant};
10
11use crate::error::{Error, Result};
12
13/// Held for its lifetime; released on drop.
14#[derive(Debug)]
15pub struct NameLock {
16    _file: File,
17    pub path: PathBuf,
18}
19
20fn lock_dir(project: &str) -> PathBuf {
21    let base = std::env::var_os("XDG_RUNTIME_DIR")
22        .filter(|s| !s.is_empty())
23        .map(PathBuf::from)
24        .unwrap_or_else(|| {
25            std::env::temp_dir().join(format!("isb-{}", rustix::process::getuid().as_raw()))
26        });
27    base.join("isb").join(project)
28}
29
30impl NameLock {
31    /// Take the lock, waiting up to `wait`. `on_wait` is called once if the lock
32    /// is busy (to tell a human what is going on).
33    pub fn acquire(
34        project: &str,
35        name: &str,
36        wait: Duration,
37        on_wait: &mut dyn FnMut(&PathBuf),
38    ) -> Result<NameLock> {
39        let dir = lock_dir(project);
40        std::fs::create_dir_all(&dir)?;
41        let path = dir.join(format!("{name}.lock"));
42        let file = OpenOptions::new()
43            .create(true)
44            .truncate(false)
45            .write(true)
46            .open(&path)?;
47        let started = Instant::now();
48        let mut told = false;
49        loop {
50            match rustix::fs::flock(&file, rustix::fs::FlockOperation::NonBlockingLockExclusive) {
51                Ok(()) => return Ok(NameLock { _file: file, path }),
52                Err(rustix::io::Errno::WOULDBLOCK) | Err(rustix::io::Errno::INTR) => {}
53                Err(e) => return Err(Error::Io(e.into())),
54            }
55            if !told {
56                on_wait(&path);
57                told = true;
58            }
59            if started.elapsed() >= wait {
60                return Err(Error::invalid(format!(
61                    "timed out after {wait:?} waiting for the lock on {name} ({}) held by another isb",
62                    path.display()
63                )));
64            }
65            std::thread::sleep(Duration::from_millis(100));
66        }
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn second_holder_waits_then_times_out() {
76        let dir = tempfile::tempdir().unwrap();
77        // SAFETY: tests in this module are the only ones touching this variable.
78        unsafe { std::env::set_var("XDG_RUNTIME_DIR", dir.path()) };
79        let a = NameLock::acquire("p", "n", Duration::from_secs(1), &mut |_| {}).unwrap();
80        let mut waited = false;
81        let b = NameLock::acquire("p", "n", Duration::from_millis(300), &mut |_| waited = true);
82        assert!(b.is_err());
83        assert!(waited);
84        drop(a);
85        NameLock::acquire("p", "n", Duration::from_secs(1), &mut |_| {}).unwrap();
86    }
87
88    #[test]
89    fn lock_fd_is_cloexec() {
90        let dir = tempfile::tempdir().unwrap();
91        let f = OpenOptions::new()
92            .create(true)
93            .truncate(false)
94            .write(true)
95            .open(dir.path().join("x"))
96            .unwrap();
97        let flags = rustix::io::fcntl_getfd(&f).unwrap();
98        assert!(flags.contains(rustix::io::FdFlags::CLOEXEC));
99    }
100}