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}