1use std::fs::OpenOptions;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11use crate::error::{Error, Result};
12
13pub const WRITER_LOCK: &str = "writer.lock";
15pub const WATCH_LEASE: &str = "watch.lock";
17
18#[must_use]
21#[allow(unsafe_code)]
22pub fn pid_alive(pid: i64) -> bool {
23 let Ok(pid) = libc::pid_t::try_from(pid) else {
24 return false;
25 };
26 if pid <= 0 {
27 return false;
28 }
29 let rc = unsafe { libc::kill(pid, 0) };
32 if rc == 0 {
33 return true;
34 }
35 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
36}
37
38#[allow(unsafe_code)]
40pub(crate) fn send_sigterm(pid: i64) {
41 let Ok(pid) = libc::pid_t::try_from(pid) else {
42 return;
43 };
44 if pid <= 0 {
45 return;
46 }
47 unsafe {
49 libc::kill(pid, libc::SIGTERM);
50 }
51}
52
53fn now_ms() -> i64 {
54 std::time::SystemTime::now()
55 .duration_since(std::time::UNIX_EPOCH)
56 .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
57 .unwrap_or(0)
58}
59
60#[must_use]
63pub fn read_holder_pid(path: &Path) -> Option<i64> {
64 let text = std::fs::read_to_string(path).ok()?;
65 let v: serde_json::Value = serde_json::from_str(&text).ok()?;
66 v.get("pid")?.as_i64()
67}
68
69fn try_create(path: &Path) -> Result<bool> {
73 match OpenOptions::new().write(true).create_new(true).open(path) {
74 Ok(mut f) => {
75 let record = serde_json::json!({ "pid": std::process::id(), "ts": now_ms() });
76 f.write_all(record.to_string().as_bytes())
77 .map_err(|e| Error::io("cannot write lock", path, e))?;
78 Ok(true)
79 }
80 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
81 let holder = read_holder_pid(path);
82 let stale = holder.is_none_or(|pid| !pid_alive(pid));
83 if !stale {
84 return Ok(false);
85 }
86 if std::fs::remove_file(path).is_err() {
87 return Ok(false);
89 }
90 match OpenOptions::new().write(true).create_new(true).open(path) {
91 Ok(mut f) => {
92 let record = serde_json::json!({ "pid": std::process::id(), "ts": now_ms() });
93 f.write_all(record.to_string().as_bytes())
94 .map_err(|e| Error::io("cannot write lock", path, e))?;
95 Ok(true)
96 }
97 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
98 Err(e) => Err(Error::io("cannot create lock", path, e)),
99 }
100 }
101 Err(e) => Err(Error::io("cannot create lock", path, e)),
102 }
103}
104
105fn ensure_dir(dir: &Path) -> Result<()> {
106 std::fs::create_dir_all(dir).map_err(|e| Error::io("cannot create", dir, e))
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub struct WriterLockOptions {
112 pub timeout: Duration,
113 pub poll: Duration,
114}
115
116impl Default for WriterLockOptions {
117 fn default() -> Self {
119 Self {
120 timeout: Duration::from_millis(5000),
121 poll: Duration::from_millis(25),
122 }
123 }
124}
125
126#[derive(Debug)]
128pub struct WriterLock {
129 path: PathBuf,
130 held: bool,
131}
132
133impl WriterLock {
134 #[must_use]
136 pub fn path_in(omgbase_dir: &Path) -> PathBuf {
137 omgbase_dir.join(WRITER_LOCK)
138 }
139
140 pub fn try_acquire(omgbase_dir: &Path) -> Result<Option<Self>> {
142 ensure_dir(omgbase_dir)?;
143 let path = Self::path_in(omgbase_dir);
144 Ok(try_create(&path)?.then(|| Self { path, held: true }))
145 }
146
147 pub fn acquire(omgbase_dir: &Path, opts: WriterLockOptions) -> Result<Self> {
150 ensure_dir(omgbase_dir)?;
151 let path = Self::path_in(omgbase_dir);
152 let deadline = Instant::now() + opts.timeout;
153 loop {
154 if try_create(&path)? {
155 return Ok(Self { path, held: true });
156 }
157 if Instant::now() >= deadline {
158 return Err(Error::WriterLockTimeout {
159 holder_pid: read_holder_pid(&path),
160 lock_path: path,
161 });
162 }
163 std::thread::sleep(opts.poll);
164 }
165 }
166
167 #[must_use]
169 pub fn is_free(omgbase_dir: &Path) -> bool {
170 let path = Self::path_in(omgbase_dir);
171 if !path.exists() {
172 return true;
173 }
174 read_holder_pid(&path).is_none_or(|pid| !pid_alive(pid))
175 }
176
177 #[must_use]
178 pub fn path(&self) -> &Path {
179 &self.path
180 }
181
182 pub fn release(&mut self) {
184 if !self.held {
185 return;
186 }
187 self.held = false;
188 let _ = std::fs::remove_file(&self.path);
189 }
190}
191
192impl Drop for WriterLock {
193 fn drop(&mut self) {
194 self.release();
195 }
196}
197
198pub fn with_writer_lock<T>(
200 omgbase_dir: &Path,
201 opts: WriterLockOptions,
202 f: impl FnOnce() -> Result<T>,
203) -> Result<T> {
204 let mut lock = WriterLock::acquire(omgbase_dir, opts)?;
205 let out = f();
206 lock.release();
207 out
208}
209
210#[derive(Debug)]
212pub struct WatchLease {
213 path: PathBuf,
214 held: bool,
215}
216
217impl WatchLease {
218 #[must_use]
220 pub fn path_in(omgbase_dir: &Path) -> PathBuf {
221 omgbase_dir.join(WATCH_LEASE)
222 }
223
224 pub fn try_acquire(omgbase_dir: &Path) -> Result<Option<Self>> {
227 ensure_dir(omgbase_dir)?;
228 let path = Self::path_in(omgbase_dir);
229 Ok(try_create(&path)?.then(|| Self { path, held: true }))
230 }
231
232 #[must_use]
234 pub fn live(omgbase_dir: &Path) -> bool {
235 let path = Self::path_in(omgbase_dir);
236 if !path.exists() {
237 return false;
238 }
239 read_holder_pid(&path).is_some_and(pid_alive)
240 }
241
242 #[must_use]
243 pub fn path(&self) -> &Path {
244 &self.path
245 }
246
247 pub fn release(&mut self) {
248 if !self.held {
249 return;
250 }
251 self.held = false;
252 let _ = std::fs::remove_file(&self.path);
253 }
254}
255
256impl Drop for WatchLease {
257 fn drop(&mut self) {
258 self.release();
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::fs::TempDir;
266
267 fn dead_pid() -> i64 {
269 let mut child = std::process::Command::new("true")
270 .spawn()
271 .expect("spawn true");
272 let pid = i64::from(child.id());
273 child.wait().unwrap();
274 pid
275 }
276
277 #[test]
278 fn liveness_probe() {
279 assert!(pid_alive(i64::from(std::process::id())));
280 assert!(pid_alive(1), "pid 1 is alive (EPERM counts as alive)");
281 assert!(!pid_alive(dead_pid()));
282 assert!(!pid_alive(0));
283 assert!(!pid_alive(-5));
284 assert!(!pid_alive(i64::MAX));
285 }
286
287 #[test]
288 fn writer_lock_round_trip_and_timeout() {
289 let tmp = TempDir::new("writer");
290 let dir = tmp.path().join(".omgbase");
291 assert!(WriterLock::is_free(&dir));
292 let fast = WriterLockOptions {
293 timeout: Duration::from_millis(80),
294 poll: Duration::from_millis(10),
295 };
296 {
297 let lock = WriterLock::acquire(&dir, fast).unwrap();
298 assert!(lock.path().is_file());
299 let body: serde_json::Value =
300 serde_json::from_str(&std::fs::read_to_string(lock.path()).unwrap()).unwrap();
301 assert_eq!(body["pid"], std::process::id());
302 assert!(body["ts"].is_number());
303 assert!(!WriterLock::is_free(&dir));
304 assert!(WriterLock::try_acquire(&dir).unwrap().is_none());
305 let err = WriterLock::acquire(&dir, fast).unwrap_err();
306 match err {
307 Error::WriterLockTimeout {
308 holder_pid,
309 lock_path,
310 } => {
311 assert_eq!(holder_pid, Some(i64::from(std::process::id())));
312 assert_eq!(lock_path, lock.path());
313 }
314 other => panic!("{other}"),
315 }
316 assert!(err_string_names_pid(
317 &WriterLock::acquire(&dir, fast).unwrap_err()
318 ));
319 }
320 assert!(WriterLock::is_free(&dir), "released on drop");
321 assert!(!WriterLock::path_in(&dir).exists());
322
323 let r: Result<()> = with_writer_lock(&dir, fast, || Err(Error::Other("boom".into())));
325 assert!(r.is_err());
326 assert!(WriterLock::is_free(&dir));
327 assert_eq!(with_writer_lock(&dir, fast, || Ok(7)).unwrap(), 7);
328
329 std::fs::write(
331 WriterLock::path_in(&dir),
332 format!("{{\"pid\":{}}}", dead_pid()),
333 )
334 .unwrap();
335 assert!(WriterLock::is_free(&dir));
336 let l = WriterLock::try_acquire(&dir).unwrap().expect("stolen");
337 drop(l);
338 std::fs::write(WriterLock::path_in(&dir), "garbage").unwrap();
339 assert!(WriterLock::is_free(&dir));
340 assert!(WriterLock::try_acquire(&dir).unwrap().is_some());
341 assert!(WriterLock::is_free(&dir));
342 let mut l = WriterLock::try_acquire(&dir).unwrap().unwrap();
344 l.release();
345 l.release();
346 }
347
348 fn err_string_names_pid(e: &Error) -> bool {
349 e.to_string()
350 .contains(&format!("held by pid {}", std::process::id()))
351 }
352
353 #[test]
354 fn watch_lease_is_try_only_and_probeable() {
355 let tmp = TempDir::new("lease");
356 let dir = tmp.path().join(".omgbase");
357 assert!(!WatchLease::live(&dir));
358 let lease = WatchLease::try_acquire(&dir).unwrap().expect("free");
359 assert!(WatchLease::live(&dir));
360 assert!(WatchLease::try_acquire(&dir).unwrap().is_none());
361 assert_eq!(
362 read_holder_pid(lease.path()),
363 Some(i64::from(std::process::id()))
364 );
365 drop(lease);
366 assert!(!WatchLease::live(&dir));
367 std::fs::write(
368 WatchLease::path_in(&dir),
369 format!("{{\"pid\":{},\"ts\":0}}", dead_pid()),
370 )
371 .unwrap();
372 assert!(!WatchLease::live(&dir), "a dead holder is not live");
373 let mut l = WatchLease::try_acquire(&dir)
374 .unwrap()
375 .expect("stolen from the dead");
376 assert!(WatchLease::live(&dir));
377 l.release();
378 assert!(!WatchLease::path_in(&dir).exists());
379 std::fs::write(WatchLease::path_in(&dir), "{}").unwrap();
380 assert!(!WatchLease::live(&dir));
381 assert!(WatchLease::try_acquire(&dir).unwrap().is_some());
382 let _w = WriterLock::try_acquire(&dir).unwrap().unwrap();
384 assert!(!WatchLease::live(&dir));
385 }
386}