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.
120pub fn try_acquire(file: &File, mode: LockMode, policy: FileLocking) -> io::Result<bool> {
121    if matches!(policy, FileLocking::Disabled) {
122        return Ok(false);
123    }
124
125    let attempt = match mode {
126        LockMode::Shared => file.try_lock_shared(),
127        LockMode::Exclusive => file.try_lock(),
128    };
129
130    match attempt {
131        Ok(()) => Ok(true),
132        Err(std::fs::TryLockError::WouldBlock) => match policy {
133            FileLocking::Enabled => Err(io::Error::new(
134                io::ErrorKind::WouldBlock,
135                "unable to lock file: another process holds a conflicting lock",
136            )),
137            FileLocking::BestEffort => Ok(false),
138            FileLocking::Disabled => unreachable!(),
139        },
140        Err(std::fs::TryLockError::Error(e)) => match policy {
141            FileLocking::Enabled => Err(e),
142            FileLocking::BestEffort => Ok(false),
143            FileLocking::Disabled => unreachable!(),
144        },
145    }
146}
147
148/// Release any lock currently held on `file`. Safe to call when
149/// no lock is held — the underlying syscall is idempotent in
150/// practice on the platforms we target.
151pub fn release(file: &File) -> io::Result<()> {
152    file.unlock()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn parse_env_value_defaults_to_enabled() {
161        assert_eq!(FileLocking::parse_env_value(None), FileLocking::Enabled);
162    }
163
164    #[test]
165    fn parse_env_value_recognizes_disabled() {
166        for v in ["FALSE", "false", "0", "off", "no"] {
167            assert_eq!(
168                FileLocking::parse_env_value(Some(v)),
169                FileLocking::Disabled,
170                "value: {v}",
171            );
172        }
173    }
174
175    #[test]
176    fn parse_env_value_recognizes_best_effort() {
177        for v in ["BEST_EFFORT", "best_effort", "best-effort", "BestEffort"] {
178            assert_eq!(
179                FileLocking::parse_env_value(Some(v)),
180                FileLocking::BestEffort,
181                "value: {v}",
182            );
183        }
184    }
185
186    #[test]
187    fn parse_env_value_recognizes_enabled() {
188        for v in ["TRUE", "true", "1", "on", "yes", "garbage"] {
189            assert_eq!(
190                FileLocking::parse_env_value(Some(v)),
191                FileLocking::Enabled,
192                "value: {v}",
193            );
194        }
195    }
196
197    #[test]
198    fn try_acquire_disabled_is_noop() {
199        let dir =
200            std::env::temp_dir().join(format!("rust_hdf5_lock_disabled_{}", std::process::id()));
201        std::fs::create_dir_all(&dir).unwrap();
202        let path = dir.join("noop.bin");
203        let f = std::fs::File::create(&path).unwrap();
204        let acquired = try_acquire(&f, LockMode::Exclusive, FileLocking::Disabled).unwrap();
205        assert!(!acquired, "Disabled policy must not acquire a lock");
206        let _ = std::fs::remove_dir_all(&dir);
207    }
208
209    #[test]
210    fn try_acquire_exclusive_then_shared_fails() {
211        let dir = std::env::temp_dir().join(format!("rust_hdf5_lock_excl_{}", std::process::id()));
212        std::fs::create_dir_all(&dir).unwrap();
213        let path = dir.join("conflict.bin");
214        let f1 = std::fs::File::create(&path).unwrap();
215        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
216        let f2 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
217        let res = try_acquire(&f2, LockMode::Shared, FileLocking::Enabled);
218        assert!(res.is_err(), "expected lock conflict");
219        // Best-effort should silently fall through.
220        let res2 = try_acquire(&f2, LockMode::Shared, FileLocking::BestEffort).unwrap();
221        assert!(!res2, "best-effort must report unsuccessful lock as false");
222        release(&f1).unwrap();
223        let _ = std::fs::remove_dir_all(&dir);
224    }
225
226    #[test]
227    fn shared_locks_coexist() {
228        let dir =
229            std::env::temp_dir().join(format!("rust_hdf5_lock_shared_{}", std::process::id()));
230        std::fs::create_dir_all(&dir).unwrap();
231        let path = dir.join("shared.bin");
232        std::fs::File::create(&path).unwrap();
233        let f1 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
234        let f2 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
235        assert!(try_acquire(&f1, LockMode::Shared, FileLocking::Enabled).unwrap());
236        assert!(try_acquire(&f2, LockMode::Shared, FileLocking::Enabled).unwrap());
237        release(&f1).unwrap();
238        release(&f2).unwrap();
239        let _ = std::fs::remove_dir_all(&dir);
240    }
241
242    #[test]
243    fn release_then_relock_works() {
244        let dir =
245            std::env::temp_dir().join(format!("rust_hdf5_lock_release_{}", std::process::id()));
246        std::fs::create_dir_all(&dir).unwrap();
247        let path = dir.join("release.bin");
248        let f1 = std::fs::File::create(&path).unwrap();
249        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
250        // Release; another opener should now be able to take a fresh lock.
251        release(&f1).unwrap();
252
253        let f2 = std::fs::OpenOptions::new()
254            .read(true)
255            .write(true)
256            .open(&path)
257            .unwrap();
258        assert!(try_acquire(&f2, LockMode::Exclusive, FileLocking::Enabled).unwrap());
259
260        release(&f2).unwrap();
261        let _ = std::fs::remove_dir_all(&dir);
262    }
263}