Skip to main content

rightkit_fs/
lock.rs

1//! Cross-process advisory file locks.
2//!
3//! The lock is an OS advisory lock, so the OS releases it when the owner exits or is killed:
4//! there is no TTL and no way for a live holder to be displaced. Unix uses POSIX record locks
5//! (`fcntl` `F_SETLK`), Windows uses `LockFileEx` through `std::fs::File::lock*`. Owner details
6//! (pid, host, label, start time) live in a sibling `<lock>.owner` file because Windows blocks
7//! other processes from reading a locked file. An
8//! owner file whose lock can be acquired is stale evidence of a crashed holder and is reported
9//! through [`FileLock::recovered_stale`].
10//!
11//! The lock file itself is never deleted: removing it after unlock lets a second process lock
12//! the old inode while a third creates and locks a new one for the same path.
13//!
14//! Owner records are JSON, so they need the `json` feature. Without it, `acquire` writes no
15//! owner file, `recovered_stale` is always `None`, and `current_owner` returns `None`. The lock
16//! itself behaves the same either way.
17//!
18//! Consolidates CutRight project/model-store/qa locks and CodeRight `DaemonDbLock`.
19
20#[cfg(feature = "json")]
21use crate::atomic::atomic_write;
22use crate::error::{FsError, Result};
23#[cfg(feature = "json")]
24use serde::{Deserialize, Serialize};
25#[cfg(not(unix))]
26use std::fs::TryLockError;
27use std::fs::{File, OpenOptions};
28use std::path::{Path, PathBuf};
29use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
33pub struct LockOwner {
34    pub pid: u32,
35    pub hostname: String,
36    pub label: String,
37    pub started_unix_ms: u64,
38}
39
40impl LockOwner {
41    pub fn current(label: &str) -> Self {
42        Self {
43            pid: std::process::id(),
44            hostname: hostname(),
45            label: label.to_string(),
46            started_unix_ms: SystemTime::now()
47                .duration_since(UNIX_EPOCH)
48                .map(|d| d.as_millis() as u64)
49                .unwrap_or(0),
50        }
51    }
52
53    pub fn describe(&self) -> String {
54        format!(
55            "pid {} on {} ({}) since unix-ms {}",
56            self.pid, self.hostname, self.label, self.started_unix_ms
57        )
58    }
59}
60
61#[derive(Clone, Debug)]
62pub struct LockOptions {
63    /// How long to wait for a busy lock. `Duration::ZERO` tries exactly once.
64    pub timeout: Duration,
65    pub poll: Duration,
66    /// Recorded in the owner file so a contender can say who holds the lock.
67    pub label: String,
68}
69
70impl Default for LockOptions {
71    fn default() -> Self {
72        Self {
73            timeout: Duration::ZERO,
74            poll: Duration::from_millis(50),
75            label: String::new(),
76        }
77    }
78}
79
80impl LockOptions {
81    pub fn wait(timeout: Duration) -> Self {
82        Self {
83            timeout,
84            ..Self::default()
85        }
86    }
87
88    pub fn labeled(mut self, label: &str) -> Self {
89        self.label = label.to_string();
90        self
91    }
92}
93
94/// Held until dropped (or the process dies).
95#[derive(Debug)]
96pub struct FileLock {
97    /// Held for its `Drop`, which releases the lock; never read.
98    #[cfg_attr(unix, allow(dead_code))]
99    file: Handle,
100    path: PathBuf,
101    owner_path: Option<PathBuf>,
102    /// Owner of a lock left behind by a process that is gone, when one was found.
103    pub recovered_stale: Option<LockOwner>,
104}
105
106/// Which lock a caller wants.
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108enum Mode {
109    Exclusive,
110    Shared,
111}
112
113/// Unix: a process-held POSIX record lock. Windows: the locked file.
114#[cfg(unix)]
115type Handle = posix::Handle;
116#[cfg(not(unix))]
117type Handle = File;
118
119pub fn owner_path_for(lock_path: &Path) -> PathBuf {
120    let mut os = lock_path.as_os_str().to_os_string();
121    os.push(".owner");
122    PathBuf::from(os)
123}
124
125fn open_lock_file(path: &Path) -> Result<File> {
126    let mut options = OpenOptions::new();
127    options.read(true).write(true).create(true).truncate(false);
128    #[cfg(unix)]
129    {
130        use std::os::unix::fs::OpenOptionsExt;
131        options
132            .mode(0o600)
133            .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
134    }
135    options.open(path).map_err(|e| FsError::io(path, e))
136}
137
138impl FileLock {
139    /// Exclusive lock, the first of the two lock primitives. Waits up to `options.timeout`
140    /// (`Duration::ZERO` tries once), then records this process as owner in `<path>.owner`.
141    /// Returns `FsError::Locked` (with the owner) or `FsError::LockTimeout` when busy.
142    pub fn acquire(path: &Path, options: &LockOptions) -> Result<Self> {
143        let file = lock_with_retry(path, options, Mode::Exclusive)?;
144        let recovered_stale = read_owner(&owner_path_for(path));
145        let owner_path = record_owner(path, &options.label)?;
146        Ok(Self {
147            file,
148            path: path.to_path_buf(),
149            owner_path,
150            recovered_stale,
151        })
152    }
153
154    /// Exclusive lock, failing immediately when busy. Shorthand for `acquire` with default options.
155    pub fn try_acquire(path: &Path) -> Result<Self> {
156        Self::acquire(path, &LockOptions::default())
157    }
158
159    /// Shared lock, the second primitive: any number of shared holders may coexist, and an
160    /// exclusive holder excludes them all. Waits up to `options.timeout`. Writes no owner
161    /// record, so `recovered_stale` is always `None`.
162    pub fn acquire_shared(path: &Path, options: &LockOptions) -> Result<Self> {
163        let file = lock_with_retry(path, options, Mode::Shared)?;
164        Ok(Self {
165            file,
166            path: path.to_path_buf(),
167            owner_path: None,
168            recovered_stale: None,
169        })
170    }
171
172    pub fn path(&self) -> &Path {
173        &self.path
174    }
175
176    /// The holder recorded on disk for `lock_path` (diagnostics; may be stale).
177    pub fn current_owner(lock_path: &Path) -> Option<LockOwner> {
178        read_owner(&owner_path_for(lock_path))
179    }
180}
181
182/// Retries `mode` on `path` until granted, timed out, or the lock file fails.
183#[cfg(unix)]
184fn lock_with_retry(path: &Path, options: &LockOptions, mode: Mode) -> Result<Handle> {
185    let started = Instant::now();
186    loop {
187        if let Some(handle) = posix::try_lock(path, mode)? {
188            return Ok(handle);
189        }
190        wait_or_fail(path, options, started, mode)?;
191    }
192}
193
194/// Windows: `LockFileEx` is per handle and not inherited, so one handle serves every attempt.
195#[cfg(not(unix))]
196fn lock_with_retry(path: &Path, options: &LockOptions, mode: Mode) -> Result<Handle> {
197    let file = open_lock_file(path)?;
198    let started = Instant::now();
199    loop {
200        let locked = match mode {
201            Mode::Exclusive => file.try_lock(),
202            Mode::Shared => file.try_lock_shared(),
203        };
204        match locked {
205            Ok(()) => return Ok(file),
206            Err(TryLockError::WouldBlock) => wait_or_fail(path, options, started, mode)?,
207            Err(TryLockError::Error(e)) => return Err(FsError::io(path, e)),
208        }
209    }
210}
211
212/// Returns the timeout error once the wait budget is spent; otherwise sleeps one poll.
213fn wait_or_fail(path: &Path, options: &LockOptions, started: Instant, mode: Mode) -> Result<()> {
214    if started.elapsed() >= options.timeout {
215        let waited_ms = started.elapsed().as_millis() as u64;
216        return Err(match mode {
217            Mode::Exclusive if options.timeout.is_zero() => {
218                let owner = read_owner(&owner_path_for(path))
219                    .map(|o| o.describe())
220                    .unwrap_or_else(|| "an unknown owner".into());
221                FsError::Locked {
222                    path: path.to_path_buf(),
223                    owner,
224                }
225            }
226            _ => FsError::LockTimeout {
227                path: path.to_path_buf(),
228                waited_ms,
229            },
230        });
231    }
232    std::thread::sleep(options.poll);
233    Ok(())
234}
235
236impl Drop for FileLock {
237    fn drop(&mut self) {
238        // Owner record goes first: while we still hold the lock nobody can have replaced it.
239        if let Some(owner) = &self.owner_path {
240            let _ = std::fs::remove_file(owner);
241        }
242        // Unix: dropping `self.file` releases the POSIX record lock (see `posix`).
243        #[cfg(not(unix))]
244        let _ = self.file.unlock();
245    }
246}
247
248/// Unix locks are POSIX record locks, not `flock`.
249///
250/// `flock` belongs to the open file description. A child forked by another thread inherits
251/// the descriptor and keeps the lock alive until it execs, so a released lock looks busy.
252/// Record locks belong to the process and are not inherited across `fork`.
253///
254/// Closing any descriptor for a file releases every record lock this process holds on it.
255/// So a process keeps one descriptor per lock file: shared holders share it, and a registry
256/// under one mutex records what this process holds. Acquire and release both run under that
257/// mutex, and a descriptor closes only under it.
258#[cfg(unix)]
259mod posix {
260    use super::{open_lock_file, Mode};
261    use crate::error::{FsError, Result};
262    use std::collections::BTreeMap;
263    use std::fs::File;
264    use std::path::{Path, PathBuf};
265    use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
266
267    struct Entry {
268        mode: Mode,
269        holders: usize,
270        file: Arc<File>,
271    }
272
273    fn registry() -> MutexGuard<'static, BTreeMap<PathBuf, Entry>> {
274        static REGISTRY: OnceLock<Mutex<BTreeMap<PathBuf, Entry>>> = OnceLock::new();
275        REGISTRY
276            .get_or_init(|| Mutex::new(BTreeMap::new()))
277            .lock()
278            .unwrap_or_else(|p| p.into_inner())
279    }
280
281    /// A record lock this process holds on one lock file. Released on drop.
282    #[derive(Debug)]
283    pub struct Handle {
284        key: PathBuf,
285        /// Taken on drop under the registry mutex, so the last descriptor closes before any
286        /// other acquire in this process can open the file again.
287        file: Option<Arc<File>>,
288    }
289
290    /// Tries `mode` on the lock file at `path`. `Ok(None)` when busy, in this process or another.
291    pub fn try_lock(path: &Path, mode: Mode) -> Result<Option<Handle>> {
292        let key = registry_key(path);
293        let mut registry = registry();
294        if let Some(entry) = registry.get_mut(&key) {
295            // This process's own record locks never conflict with each other, so contention
296            // inside the process is decided here: shared holders join, anything else waits.
297            if entry.mode == Mode::Shared && mode == Mode::Shared {
298                entry.holders += 1;
299                return Ok(Some(Handle {
300                    key,
301                    file: Some(entry.file.clone()),
302                }));
303            }
304            return Ok(None);
305        }
306        let file = Arc::new(open_lock_file(path)?);
307        if !set_lock(&file, mode, path)? {
308            // This process holds no record lock on the file (its own locks never make F_SETLK
309            // fail), so closing this descriptor releases nothing.
310            return Ok(None);
311        }
312        registry.insert(
313            key.clone(),
314            Entry {
315                mode,
316                holders: 1,
317                file: file.clone(),
318            },
319        );
320        Ok(Some(Handle {
321            key,
322            file: Some(file),
323        }))
324    }
325
326    /// `Ok(true)` when granted; `Ok(false)` when another process holds a conflicting lock.
327    fn set_lock(file: &File, mode: Mode, path: &Path) -> Result<bool> {
328        let kind = match mode {
329            Mode::Exclusive => libc::F_WRLCK,
330            Mode::Shared => libc::F_RDLCK,
331        };
332        match set_record_lock(file, kind) {
333            Ok(granted) => Ok(granted),
334            Err(error) => Err(FsError::io(path, error)),
335        }
336    }
337
338    /// Takes a non-blocking POSIX record lock of `kind` (`F_WRLCK`/`F_RDLCK`) over the whole file.
339    /// `Ok(false)` when another process holds a conflicting lock. Retries on `EINTR`.
340    fn set_record_lock(file: &File, kind: libc::c_short) -> std::io::Result<bool> {
341        use std::os::fd::AsRawFd;
342        let request = libc::flock {
343            l_type: kind as _,
344            l_whence: libc::SEEK_SET as _,
345            l_start: 0,
346            l_len: 0,
347            l_pid: 0,
348        };
349        loop {
350            // SAFETY: `file` is an open descriptor for the duration of the call, and `request` is
351            // a fully initialised `flock` that outlives it; `F_SETLK` only reads through the
352            // pointer.
353            let rc = unsafe {
354                libc::fcntl(
355                    file.as_raw_fd(),
356                    libc::F_SETLK,
357                    &request as *const libc::flock,
358                )
359            };
360            if rc != -1 {
361                return Ok(true);
362            }
363            let error = std::io::Error::last_os_error();
364            match error.raw_os_error() {
365                Some(libc::EINTR) => continue,
366                Some(libc::EAGAIN) | Some(libc::EACCES) => return Ok(false),
367                _ => return Err(error),
368            }
369        }
370    }
371
372    /// Canonical parent plus file name, so one file reached through two spellings of its
373    /// directory shares one registry entry.
374    fn registry_key(path: &Path) -> PathBuf {
375        let parent = path
376            .parent()
377            .filter(|p| !p.as_os_str().is_empty())
378            .unwrap_or(Path::new("."));
379        match (parent.canonicalize(), path.file_name()) {
380            (Ok(dir), Some(name)) => dir.join(name),
381            _ => path.to_path_buf(),
382        }
383    }
384
385    impl Drop for Handle {
386        fn drop(&mut self) {
387            let mut registry = registry();
388            let last = match registry.get_mut(&self.key) {
389                Some(entry) => {
390                    entry.holders -= 1;
391                    entry.holders == 0
392                }
393                None => false,
394            };
395            if last {
396                registry.remove(&self.key);
397            }
398            drop(self.file.take());
399        }
400    }
401}
402
403/// Run `f` while holding the exclusive lock at `path`.
404pub fn with_lock<T>(path: &Path, options: &LockOptions, f: impl FnOnce() -> T) -> Result<T> {
405    let _guard = FileLock::acquire(path, options)?;
406    Ok(f())
407}
408
409/// Writes the owner record for the exclusive holder of `lock_path`, returning its path.
410#[cfg(feature = "json")]
411fn record_owner(lock_path: &Path, label: &str) -> Result<Option<PathBuf>> {
412    let owner_path = owner_path_for(lock_path);
413    let record = serde_json::to_vec_pretty(&LockOwner::current(label)).expect("owner serializes");
414    atomic_write(&owner_path, &record)?;
415    Ok(Some(owner_path))
416}
417
418#[cfg(not(feature = "json"))]
419fn record_owner(_lock_path: &Path, _label: &str) -> Result<Option<PathBuf>> {
420    Ok(None)
421}
422
423#[cfg(feature = "json")]
424fn read_owner(path: &Path) -> Option<LockOwner> {
425    serde_json::from_slice(&std::fs::read(path).ok()?).ok()
426}
427
428#[cfg(not(feature = "json"))]
429fn read_owner(_path: &Path) -> Option<LockOwner> {
430    None
431}
432
433pub fn hostname() -> String {
434    #[cfg(unix)]
435    {
436        let mut buf = [0u8; 256];
437        // SAFETY: buffer is valid for `buf.len()` bytes and gethostname NUL-terminates within it.
438        if unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) } == 0 {
439            let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len());
440            let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
441            if !name.is_empty() {
442                return name;
443            }
444        }
445    }
446    for var in ["COMPUTERNAME", "HOSTNAME"] {
447        if let Some(v) = std::env::var_os(var) {
448            let s = v.to_string_lossy().trim().to_string();
449            if !s.is_empty() {
450                return s;
451            }
452        }
453    }
454    "unknown".into()
455}