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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
42pub enum FileLocking {
43    /// Acquire the lock; fail to open if it cannot be acquired
44    /// (the HDF5 C-library default).
45    #[default]
46    Enabled,
47    /// Skip locking entirely.
48    Disabled,
49    /// Try to acquire the lock; on failure (filesystem doesn't
50    /// support locking, or another holder), proceed without one.
51    /// Useful on NFS and similar filesystems.
52    BestEffort,
53}
54
55impl FileLocking {
56    /// Returns the policy implied by the `HDF5_USE_FILE_LOCKING`
57    /// environment variable, falling back to [`FileLocking::Enabled`].
58    pub fn from_env() -> Self {
59        Self::parse_env_value(std::env::var("HDF5_USE_FILE_LOCKING").ok().as_deref())
60    }
61
62    /// Returns the policy implied by the env var if set, otherwise `default`.
63    pub fn from_env_or(default: FileLocking) -> Self {
64        match std::env::var("HDF5_USE_FILE_LOCKING").ok().as_deref() {
65            None => default,
66            Some(v) => Self::parse_env_value(Some(v)),
67        }
68    }
69
70    pub(crate) fn parse_env_value(value: Option<&str>) -> Self {
71        match value {
72            None => FileLocking::Enabled,
73            Some(v) => {
74                let trimmed = v.trim();
75                if trimmed.eq_ignore_ascii_case("FALSE")
76                    || trimmed == "0"
77                    || trimmed.eq_ignore_ascii_case("OFF")
78                    || trimmed.eq_ignore_ascii_case("NO")
79                {
80                    FileLocking::Disabled
81                } else if trimmed.eq_ignore_ascii_case("BEST_EFFORT")
82                    || trimmed.eq_ignore_ascii_case("BEST-EFFORT")
83                    || trimmed.eq_ignore_ascii_case("BESTEFFORT")
84                {
85                    FileLocking::BestEffort
86                } else {
87                    // Any other value (TRUE/1/ON/YES or unrecognized) → enabled.
88                    FileLocking::Enabled
89                }
90            }
91        }
92    }
93}
94
95/// Attempt to acquire the requested lock on `file`.
96///
97/// Returns `Ok(true)` if the lock was acquired, `Ok(false)` if locking
98/// was skipped (policy = Disabled) or the attempt failed under
99/// [`FileLocking::BestEffort`]. Returns `Err` only when policy is
100/// [`FileLocking::Enabled`] and the lock could not be obtained.
101pub fn try_acquire(file: &File, mode: LockMode, policy: FileLocking) -> io::Result<bool> {
102    if matches!(policy, FileLocking::Disabled) {
103        return Ok(false);
104    }
105
106    let attempt = match mode {
107        LockMode::Shared => file.try_lock_shared(),
108        LockMode::Exclusive => file.try_lock(),
109    };
110
111    match attempt {
112        Ok(()) => Ok(true),
113        Err(std::fs::TryLockError::WouldBlock) => match policy {
114            FileLocking::Enabled => Err(io::Error::new(
115                io::ErrorKind::WouldBlock,
116                "unable to lock file: another process holds a conflicting lock",
117            )),
118            FileLocking::BestEffort => Ok(false),
119            FileLocking::Disabled => unreachable!(),
120        },
121        Err(std::fs::TryLockError::Error(e)) => match policy {
122            FileLocking::Enabled => Err(e),
123            FileLocking::BestEffort => Ok(false),
124            FileLocking::Disabled => unreachable!(),
125        },
126    }
127}
128
129/// Release any lock currently held on `file`. Safe to call when
130/// no lock is held — the underlying syscall is idempotent in
131/// practice on the platforms we target.
132pub fn release(file: &File) -> io::Result<()> {
133    file.unlock()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn parse_env_value_defaults_to_enabled() {
142        assert_eq!(FileLocking::parse_env_value(None), FileLocking::Enabled);
143    }
144
145    #[test]
146    fn parse_env_value_recognizes_disabled() {
147        for v in ["FALSE", "false", "0", "off", "no"] {
148            assert_eq!(
149                FileLocking::parse_env_value(Some(v)),
150                FileLocking::Disabled,
151                "value: {v}",
152            );
153        }
154    }
155
156    #[test]
157    fn parse_env_value_recognizes_best_effort() {
158        for v in ["BEST_EFFORT", "best_effort", "best-effort", "BestEffort"] {
159            assert_eq!(
160                FileLocking::parse_env_value(Some(v)),
161                FileLocking::BestEffort,
162                "value: {v}",
163            );
164        }
165    }
166
167    #[test]
168    fn parse_env_value_recognizes_enabled() {
169        for v in ["TRUE", "true", "1", "on", "yes", "garbage"] {
170            assert_eq!(
171                FileLocking::parse_env_value(Some(v)),
172                FileLocking::Enabled,
173                "value: {v}",
174            );
175        }
176    }
177
178    #[test]
179    fn try_acquire_disabled_is_noop() {
180        let dir =
181            std::env::temp_dir().join(format!("rust_hdf5_lock_disabled_{}", std::process::id()));
182        std::fs::create_dir_all(&dir).unwrap();
183        let path = dir.join("noop.bin");
184        let f = std::fs::File::create(&path).unwrap();
185        let acquired = try_acquire(&f, LockMode::Exclusive, FileLocking::Disabled).unwrap();
186        assert!(!acquired, "Disabled policy must not acquire a lock");
187        let _ = std::fs::remove_dir_all(&dir);
188    }
189
190    #[test]
191    fn try_acquire_exclusive_then_shared_fails() {
192        let dir = std::env::temp_dir().join(format!("rust_hdf5_lock_excl_{}", std::process::id()));
193        std::fs::create_dir_all(&dir).unwrap();
194        let path = dir.join("conflict.bin");
195        let f1 = std::fs::File::create(&path).unwrap();
196        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
197        let f2 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
198        let res = try_acquire(&f2, LockMode::Shared, FileLocking::Enabled);
199        assert!(res.is_err(), "expected lock conflict");
200        // Best-effort should silently fall through.
201        let res2 = try_acquire(&f2, LockMode::Shared, FileLocking::BestEffort).unwrap();
202        assert!(!res2, "best-effort must report unsuccessful lock as false");
203        release(&f1).unwrap();
204        let _ = std::fs::remove_dir_all(&dir);
205    }
206
207    #[test]
208    fn shared_locks_coexist() {
209        let dir =
210            std::env::temp_dir().join(format!("rust_hdf5_lock_shared_{}", std::process::id()));
211        std::fs::create_dir_all(&dir).unwrap();
212        let path = dir.join("shared.bin");
213        std::fs::File::create(&path).unwrap();
214        let f1 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
215        let f2 = std::fs::OpenOptions::new().read(true).open(&path).unwrap();
216        assert!(try_acquire(&f1, LockMode::Shared, FileLocking::Enabled).unwrap());
217        assert!(try_acquire(&f2, LockMode::Shared, FileLocking::Enabled).unwrap());
218        release(&f1).unwrap();
219        release(&f2).unwrap();
220        let _ = std::fs::remove_dir_all(&dir);
221    }
222
223    #[test]
224    fn release_then_relock_works() {
225        let dir =
226            std::env::temp_dir().join(format!("rust_hdf5_lock_release_{}", std::process::id()));
227        std::fs::create_dir_all(&dir).unwrap();
228        let path = dir.join("release.bin");
229        let f1 = std::fs::File::create(&path).unwrap();
230        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
231        // Release; another opener should now be able to take a fresh lock.
232        release(&f1).unwrap();
233
234        let f2 = std::fs::OpenOptions::new()
235            .read(true)
236            .write(true)
237            .open(&path)
238            .unwrap();
239        assert!(try_acquire(&f2, LockMode::Exclusive, FileLocking::Enabled).unwrap());
240
241        release(&f2).unwrap();
242        let _ = std::fs::remove_dir_all(&dir);
243    }
244}