Skip to main content

hdf5_pure/
file_lock.rs

1//! OS advisory file locking for the in-place editor (issue #73).
2//!
3//! This is the crash-safe half of HDF5's concurrency model and the `hdf5-pure`
4//! analogue of `H5Pset_file_locking` / the `HDF5_USE_FILE_LOCKING` environment
5//! variable. It is deliberately distinct from the *superblock consistency flag*
6//! (the durable `status_flags` byte a SWMR writer sets; see [`crate::SwmrWriter`]):
7//!
8//! - An **OS lock** is owned by the kernel and tied to the open file. It is
9//!   released automatically when the process exits *for any reason* — clean exit,
10//!   panic, `SIGKILL`, even power loss — so it never leaves stale state and is
11//!   the authoritative signal for "a writer is alive *right now*".
12//! - The **on-disk flag** is just a byte; only userspace code at clean shutdown
13//!   can reset it, so a crash freezes it set. Recover it with
14//!   [`crate::SwmrWriter::clear_swmr_flag`] (the `h5clear -s` equivalent).
15//!
16//! ## Scope: the in-place editor only
17//!
18//! Only [`crate::EditSession`] (and the [`crate::SwmrWriter::clear_swmr_flag`]
19//! recovery rewrite) take a lock — an **exclusive** one — so a second editor or
20//! a concurrent writer cannot open the file. [`crate::SwmrWriter`] and the
21//! readers ([`crate::File::open`] and friends) take **no** lock, on purpose:
22//!
23//! - SWMR is single-writer-*by-contract* and is designed for concurrent reads;
24//!   the reference library itself runs SWMR with file locking disabled. Holding
25//!   a lock would defeat the "multiple-reader" half.
26//! - Crucially, [`std::fs::File`] locking is **advisory on Unix** (`flock`) but
27//!   **mandatory on Windows** (`LockFileEx`): a held lock there blocks *reads* by
28//!   every other handle, not just other lock attempts. A whole-file lock on a
29//!   SWMR writer would therefore make the file unreadable to its readers on
30//!   Windows. Confining locking to the exclusive editor keeps reads working on
31//!   every platform. (One consequence: while an editor holds the lock, a
32//!   concurrent read of the same file is permitted on Unix but blocked by the OS
33//!   on Windows — drop the editor before reading the file back.)
34//!
35//! Locking uses the cross-platform [`std::fs::File`] lock API, so it adds no
36//! dependency, and it lives only in the already `std`-gated edit path, so
37//! `no_std`/`wasm` builds are unaffected.
38
39use std::fs::{File, TryLockError};
40use std::path::Path;
41
42use crate::error::Error;
43
44/// Policy for OS advisory file locking when opening a file for editing.
45///
46/// The default is [`FileLocking::Enabled`]. The `HDF5_USE_FILE_LOCKING`
47/// environment variable, when set to a recognized value, overrides the requested
48/// policy (matching the reference HDF5 library): `FALSE`/`0`/`NO`/`OFF` disable
49/// locking, `BEST_EFFORT` selects best-effort, and `TRUE`/`1`/`YES`/`ON` enable
50/// it.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum FileLocking {
53    /// Acquire the lock, and fail the open with [`Error::Io`] if the filesystem
54    /// does not support locking. A lock held by another process always fails the
55    /// open with [`Error::FileLocked`].
56    #[default]
57    Enabled,
58    /// Do not attempt to lock the file at all.
59    Disabled,
60    /// Attempt to lock, but proceed *without* a lock when the filesystem reports
61    /// that locking is unavailable (e.g. some NFS / network mounts). A lock that
62    /// is genuinely *held* by another process still fails the open. Mirrors the
63    /// reference library's `BEST_EFFORT` / `ignore_disabled_locks`.
64    BestEffort,
65}
66
67/// Parse a recognized `HDF5_USE_FILE_LOCKING` value into a policy, or `None` for
68/// an unrecognized value (in which case the requested policy is kept).
69///
70/// Pure (no environment access) so it can be unit-tested without the
71/// process-global, edition-2024-`unsafe` env mutators.
72fn parse_env(value: &str) -> Option<FileLocking> {
73    let v = value.trim();
74    if v.eq_ignore_ascii_case("FALSE")
75        || v == "0"
76        || v.eq_ignore_ascii_case("NO")
77        || v.eq_ignore_ascii_case("OFF")
78    {
79        Some(FileLocking::Disabled)
80    } else if v.eq_ignore_ascii_case("BEST_EFFORT") {
81        Some(FileLocking::BestEffort)
82    } else if v.eq_ignore_ascii_case("TRUE")
83        || v == "1"
84        || v.eq_ignore_ascii_case("YES")
85        || v.eq_ignore_ascii_case("ON")
86    {
87        Some(FileLocking::Enabled)
88    } else {
89        None
90    }
91}
92
93/// Apply the `HDF5_USE_FILE_LOCKING` environment override to a requested policy.
94/// The environment variable, when set to a recognized value, takes precedence.
95fn resolve(requested: FileLocking) -> FileLocking {
96    std::env::var("HDF5_USE_FILE_LOCKING")
97        .ok()
98        .and_then(|v| parse_env(&v))
99        .unwrap_or(requested)
100}
101
102/// Acquire an **exclusive** advisory lock on `handle` for a writer open.
103///
104/// Non-blocking: if another process holds a conflicting lock, this returns
105/// [`Error::FileLocked`] immediately rather than waiting. The lock is released
106/// when `handle` is dropped (or the process exits, including on a crash).
107pub(crate) fn acquire_exclusive(
108    handle: &File,
109    requested: FileLocking,
110    path: &Path,
111) -> Result<(), Error> {
112    let mode = resolve(requested);
113    if mode == FileLocking::Disabled {
114        return Ok(());
115    }
116    match handle.try_lock() {
117        Ok(()) => Ok(()),
118        // A conflicting lock is genuinely held by another process: the file is
119        // in use. `BestEffort` does not soften this — only *unavailable* locking
120        // is tolerated, not active contention.
121        Err(TryLockError::WouldBlock) => Err(Error::FileLocked(format!(
122            "{}: file is already locked by another process. If a previous writer \
123             crashed, the OS lock is released automatically (try again); a leftover \
124             on-disk SWMR flag can be cleared with SwmrWriter::clear_swmr_flag. Set \
125             HDF5_USE_FILE_LOCKING=FALSE or pass FileLocking::Disabled to bypass locking.",
126            path.display(),
127        ))),
128        // Locking failed for another reason — typically the filesystem does not
129        // support advisory locks (some NFS / network mounts).
130        Err(TryLockError::Error(e)) => match mode {
131            FileLocking::BestEffort => Ok(()),
132            _ => Err(Error::Io(e)),
133        },
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn parse_env_recognizes_disable_values() {
143        for v in ["FALSE", "false", "0", "No", "off", " false "] {
144            assert_eq!(parse_env(v), Some(FileLocking::Disabled), "value {v:?}");
145        }
146    }
147
148    #[test]
149    fn parse_env_recognizes_enable_and_best_effort() {
150        for v in ["TRUE", "true", "1", "Yes", "on"] {
151            assert_eq!(parse_env(v), Some(FileLocking::Enabled), "value {v:?}");
152        }
153        assert_eq!(parse_env("BEST_EFFORT"), Some(FileLocking::BestEffort));
154        assert_eq!(parse_env("best_effort"), Some(FileLocking::BestEffort));
155    }
156
157    #[test]
158    fn parse_env_unrecognized_is_none() {
159        assert_eq!(parse_env(""), None);
160        assert_eq!(parse_env("maybe"), None);
161        assert_eq!(parse_env("2"), None);
162    }
163
164    #[test]
165    fn default_is_enabled() {
166        assert_eq!(FileLocking::default(), FileLocking::Enabled);
167    }
168}