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, then **downgrade** to a
9//!   shared lock once SWMR mode starts so concurrent SWMR readers can attach.
10//!
11//! Locks are released automatically when the underlying [`std::fs::File`]
12//! is dropped (i.e. when the [`crate::io::file_handle::FileHandle`] closes).
13//!
14//! Locking can be controlled via:
15//! - The `HDF5_USE_FILE_LOCKING` environment variable (`TRUE` / `FALSE` /
16//!   `BEST_EFFORT`).
17//! - The [`FileLocking`] enum passed to a `*_with_locking` constructor or
18//!   to [`crate::file::H5FileOptions`].
19
20use std::fs::File;
21use std::io;
22
23/// Whether the file should be locked shared (multiple readers) or
24/// exclusive (sole owner).
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum LockMode {
27    /// Shared lock — multiple holders allowed; conflicts with any
28    /// exclusive lock.
29    Shared,
30    /// Exclusive lock — sole holder; conflicts with any shared or
31    /// exclusive lock.
32    Exclusive,
33}
34
35/// File-locking policy applied at file open time.
36#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
37pub enum FileLocking {
38    /// Acquire the lock; fail to open if it cannot be acquired
39    /// (the HDF5 C-library default).
40    #[default]
41    Enabled,
42    /// Skip locking entirely.
43    Disabled,
44    /// Try to acquire the lock; on failure (filesystem doesn't
45    /// support locking, or another holder), proceed without one.
46    /// Useful on NFS and similar filesystems.
47    BestEffort,
48}
49
50impl FileLocking {
51    /// Returns the policy implied by the `HDF5_USE_FILE_LOCKING`
52    /// environment variable, falling back to [`FileLocking::Enabled`].
53    pub fn from_env() -> Self {
54        Self::parse_env_value(std::env::var("HDF5_USE_FILE_LOCKING").ok().as_deref())
55    }
56
57    /// Returns the policy implied by the env var if set, otherwise `default`.
58    pub fn from_env_or(default: FileLocking) -> Self {
59        match std::env::var("HDF5_USE_FILE_LOCKING").ok().as_deref() {
60            None => default,
61            Some(v) => Self::parse_env_value(Some(v)),
62        }
63    }
64
65    pub(crate) fn parse_env_value(value: Option<&str>) -> Self {
66        match value {
67            None => FileLocking::Enabled,
68            Some(v) => {
69                let trimmed = v.trim();
70                if trimmed.eq_ignore_ascii_case("FALSE")
71                    || trimmed == "0"
72                    || trimmed.eq_ignore_ascii_case("OFF")
73                    || trimmed.eq_ignore_ascii_case("NO")
74                {
75                    FileLocking::Disabled
76                } else if trimmed.eq_ignore_ascii_case("BEST_EFFORT")
77                    || trimmed.eq_ignore_ascii_case("BEST-EFFORT")
78                    || trimmed.eq_ignore_ascii_case("BESTEFFORT")
79                {
80                    FileLocking::BestEffort
81                } else {
82                    // Any other value (TRUE/1/ON/YES or unrecognized) → enabled.
83                    FileLocking::Enabled
84                }
85            }
86        }
87    }
88}
89
90/// Attempt to acquire the requested lock on `file`.
91///
92/// Returns `Ok(true)` if the lock was acquired, `Ok(false)` if locking
93/// was skipped (policy = Disabled) or the attempt failed under
94/// [`FileLocking::BestEffort`]. Returns `Err` only when policy is
95/// [`FileLocking::Enabled`] and the lock could not be obtained.
96pub fn try_acquire(file: &File, mode: LockMode, policy: FileLocking) -> io::Result<bool> {
97    if matches!(policy, FileLocking::Disabled) {
98        return Ok(false);
99    }
100
101    let attempt = match mode {
102        LockMode::Shared => file.try_lock_shared(),
103        LockMode::Exclusive => file.try_lock(),
104    };
105
106    match attempt {
107        Ok(()) => Ok(true),
108        Err(std::fs::TryLockError::WouldBlock) => match policy {
109            FileLocking::Enabled => Err(io::Error::new(
110                io::ErrorKind::WouldBlock,
111                "unable to lock file: another process holds a conflicting lock",
112            )),
113            FileLocking::BestEffort => Ok(false),
114            FileLocking::Disabled => unreachable!(),
115        },
116        Err(std::fs::TryLockError::Error(e)) => match policy {
117            FileLocking::Enabled => Err(e),
118            FileLocking::BestEffort => Ok(false),
119            FileLocking::Disabled => unreachable!(),
120        },
121    }
122}
123
124/// Release any lock currently held on `file`. Safe to call when
125/// no lock is held — the underlying syscall is idempotent in
126/// practice on the platforms we target.
127pub fn release(file: &File) -> io::Result<()> {
128    file.unlock()
129}
130
131/// Downgrade an exclusive lock to a shared lock.
132///
133/// There is a brief window between unlock and re-lock during which
134/// another process could acquire an exclusive lock. The HDF5 C
135/// library has the same race in its SWMR start-up path.
136pub fn downgrade_to_shared(file: &File, policy: FileLocking) -> io::Result<bool> {
137    if matches!(policy, FileLocking::Disabled) {
138        return Ok(false);
139    }
140    file.unlock()?;
141    try_acquire(file, LockMode::Shared, policy)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn parse_env_value_defaults_to_enabled() {
150        assert_eq!(FileLocking::parse_env_value(None), FileLocking::Enabled);
151    }
152
153    #[test]
154    fn parse_env_value_recognizes_disabled() {
155        for v in ["FALSE", "false", "0", "off", "no"] {
156            assert_eq!(
157                FileLocking::parse_env_value(Some(v)),
158                FileLocking::Disabled,
159                "value: {v}",
160            );
161        }
162    }
163
164    #[test]
165    fn parse_env_value_recognizes_best_effort() {
166        for v in ["BEST_EFFORT", "best_effort", "best-effort", "BestEffort"] {
167            assert_eq!(
168                FileLocking::parse_env_value(Some(v)),
169                FileLocking::BestEffort,
170                "value: {v}",
171            );
172        }
173    }
174
175    #[test]
176    fn parse_env_value_recognizes_enabled() {
177        for v in ["TRUE", "true", "1", "on", "yes", "garbage"] {
178            assert_eq!(
179                FileLocking::parse_env_value(Some(v)),
180                FileLocking::Enabled,
181                "value: {v}",
182            );
183        }
184    }
185
186    #[test]
187    fn try_acquire_disabled_is_noop() {
188        let dir = std::env::temp_dir().join(format!(
189            "rust_hdf5_lock_disabled_{}",
190            std::process::id()
191        ));
192        std::fs::create_dir_all(&dir).unwrap();
193        let path = dir.join("noop.bin");
194        let f = std::fs::File::create(&path).unwrap();
195        let acquired = try_acquire(&f, LockMode::Exclusive, FileLocking::Disabled).unwrap();
196        assert!(!acquired, "Disabled policy must not acquire a lock");
197        let _ = std::fs::remove_dir_all(&dir);
198    }
199
200    #[test]
201    fn try_acquire_exclusive_then_shared_fails() {
202        let dir = std::env::temp_dir().join(format!(
203            "rust_hdf5_lock_excl_{}",
204            std::process::id()
205        ));
206        std::fs::create_dir_all(&dir).unwrap();
207        let path = dir.join("conflict.bin");
208        let f1 = std::fs::File::create(&path).unwrap();
209        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
210        let f2 = std::fs::OpenOptions::new()
211            .read(true)
212            .open(&path)
213            .unwrap();
214        let res = try_acquire(&f2, LockMode::Shared, FileLocking::Enabled);
215        assert!(res.is_err(), "expected lock conflict");
216        // Best-effort should silently fall through.
217        let res2 = try_acquire(&f2, LockMode::Shared, FileLocking::BestEffort).unwrap();
218        assert!(!res2, "best-effort must report unsuccessful lock as false");
219        release(&f1).unwrap();
220        let _ = std::fs::remove_dir_all(&dir);
221    }
222
223    #[test]
224    fn shared_locks_coexist() {
225        let dir = std::env::temp_dir().join(format!(
226            "rust_hdf5_lock_shared_{}",
227            std::process::id()
228        ));
229        std::fs::create_dir_all(&dir).unwrap();
230        let path = dir.join("shared.bin");
231        std::fs::File::create(&path).unwrap();
232        let f1 = std::fs::OpenOptions::new()
233            .read(true)
234            .open(&path)
235            .unwrap();
236        let f2 = std::fs::OpenOptions::new()
237            .read(true)
238            .open(&path)
239            .unwrap();
240        assert!(try_acquire(&f1, LockMode::Shared, FileLocking::Enabled).unwrap());
241        assert!(try_acquire(&f2, LockMode::Shared, FileLocking::Enabled).unwrap());
242        release(&f1).unwrap();
243        release(&f2).unwrap();
244        let _ = std::fs::remove_dir_all(&dir);
245    }
246
247    #[test]
248    fn downgrade_releases_exclusive() {
249        let dir = std::env::temp_dir().join(format!(
250            "rust_hdf5_lock_downgrade_{}",
251            std::process::id()
252        ));
253        std::fs::create_dir_all(&dir).unwrap();
254        let path = dir.join("downgrade.bin");
255        let f1 = std::fs::File::create(&path).unwrap();
256        assert!(try_acquire(&f1, LockMode::Exclusive, FileLocking::Enabled).unwrap());
257        downgrade_to_shared(&f1, FileLocking::Enabled).unwrap();
258
259        // Another shared lock should now succeed.
260        let f2 = std::fs::OpenOptions::new()
261            .read(true)
262            .open(&path)
263            .unwrap();
264        assert!(try_acquire(&f2, LockMode::Shared, FileLocking::Enabled).unwrap());
265        // But an exclusive opener should still be blocked.
266        let f3 = std::fs::OpenOptions::new()
267            .read(true)
268            .write(true)
269            .open(&path)
270            .unwrap();
271        assert!(try_acquire(&f3, LockMode::Exclusive, FileLocking::Enabled).is_err());
272
273        release(&f1).unwrap();
274        release(&f2).unwrap();
275        let _ = std::fs::remove_dir_all(&dir);
276    }
277}