Skip to main content

start_command/
store_lock.rs

1//! Cross-language file lock. Publish fully written data with an atomic hard link.
2use crate::local_hostname;
3use serde_json::{json, Value};
4use std::fs::{self, OpenOptions};
5use std::io::Write;
6use std::path::PathBuf;
7use std::thread;
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9use uuid::Uuid;
10
11pub struct LockManager {
12    path: PathBuf,
13    token: Option<String>,
14    monitor_owner: bool,
15}
16
17impl LockManager {
18    pub fn new(path: PathBuf) -> Self {
19        Self {
20            path,
21            token: None,
22            monitor_owner: false,
23        }
24    }
25
26    pub fn for_cpu_monitor(mut self) -> Self {
27        self.monitor_owner = true;
28        self
29    }
30
31    pub fn acquire(&mut self, timeout: u64) -> bool {
32        let started = Instant::now();
33        let debug = |message: &str| {
34            if std::env::var("START_DEBUG").as_deref() == Ok("1") {
35                eprintln!("[DEBUG] Store lock {}: {}", self.path.display(), message);
36            }
37        };
38        while started.elapsed() < Duration::from_millis(timeout) {
39            if let Ok(stat) = fs::metadata(&self.path) {
40                let data = fs::read_to_string(&self.path)
41                    .ok()
42                    .and_then(|s| serde_json::from_str::<Value>(&s).ok());
43                if self.stale(data.as_ref(), &stat) {
44                    if let Ok(current) = fs::metadata(&self.path) {
45                        if Self::same_file(&stat, &current) {
46                            debug("reclaiming stale or malformed lock");
47                            let _ = fs::remove_file(&self.path);
48                        }
49                    }
50                }
51            }
52            let token = Uuid::new_v4().to_string();
53            let temp = self
54                .path
55                .with_extension(format!("lock.{}.{}", std::process::id(), token));
56            let result = (|| -> std::io::Result<()> {
57                let mut file = OpenOptions::new()
58                    .write(true)
59                    .create_new(true)
60                    .open(&temp)?;
61                let data = json!({"pid": std::process::id(), "timestamp":
62                    SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
63                    "hostname": local_hostname::get().map(|h| h.to_string_lossy().to_string()).unwrap_or_default(),
64                    "token": token, "processStart": Self::process_info(std::process::id() as u64).map(|p| p.1)});
65                file.write_all(data.to_string().as_bytes())?;
66                file.sync_all()?;
67                fs::hard_link(&temp, &self.path)
68            })();
69            let _ = fs::remove_file(&temp);
70            match result {
71                Ok(()) => {
72                    debug(&format!(
73                        "acquired after {}ms",
74                        started.elapsed().as_millis()
75                    ));
76                    self.token = Some(token);
77                    return true;
78                }
79                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
80                    thread::sleep(Duration::from_millis(100));
81                }
82                Err(error) => {
83                    debug(&format!("publication failed: {}", error));
84                    return false;
85                }
86            }
87        }
88        debug("acquisition timed out");
89        false
90    }
91
92    fn same_file(a: &fs::Metadata, b: &fs::Metadata) -> bool {
93        #[cfg(unix)]
94        {
95            use std::os::unix::fs::MetadataExt;
96            a.dev() == b.dev() && a.ino() == b.ino() && a.modified().ok() == b.modified().ok()
97        }
98        #[cfg(not(unix))]
99        {
100            a.modified().ok() == b.modified().ok() && a.len() == b.len()
101        }
102    }
103
104    fn stale(&self, data: Option<&Value>, stat: &fs::Metadata) -> bool {
105        let timestamp = data
106            .and_then(|d| d.get("timestamp"))
107            .and_then(Value::as_u64);
108        let pid = data.and_then(|d| d.get("pid")).and_then(Value::as_u64);
109        let (Some(timestamp), Some(pid)) =
110            (timestamp, pid.filter(|p| *p > 0 && *p <= i32::MAX as u64))
111        else {
112            return stat
113                .modified()
114                .ok()
115                .and_then(|m| m.elapsed().ok())
116                .is_some_and(|age| age >= Duration::from_secs(3));
117        };
118        let hostname = crate::launch_owner::hostname();
119        if data.and_then(|d| d.get("hostname")).and_then(Value::as_str) == Some(&hostname) {
120            if !crate::launch_owner::process_alive(pid) {
121                return true;
122            }
123            if let Some((state, start)) = Self::process_info(pid) {
124                if state == "Z"
125                    || state == "X"
126                    || state == "x"
127                    || data
128                        .and_then(|d| d["processStart"].as_str())
129                        .is_some_and(|s| s != start)
130                {
131                    return true;
132                }
133            }
134            #[cfg(target_os = "linux")]
135            if self.monitor_owner && data.and_then(|d| d["processStart"].as_str()).is_none() {
136                if let Ok(command) = fs::read_to_string(format!("/proc/{}/cmdline", pid)) {
137                    return !command.contains("cpu-penalty-monitor")
138                        && !command.contains("__start-cpu-monitor");
139                }
140            }
141            return false;
142        }
143        let now = SystemTime::now()
144            .duration_since(UNIX_EPOCH)
145            .unwrap_or_default()
146            .as_millis() as u64;
147        now.saturating_sub(timestamp) > 60000
148    }
149
150    fn process_info(pid: u64) -> Option<(String, String)> {
151        #[cfg(target_os = "linux")]
152        {
153            let stat = fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
154            let fields: Vec<&str> = stat[stat.rfind(')')? + 2..].split_whitespace().collect();
155            Some((fields.first()?.to_string(), fields.get(19)?.to_string()))
156        }
157        #[cfg(not(target_os = "linux"))]
158        {
159            let _ = pid;
160            None
161        }
162    }
163
164    pub fn release(&mut self) {
165        if let Some(token) = self.token.take() {
166            let data = fs::read_to_string(&self.path)
167                .ok()
168                .and_then(|s| serde_json::from_str::<Value>(&s).ok());
169            if data
170                .as_ref()
171                .and_then(|d| d.get("token"))
172                .and_then(Value::as_str)
173                == Some(&token)
174            {
175                let _ = fs::remove_file(&self.path);
176            }
177        }
178    }
179}
180
181impl Drop for LockManager {
182    fn drop(&mut self) {
183        self.release();
184    }
185}