Skip to main content

rust_hdf5/io/
locking.rs

1//! OS-level advisory file locking for HDF5 files.
2//!
3//! Mirrors the locking semantics of the HDF5 C library:
4//!
5//! - A read-only opener takes a **shared** lock; multiple readers are allowed.
6//! - A read/write opener takes an **exclusive** lock; conflicts with any
7//!   other lock holder.
8//! - SWMR writers initially take an exclusive lock and **release** it once
9//!   SWMR mode starts so concurrent SWMR readers can attach. (We don't
10//!   downgrade exclusive→shared on the same handle: Windows'
11//!   `LockFileEx` is mandatory and a same-handle unlock+shared-relock
12//!   leaves subsequent `WriteFile` calls failing with
13//!   `ERROR_LOCK_VIOLATION`. The HDF5 C library similarly relies on the
14//!   SWMR file-format sentinel rather than OS locks during streaming.)
15//!
16//! Locks are released automatically when the underlying [`std::fs::File`]
17//! is dropped (i.e. when the [`crate::io::file_handle::FileHandle`] closes).
18//!
19//! Locking can be controlled via:
20//! - The `HDF5_USE_FILE_LOCKING` environment variable (`TRUE` / `FALSE` /
21//!   `BEST_EFFORT`).
22//! - The [`FileLocking`] enum passed to a `*_with_locking` constructor or
23//!   to [`crate::file::H5FileOptions`].
24
25use std::fs::File;
26use std::io;
27
28/// Whether the file should be locked shared (multiple readers) or
29/// exclusive (sole owner).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum LockMode {
32    /// Shared lock — multiple holders allowed; conflicts with any
33    /// exclusive lock.
34    Shared,
35    /// Exclusive lock — sole holder; conflicts with any shared or
36    /// exclusive lock.
37    Exclusive,
38}
39
40/// File-locking policy applied at file open time.
41///
42/// # Platform notes
43///
44/// On Unix (`flock(2)` / `fcntl(F_OFD_SETLK)`) the lock is **advisory**:
45/// a handle without a lock can still read and write a file that another
46/// handle has locked. Setting [`FileLocking::Disabled`] or
47/// [`FileLocking::BestEffort`] therefore lets the opener bypass another
48/// process's lock at the cost of safety.
49///
50/// On Windows (`LockFileEx`) the lock is **mandatory**: while one
51/// handle holds an exclusive range lock, no other handle (regardless
52/// of locking policy) can read or write that range — `WriteFile` and
53/// `ReadFile` return `ERROR_LOCK_VIOLATION` (33). `Disabled` and
54/// `BestEffort` only control whether *we* try to acquire a lock, not
55/// whether the OS enforces locks held by other handles. The HDF5 C
56/// library has the same limitation on Windows.
57#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
58pub enum FileLocking {
59    /// Acquire the lock; fail to open if it cannot be acquired
60    /// (the HDF5 C-library default).
61    #[default]
62    Enabled,
63    /// Skip locking entirely. On Windows, OS-level locks held by other
64    /// handles still apply.
65    Disabled,
66    /// Try to acquire the lock; if the filesystem doesn't support
67    /// locking (e.g. NFS), proceed without one. On Unix this also
68    /// proceeds when the lock is contended; on Windows the resulting
69    /// reads/writes still fail at the OS level if another handle holds
70    /// a conflicting `LockFileEx` lock.
71    BestEffort,
72}
73
74impl FileLocking {
75    /// Returns the policy implied by the `HDF5_USE_FILE_LOCKING`
76    /// environment variable, falling back to [`FileLocking::Enabled`].
77    pub fn from_env() -> Self {
78        Self::parse_env_value(std::env::var("HDF5_USE_FILE_LOCKING").ok().as_deref())
79    }
80
81    /// Returns the policy implied by the env var if set, otherwise `default`.
82    pub fn from_env_or(default: FileLocking) -> Self {
83        match std::env::var("HDF5_USE_FILE_LOCKING").ok().as_deref() {
84            None => default,
85            Some(v) => Self::parse_env_value(Some(v)),
86        }
87    }
88
89    pub(crate) fn parse_env_value(value: Option<&str>) -> Self {
90        match value {
91            None => FileLocking::Enabled,
92            Some(v) => {
93                let trimmed = v.trim();
94                if trimmed.eq_ignore_ascii_case("FALSE")
95                    || trimmed == "0"
96                    || trimmed.eq_ignore_ascii_case("OFF")
97                    || trimmed.eq_ignore_ascii_case("NO")
98                {
99                    FileLocking::Disabled
100                } else if trimmed.eq_ignore_ascii_case("BEST_EFFORT")
101                    || trimmed.eq_ignore_ascii_case("BEST-EFFORT")
102                    || trimmed.eq_ignore_ascii_case("BESTEFFORT")
103                {
104                    FileLocking::BestEffort
105                } else {
106                    // Any other value (TRUE/1/ON/YES or unrecognized) → enabled.
107                    FileLocking::Enabled
108                }
109            }
110        }
111    }
112}
113
114/// Attempt to acquire the requested lock on `file`.
115///
116/// Returns `Ok(true)` if the lock was acquired, `Ok(false)` if locking
117/// was skipped (policy = Disabled) or the attempt failed under
118/// [`FileLocking::BestEffort`]. Returns `Err` only when policy is
119/// [`FileLocking::Enabled`] and the lock could not be obtained.
120///
121/// On a `WouldBlock` response we retry briefly (about 100 ms total).
122/// macOS in particular has been observed to surface a stale lock
123/// state for a short window after the previous holder's `close(2)`,
124/// so a quick retry distinguishes a transient release-pending race
125/// from a real long-lived conflict without meaningfully slowing the
126/// real-conflict path.
127pub fn try_acquire(file: &File, mode: LockMode, policy: FileLocking) -> io::Result<bool> {
128    if matches!(policy, FileLocking::Disabled) {
129        return Ok(false);
130    }
131
132    const RETRY_ATTEMPTS: u32 = 10;
133    const RETRY_SLEEP: std::time::Duration = std::time::Duration::from_millis(10);
134
135    let mut attempt = match mode {
136        LockMode::Shared => file.try_lock_shared(),
137        LockMode::Exclusive => file.try_lock(),
138    };
139    for _ in 0..RETRY_ATTEMPTS {
140        if !matches!(attempt, Err(std::fs::TryLockError::WouldBlock)) {
141            break;
142        }
143        std::thread::sleep(RETRY_SLEEP);
144        attempt = match mode {
145            LockMode::Shared => file.try_lock_shared(),
146            LockMode::Exclusive => file.try_lock(),
147        };
148    }
149
150    match attempt {
151        Ok(()) => Ok(true),
152        Err(std::fs::TryLockError::WouldBlock) => match policy {
153            FileLocking::Enabled => Err(io::Error::new(
154                io::ErrorKind::WouldBlock,
155                "unable to lock file: another process holds a conflicting lock",
156            )),
157            FileLocking::BestEffort => Ok(false),
158            FileLocking::Disabled => unreachable!(),
159        },
160        Err(std::fs::TryLockError::Error(e)) => match policy {
161            FileLocking::Enabled => Err(e),
162            FileLocking::BestEffort => Ok(false),
163            FileLocking::Disabled => unreachable!(),
164        },
165    }
166}
167
168/// Release any lock currently held on `file`. Safe to call when
169/// no lock is held — the underlying syscall is idempotent in
170/// practice on the platforms we target.
171pub fn release(file: &File) -> io::Result<()> {
172    file.unlock()
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn parse_env_value_defaults_to_enabled() {
181        assert_eq!(FileLocking::parse_env_value(None), FileLocking::Enabled);
182    }
183
184    #[test]
185    fn parse_env_value_recognizes_disabled() {
186        for v in ["FALSE", "false", "0", "off", "no"] {
187            assert_eq!(
188                FileLocking::parse_env_value(Some(v)),
189                FileLocking::Disabled,
190                "value: {v}",
191            );
192        }
193    }
194
195    #[test]
196    fn parse_env_value_recognizes_best_effort() {
197        for v in ["BEST_EFFORT", "best_effort", "best-effort", "BestEffort"] {
198            assert_eq!(
199                FileLocking::parse_env_value(Some(v)),
200                FileLocking::BestEffort,
201                "value: {v}",
202            );
203        }
204    }
205
206    #[test]
207    fn parse_env_value_recognizes_enabled() {
208        for v in ["TRUE", "true", "1", "on", "yes", "garbage"] {
209            assert_eq!(
210                FileLocking::parse_env_value(Some(v)),
211                FileLocking::Enabled,
212                "value: {v}",
213            );
214        }
215    }
216
217    #[test]
218    fn try_acquire_disabled_is_noop() {
219        let dir =
220            std::env::temp_dir().join(format!("rust_hdf5_lock_disabled_{}", std::process::id()));
221        std::fs::create_dir_all(&dir).unwrap();
222        let path = dir.join("noop.bin");
223        let f = std::fs::File::create(&path).unwrap();
224        let acquired = try_acquire(&f, LockMode::Exclusive, FileLocking::Disabled).unwrap();
225        assert!(!acquired, "Disabled policy must not acquire a lock");
226        let _ = std::fs::remove_dir_all(&dir);
227    }
228
229    #[test]
230    fn try_acquire_exclusive_then_shared_fails() {
231        let dir = std::env::temp_dir().join(format!("rust_hdf5_lock_excl_{}", std::process::id()));
232        std::fs::create_dir_all(&dir).unwrap();
233        let path = dir.join("conflict.bin");
234        let f1 = std::fs::File::create(&path).unwrap();
235        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
236        let f2 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
237        let res = try_acquire(&f2, LockMode::Shared, FileLocking::Enabled);
238        assert!(res.is_err(), "expected lock conflict");
239        // Best-effort should silently fall through.
240        let res2 = try_acquire(&f2, LockMode::Shared, FileLocking::BestEffort).unwrap();
241        assert!(!res2, "best-effort must report unsuccessful lock as false");
242        release(&f1).unwrap();
243        let _ = std::fs::remove_dir_all(&dir);
244    }
245
246    #[test]
247    fn shared_locks_coexist() {
248        let dir =
249            std::env::temp_dir().join(format!("rust_hdf5_lock_shared_{}", std::process::id()));
250        std::fs::create_dir_all(&dir).unwrap();
251        let path = dir.join("shared.bin");
252        std::fs::File::create(&path).unwrap();
253        let f1 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
254        let f2 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
255        assert!(try_acquire(&f1, LockMode::Shared, FileLocking::Enabled).unwrap());
256        assert!(try_acquire(&f2, LockMode::Shared, FileLocking::Enabled).unwrap());
257        release(&f1).unwrap();
258        release(&f2).unwrap();
259        let _ = std::fs::remove_dir_all(&dir);
260    }
261
262    #[test]
263    fn release_then_relock_works() {
264        let dir =
265            std::env::temp_dir().join(format!("rust_hdf5_lock_release_{}", std::process::id()));
266        std::fs::create_dir_all(&dir).unwrap();
267        let path = dir.join("release.bin");
268        let f1 = std::fs::File::create(&path).unwrap();
269        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
270        // Release; another opener should now be able to take a fresh lock.
271        release(&f1).unwrap();
272
273        let f2 = std::fs::OpenOptions::new()
274            .read(true)
275            .write(true)
276            .open(&path)
277            .unwrap();
278        assert!(try_acquire(&f2, LockMode::Exclusive, FileLocking::Enabled).unwrap());
279
280        release(&f2).unwrap();
281        let _ = std::fs::remove_dir_all(&dir);
282    }
283}