Skip to main content

kunobi_daemon/
ownership.rs

1use std::fs::{File, OpenOptions, TryLockError};
2use std::io;
3use std::path::Path;
4use std::time::Instant;
5
6#[cfg(all(feature = "local", unix))]
7use crate::local::unix::open_private;
8#[cfg(all(feature = "local", windows))]
9use crate::local::windows::open_private;
10
11/// An exclusive OS file lock, released when this value is dropped.
12///
13/// Every participant must use the same persistent path in an application-owned
14/// directory. Neither this type nor a caller may unlink or replace the lock
15/// file: another process could still hold a lock on the original inode.
16/// Keep this guard alive through endpoint cleanup and the work it protects.
17/// Use separate paths for daemon ownership and upgrade serialization.
18#[derive(Debug)]
19pub struct ProcessLock(File);
20
21impl ProcessLock {
22    /// Acquire without waiting, creating the file if necessary.
23    ///
24    /// Returns `Ok(None)` only for contention; filesystem and locking failures
25    /// remain errors. The parent directory must already exist.
26    pub fn try_acquire(path: impl AsRef<Path>) -> io::Result<Option<Self>> {
27        Self::try_from_file(open_or_create(path.as_ref())?)
28    }
29
30    /// Acquire, waiting until `deadline` for the holder to release the lock.
31    ///
32    /// Opens the file as [`Self::try_acquire`] does. Returns `Ok(None)` if the
33    /// lock is still held at the deadline; a deadline already passed still gets
34    /// one attempt, and no later attempt is made after it. The OS signals no
35    /// release, so contention is rechecked after a pause that grows to 50 ms.
36    pub fn acquire_until(path: impl AsRef<Path>, deadline: Instant) -> io::Result<Option<Self>> {
37        Self::lock_until(open_or_create(path.as_ref())?, deadline)
38    }
39
40    /// [`Self::acquire_until`] for a lock file private to this user.
41    ///
42    /// Refuses a lock file that is a symlink, or a junction on Windows. On Unix
43    /// the file must also belong to the effective user, and its mode becomes
44    /// 0600. Either refusal is `PermissionDenied`. Directories on the path are
45    /// not checked: keep the lock in a private application-owned directory.
46    /// On Windows nothing about access is checked: a new file inherits the
47    /// directory's ACL, but an existing lock file keeps its own, so the caller
48    /// must secure a lock file it did not create.
49    #[cfg(feature = "local")]
50    pub fn acquire_private_until(
51        path: impl AsRef<Path>,
52        deadline: Instant,
53    ) -> io::Result<Option<Self>> {
54        Self::lock_until(open_private(path.as_ref())?, deadline)
55    }
56
57    /// Lock an already-open file. This preserves a consumer's secure open policy.
58    pub fn try_from_file(file: File) -> io::Result<Option<Self>> {
59        Ok(if locked(&file)? {
60            Some(Self(file))
61        } else {
62            None
63        })
64    }
65
66    /// Observe an existing lock without creating a file. Only contention is true.
67    ///
68    /// The check takes the lock and releases it at once. A process that tries
69    /// to acquire it during that moment sees contention.
70    pub fn is_held(path: impl AsRef<Path>) -> io::Result<bool> {
71        let Some(file) = open_existing(path.as_ref())? else {
72            return Ok(false);
73        };
74        Ok(Self::try_from_file(file)?.is_none())
75    }
76
77    /// Wait until no process holds the lock, or `deadline` passes. True once
78    /// it is free; a missing lock file is free, and none is created.
79    ///
80    /// Like [`Self::is_held`], each check takes the lock for a moment, and the
81    /// lock is not kept: another process may take it before the caller acts.
82    /// Failing to open the file is an error, not a release.
83    pub fn wait_released_until(path: impl AsRef<Path>, deadline: Instant) -> io::Result<bool> {
84        let Some(file) = open_existing(path.as_ref())? else {
85            return Ok(true);
86        };
87        // Dropping the guard unlocks explicitly before the file closes.
88        Ok(Self::lock_until(file, deadline)?.is_some())
89    }
90
91    /// Acquire, rechecking after each pause of the [`Self::acquire_until`]
92    /// schedule until the lock is free. The caller's timeout bounds the wait.
93    #[cfg(feature = "async")]
94    pub(crate) async fn acquire_async(path: &Path) -> io::Result<Self> {
95        let file = open_or_create(path)?;
96        let mut pause = crate::backoff::FIRST;
97        while !locked(&file)? {
98            if let Some(nap) = crate::backoff::next_pause(&mut pause, None) {
99                #[expect(
100                    clippy::disallowed_methods,
101                    reason = "The OS signals no file-lock release; pauses grow to 50 ms and the caller's timeout bounds the wait."
102                )]
103                tokio::time::sleep(nap).await;
104            }
105        }
106        Ok(Self(file))
107    }
108
109    fn lock_until(file: File, deadline: Instant) -> io::Result<Option<Self>> {
110        let acquired = crate::backoff::poll(|| locked(&file), Some(deadline))?;
111        Ok(if acquired { Some(Self(file)) } else { None })
112    }
113}
114
115/// Take the lock if it is free. Contention is `false`, not an error.
116fn locked(file: &File) -> io::Result<bool> {
117    match file.try_lock() {
118        Ok(()) => Ok(true),
119        Err(TryLockError::WouldBlock) => Ok(false),
120        Err(TryLockError::Error(error)) => Err(error),
121    }
122}
123
124fn open_or_create(path: &Path) -> io::Result<File> {
125    let mut options = OpenOptions::new();
126    options.read(true).write(true).create(true).truncate(false);
127    #[cfg(unix)]
128    {
129        use std::os::unix::fs::OpenOptionsExt;
130        options.mode(0o600);
131    }
132    options.open(path)
133}
134
135/// Open the lock file if it exists. A missing file is `None`.
136fn open_existing(path: &Path) -> io::Result<Option<File>> {
137    match OpenOptions::new().read(true).write(true).open(path) {
138        Ok(file) => Ok(Some(file)),
139        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
140        Err(error) => Err(error),
141    }
142}
143
144impl Drop for ProcessLock {
145    fn drop(&mut self) {
146        // Explicit unlock also releases ownership if an OS-level duplicate of
147        // the descriptor survives elsewhere in the process.
148        let _ = self.0.unlock();
149    }
150}