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