Skip to main content

zeppelin_embed/lifecycle/
lock.rs

1//! Single-writer ownership via a process-local registry and an OS file lock.
2//!
3//! Two mechanisms are needed, because neither alone is sufficient:
4//!
5//! * The **process-local registry** provides same-process exclusion. POSIX
6//!   record locks do not conflict between descriptors held by one process, and
7//!   Windows byte-range locks likewise do not conflict with the process that
8//!   already owns them, so without the registry a second `Store` in the same
9//!   process would be admitted.
10//! * The **persistent `writer.lock`** provides cross-process exclusion and is
11//!   released by the operating system when the holder dies, orderly or not.
12//!
13//! The registry is keyed on the store directory's stable filesystem identity —
14//! `(st_dev, st_ino)` on Unix, `(volume serial, file index)` on Windows — never
15//! on path text, so case differences, separator differences, relative versus
16//! absolute spellings and supported junctions all converge on one store.
17//!
18//! Nothing else in this process may open [`STORE_LOCK_FILE`].
19
20use std::collections::BTreeSet;
21use std::fs::{File, OpenOptions};
22#[cfg(unix)]
23use std::os::fd::AsRawFd;
24#[cfg(unix)]
25use std::os::unix::fs::MetadataExt as _;
26use std::path::{Path, PathBuf};
27use std::sync::{Mutex, MutexGuard};
28
29/// Persistent lock filename; only [`StoreLock`] may open this file.
30pub const STORE_LOCK_FILE: &str = "writer.lock";
31
32/// Stable filesystem identity of a store directory.
33type StoreKey = (u64, u64);
34
35static HELD_STORES: Mutex<BTreeSet<StoreKey>> = Mutex::new(BTreeSet::new());
36
37/// Exclusive writer ownership automatically released when the process dies.
38#[derive(Debug)]
39pub struct StoreLock {
40    file: Option<File>,
41    registry_key: StoreKey,
42}
43
44impl StoreLock {
45    /// Attempts to acquire exclusive non-blocking ownership for one store.
46    ///
47    /// Registry admission happens first so a second same-process writer is
48    /// rejected before any lock file is touched. Every failure after that point
49    /// unregisters exactly once, so a failed admission never leaves the store
50    /// permanently unopenable.
51    pub fn acquire(directory: &Path) -> Result<Self, StoreLockError> {
52        let path = directory.join(STORE_LOCK_FILE);
53        let registry_key = store_identity(directory).map_err(|source| StoreLockError::Io {
54            path: directory.to_path_buf(),
55            source,
56        })?;
57        if !held_stores().insert(registry_key) {
58            return Err(StoreLockError::Io {
59                path,
60                source: std::io::Error::from(std::io::ErrorKind::WouldBlock),
61            });
62        }
63        let file = open_lock_file(&path).map_err(|source| {
64            held_stores().remove(&registry_key);
65            StoreLockError::Io {
66                path: path.clone(),
67                source,
68            }
69        })?;
70        if let Err(source) = lock_exclusive(&file) {
71            drop(file);
72            held_stores().remove(&registry_key);
73            return Err(StoreLockError::Io { path, source });
74        }
75        Ok(Self {
76            file: Some(file),
77            registry_key,
78        })
79    }
80}
81
82fn held_stores() -> MutexGuard<'static, BTreeSet<StoreKey>> {
83    match HELD_STORES.lock() {
84        Ok(held) => held,
85        Err(poisoned) => poisoned.into_inner(),
86    }
87}
88
89/// The store directory's stable filesystem identity.
90#[cfg(unix)]
91fn store_identity(directory: &Path) -> std::io::Result<StoreKey> {
92    let metadata = std::fs::metadata(directory)?;
93    Ok((metadata.dev(), metadata.ino()))
94}
95
96#[cfg(windows)]
97fn store_identity(directory: &Path) -> std::io::Result<StoreKey> {
98    crate::sys::windows::directory_identity(directory)
99}
100
101#[cfg(not(any(unix, windows)))]
102fn store_identity(_directory: &Path) -> std::io::Result<StoreKey> {
103    Err(std::io::Error::new(
104        std::io::ErrorKind::Unsupported,
105        "store identity requires a supported platform",
106    ))
107}
108
109/// Opens the persistent lock file with the access the platform lock needs.
110#[cfg(not(windows))]
111fn open_lock_file(path: &Path) -> std::io::Result<File> {
112    OpenOptions::new()
113        .create(true)
114        .truncate(false)
115        .read(true)
116        .write(true)
117        .open(path)
118}
119
120/// Opens the persistent lock file, additionally refusing to share deletion.
121///
122/// Rust opens files with `FILE_SHARE_READ | FILE_SHARE_WRITE |
123/// FILE_SHARE_DELETE` by default. Sharing deletion would let any process
124/// unlink or rename `writer.lock` out from under a live writer, after which a
125/// second writer would create a fresh lock file and be admitted. Narrowing the
126/// share mode makes the held lock file undeletable and unrenameable for as long
127/// as ownership lasts. Handles are non-inheritable by default, so a child
128/// process cannot inherit writer ownership either.
129#[cfg(windows)]
130fn open_lock_file(path: &Path) -> std::io::Result<File> {
131    use std::os::windows::fs::OpenOptionsExt as _;
132
133    OpenOptions::new()
134        .create(true)
135        .truncate(false)
136        .read(true)
137        .write(true)
138        .share_mode(crate::sys::windows::FILE_SHARE_READ | crate::sys::windows::FILE_SHARE_WRITE)
139        .open(path)
140}
141
142#[cfg(unix)]
143fn lock_exclusive(file: &File) -> std::io::Result<()> {
144    let mut lock = unsafe {
145        // SAFETY: all-zero is a valid `flock` value before named fields are set.
146        std::mem::zeroed::<libc::flock>()
147    };
148    lock.l_type = libc::F_WRLCK as libc::c_short;
149    lock.l_whence = libc::SEEK_SET as libc::c_short;
150    lock.l_start = 0;
151    lock.l_len = 0;
152    let result = unsafe {
153        // SAFETY: `file` owns a live descriptor and `lock` remains valid for
154        // the duration of this non-blocking `F_SETLK` call.
155        libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lock)
156    };
157    if result == -1 {
158        let error = std::io::Error::last_os_error();
159        if error
160            .raw_os_error()
161            .is_some_and(|errno| errno == libc::EAGAIN || errno == libc::EACCES)
162        {
163            Err(std::io::Error::from(std::io::ErrorKind::WouldBlock))
164        } else {
165            Err(error)
166        }
167    } else {
168        Ok(())
169    }
170}
171
172/// Takes the cross-process lock with `LockFileEx`, non-blocking and exclusive,
173/// over the fixed range documented by `sys::windows::WRITER_LOCK_RANGE`.
174///
175/// Contention arrives as [`std::io::ErrorKind::WouldBlock`]; a permission
176/// failure stays a permission failure and is never reported as contention.
177#[cfg(windows)]
178fn lock_exclusive(file: &File) -> std::io::Result<()> {
179    crate::sys::windows::lock_file_exclusive(file)
180}
181
182#[cfg(not(any(unix, windows)))]
183fn lock_exclusive(_: &File) -> std::io::Result<()> {
184    Err(std::io::Error::new(
185        std::io::ErrorKind::Unsupported,
186        "store locks require a supported file-locking platform",
187    ))
188}
189
190impl Drop for StoreLock {
191    fn drop(&mut self) {
192        // Closing the last handle releases the OS lock on both platforms; the
193        // registry entry is then removed exactly once.
194        drop(self.file.take());
195        held_stores().remove(&self.registry_key);
196    }
197}
198
199/// Failure to open or acquire the single-writer lock.
200#[derive(Debug)]
201pub enum StoreLockError {
202    /// A named filesystem or lock operation failed.
203    Io {
204        /// Lock-file path.
205        path: PathBuf,
206        /// Underlying platform error.
207        source: std::io::Error,
208    },
209}
210
211impl std::fmt::Display for StoreLockError {
212    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        match self {
214            Self::Io { path, source } => {
215                write!(formatter, "store lock {}: {source}", path.display())
216            }
217        }
218    }
219}
220
221impl std::error::Error for StoreLockError {
222    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
223        match self {
224            Self::Io { source, .. } => Some(source),
225        }
226    }
227}