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}