Skip to main content

bacnet_ops/safety/
lock.rs

1//! An exclusive advisory lock on a profile workspace's ledger, so two
2//! sessions on one profile (two terminals, say) can't both change it.
3//!
4//! The lock is `std::fs::File::try_lock` on `ledger.lock`: `flock` on Unix
5//! and `LockFileEx` on Windows. The holder writes its front end and process
6//! id into the file (`tui 4242`); another process reads them to say who
7//! holds the lock, `bacnet tui (pid 4242)`, where the platform lets it read
8//! a locked file (Windows does not).
9
10use std::fs::{File, OpenOptions, TryLockError};
11use std::io::Write as _;
12use std::path::Path;
13
14/// The lock file in the workspace.
15pub const FILE: &str = "ledger.lock";
16
17/// The held lock; dropping it releases the lock.
18#[derive(Debug)]
19pub struct WorkspaceLock {
20    _file: File,
21}
22
23/// Take the workspace's lock for `front_end` (`tui`, later `mcp`), or say
24/// who holds it.
25pub fn lock(workspace: &Path, front_end: &str) -> Result<WorkspaceLock, String> {
26    let path = workspace.join(FILE);
27    let mut file = OpenOptions::new()
28        .read(true)
29        .write(true)
30        .create(true)
31        .truncate(false)
32        .open(&path)
33        .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
34    match file.try_lock() {
35        Ok(()) => {
36            // Best effort: it only helps the message another process shows.
37            let _ = file
38                .set_len(0)
39                .and_then(|()| write!(file, "{front_end} {}", std::process::id()))
40                .and_then(|()| file.flush());
41            Ok(WorkspaceLock { _file: file })
42        }
43        Err(TryLockError::WouldBlock) => {
44            let holder = std::fs::read_to_string(&path)
45                .ok()
46                .and_then(|text| holder(&text));
47            Err(match holder {
48                Some((front_end, pid)) => {
49                    format!("this profile's ledger is in use by bacnet {front_end} (pid {pid})")
50                }
51                None => "this profile's ledger is in use by another bacnet process".into(),
52            })
53        }
54        Err(TryLockError::Error(error)) => Err(format!("cannot lock {}: {error}", path.display())),
55    }
56}
57
58/// The front end and process id a lock file names: `tui 4242`, or a bare
59/// id, which earlier releases wrote and only the TUI took.
60fn holder(text: &str) -> Option<(String, u32)> {
61    let mut words = text.split_whitespace();
62    let (front_end, pid) = match (words.next()?, words.next(), words.next()) {
63        (pid, None, None) => ("tui", pid),
64        (front_end, Some(pid), None) => (front_end, pid),
65        _ => return None,
66    };
67    let valid = !front_end.is_empty()
68        && front_end
69            .chars()
70            .all(|ch| ch.is_ascii_lowercase() || ch == '-');
71    valid
72        .then(|| pid.parse().ok())
73        .flatten()
74        .map(|pid| (front_end.to_string(), pid))
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn a_second_holder_is_refused_until_the_first_lets_go() {
83        let dir = tempfile::tempdir().unwrap();
84        let first = lock(dir.path(), "tui").unwrap();
85        let refused = lock(dir.path(), "mcp").unwrap_err();
86        assert!(refused.contains("in use"), "{refused}");
87        // Windows does not let another handle read a locked file.
88        #[cfg(unix)]
89        assert!(
90            refused.contains(&format!("bacnet tui (pid {})", std::process::id())),
91            "{refused}"
92        );
93        drop(first);
94        lock(dir.path(), "mcp").unwrap();
95    }
96
97    #[test]
98    fn the_holder_is_read_back() {
99        assert_eq!(holder("tui 42\n"), Some(("tui".into(), 42)));
100        assert_eq!(holder("mcp 7"), Some(("mcp".into(), 7)));
101        assert_eq!(
102            holder("42"),
103            Some(("tui".into(), 42)),
104            "an earlier release's file"
105        );
106        assert_eq!(holder(""), None);
107        assert_eq!(holder("tui"), None);
108        assert_eq!(holder("TUI 42"), None);
109        assert_eq!(holder("tui 42 extra"), None);
110    }
111}