bacnet_ops/safety/
lock.rs1use std::fs::{File, OpenOptions, TryLockError};
11use std::io::Write as _;
12use std::path::Path;
13
14pub const FILE: &str = "ledger.lock";
16
17#[derive(Debug)]
19pub struct WorkspaceLock {
20 _file: File,
21}
22
23pub 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 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
58fn 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 #[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}