Skip to main content

bark/lock_manager/
pid_fcntl.rs

1//! `fcntl(F_SETLK)`-based pid lock for single-process-per-datadir
2//! deployment.
3//!
4//! Same shape as [`super::pid_flock::FlockPidLockManager`] — one
5//! OS-level lock on `<datadir>/LOCK` held for the manager's lifetime,
6//! all per-key locking delegated to an internal
7//! [`MemoryLockManager`](super::memory::MemoryLockManager). The only
8//! difference is the OS primitive: this variant uses POSIX
9//! `fcntl(F_SETLK)` instead of `flock(2)`.
10//!
11//! # Safety scope
12//!
13//! Prevents concurrent access by callers within the **current OS
14//! process**. Construction refuses to start a second process holding
15//! the same datadir.
16//!
17//! # Platform support
18//!
19//! Linux, macOS, iOS, Android. Not available on Windows or `wasm32`.
20//!
21//! Pick this over [`super::pid_flock::FlockPidLockManager`] when the
22//! datadir may live on a POSIX-compliant networked filesystem (e.g.
23//! NFSv4 with locking enabled). `fcntl` is the only file-locking
24//! primitive POSIX requires NFS implementations to honor — `flock(2)`
25//! on a networked mount is implementation-defined.
26//!
27//! # When to use
28//!
29//! - Single-process-per-datadir deployments where the datadir may
30//!   sit on networked storage.
31
32use std::collections::HashSet;
33use std::fs::{self, File};
34use std::io::{Read, Write};
35use std::os::unix::io::AsRawFd;
36use std::path::{Path, PathBuf};
37use std::sync::{Mutex, OnceLock};
38
39use anyhow::Context;
40
41use super::{LockGuard, LockManager, PidLockError};
42use super::memory::MemoryLockManager;
43
44/// File name used for the datadir-level PID lock.
45pub const LOCK_FILE: &str = "LOCK";
46
47fn open_lock_file(path: &Path) -> anyhow::Result<File> {
48	File::options()
49		.read(true)
50		.write(true)
51		.create(true)
52		.truncate(false)
53		.open(path)
54		.with_context(|| format!("failed to open lock file {}", path.display()))
55}
56
57/// Attempt a non-blocking POSIX write lock on the whole file.
58fn try_fcntl_lock(file: &File) -> anyhow::Result<bool> {
59	let mut lk: libc::flock = unsafe { std::mem::zeroed() };
60	lk.l_type = libc::F_WRLCK as libc::c_short;
61	lk.l_whence = libc::SEEK_SET as libc::c_short;
62	lk.l_start = 0;
63	lk.l_len = 0;
64
65	let ret = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_SETLK, &lk) };
66	if ret == 0 {
67		return Ok(true);
68	}
69	let err = std::io::Error::last_os_error();
70	match err.raw_os_error() {
71		Some(libc::EAGAIN) | Some(libc::EACCES) => Ok(false),
72		_ => Err(err).context("fcntl F_SETLK failed"),
73	}
74}
75
76
77pub struct FcntlPidLockManager {
78	// Holds the OS lock for the lifetime of the manager. Dropped on
79	// drop of the manager — at which point the OS releases the lock.
80	_pid_file: File,
81	// Removes our datadir from the in-process registry on drop so a
82	// subsequent `new()` can re-acquire.
83	_registration: Registration,
84	datadir: PathBuf,
85	in_process: MemoryLockManager,
86}
87
88impl FcntlPidLockManager {
89	/// Take the pid lock on `datadir` via `fcntl(F_SETLK)` and construct
90	/// a manager. Fails with [`PidLockError::AlreadyHeld`] if another
91	/// process — or another instance in this process — already holds
92	/// the lock, or [`PidLockError::SetupFailed`] for any other I/O
93	/// failure.
94	pub fn new(datadir: impl Into<PathBuf>) -> Result<Self, PidLockError> {
95		let datadir = datadir.into();
96		let setup = |source: anyhow::Error| PidLockError::SetupFailed {
97			datadir: datadir.clone(),
98			source,
99		};
100
101		fs::create_dir_all(&datadir)
102			.with_context(|| format!("failed to create datadir {}", datadir.display()))
103			.map_err(&setup)?;
104
105		// POSIX fcntl locks are scoped to (process, inode), so a second
106		// `fcntl(F_SETLK)` from the same process on the same file would
107		// silently succeed. Track which datadirs this process already
108		// holds the lock for so a second `new()` fails cleanly.
109		let registration = Registration::try_register(&datadir)?;
110
111		let path = datadir.join(LOCK_FILE);
112		let mut file = open_lock_file(&path).map_err(&setup)?;
113
114		if !try_fcntl_lock(&file).map_err(&setup)? {
115			let mut holder = String::new();
116			let _ = file.read_to_string(&mut holder);
117			let pid = holder.trim().parse::<u32>().ok();
118			return Err(PidLockError::AlreadyHeld { datadir, pid });
119		}
120
121		// Stamp the holder PID for diagnostics. Truncate first to drop
122		// any stale content left by a previous holder.
123		file.set_len(0).context("failed to truncate pid lock").map_err(&setup)?;
124		write!(file, "{}", std::process::id())
125			.context("failed to write pid lock").map_err(&setup)?;
126		file.flush().context("failed to flush pid lock").map_err(&setup)?;
127
128		Ok(Self {
129			_pid_file: file,
130			_registration: registration,
131			datadir,
132			in_process: MemoryLockManager::new(),
133		})
134	}
135
136	pub fn datadir(&self) -> &Path {
137		&self.datadir
138	}
139}
140
141/// Process-local registry of datadirs currently held by an
142/// `FcntlPidLockManager`. Removed on drop.
143struct Registration {
144	datadir: PathBuf,
145}
146
147impl Registration {
148	fn try_register(datadir: &Path) -> Result<Self, PidLockError> {
149		let mut held = held_datadirs().lock().expect("FcntlPidLockManager registry poisoned");
150		if !held.insert(datadir.to_path_buf()) {
151			return Err(PidLockError::AlreadyHeld {
152				datadir: datadir.to_path_buf(),
153				pid: Some(std::process::id()),
154			});
155		}
156		Ok(Self { datadir: datadir.to_path_buf() })
157	}
158}
159
160impl Drop for Registration {
161	fn drop(&mut self) {
162		if let Ok(mut held) = held_datadirs().lock() {
163			held.remove(&self.datadir);
164		}
165	}
166}
167
168fn held_datadirs() -> &'static Mutex<HashSet<PathBuf>> {
169	static HELD: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
170	HELD.get_or_init(|| Mutex::new(HashSet::new()))
171}
172
173impl std::fmt::Debug for FcntlPidLockManager {
174	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175		f.debug_struct("FcntlPidLockManager").field("datadir", &self.datadir).finish()
176	}
177}
178
179#[async_trait::async_trait]
180impl LockManager for FcntlPidLockManager {
181	async fn try_lock(&self, key: &str) -> Option<Box<dyn LockGuard>> {
182		self.in_process.try_lock(key).await
183	}
184
185	async fn lock(
186		&self,
187		key: &str,
188		timeout: std::time::Duration,
189	) -> anyhow::Result<Box<dyn LockGuard>> {
190		self.in_process.lock(key, timeout).await
191	}
192}
193
194#[cfg(test)]
195mod test {
196	use super::*;
197
198	fn tmp_dir() -> PathBuf {
199		let dir = std::env::temp_dir()
200			.join(format!("bark-pid-fcntl-lockmgr-{}", std::process::id()))
201			.join(format!("{}", std::time::SystemTime::now()
202				.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
203		let _ = fs::remove_dir_all(&dir);
204		dir
205	}
206
207	#[tokio::test]
208	async fn acquire_writes_holder_pid() {
209		let dir = tmp_dir();
210		let mgr = FcntlPidLockManager::new(&dir).unwrap();
211		let contents = fs::read_to_string(dir.join(LOCK_FILE)).unwrap();
212		assert_eq!(contents, std::process::id().to_string());
213		drop(mgr);
214		let _ = fs::remove_dir_all(&dir);
215	}
216
217	#[tokio::test]
218	async fn second_acquire_in_same_process_is_refused() {
219		let dir = tmp_dir();
220		let _held = FcntlPidLockManager::new(&dir).unwrap();
221		let err = FcntlPidLockManager::new(&dir).unwrap_err();
222		assert!(
223			err.to_string().contains("another process is already using datadir"),
224			"unexpected error: {}", err,
225		);
226		drop(_held);
227		let _ = fs::remove_dir_all(&dir);
228	}
229
230	#[tokio::test]
231	async fn reacquire_after_drop_succeeds() {
232		let dir = tmp_dir();
233		let first = FcntlPidLockManager::new(&dir).unwrap();
234		drop(first);
235		let _second = FcntlPidLockManager::new(&dir).unwrap();
236		let _ = fs::remove_dir_all(&dir);
237	}
238
239	#[tokio::test]
240	async fn per_key_locking_works_in_process() {
241		let dir = tmp_dir();
242		let mgr = FcntlPidLockManager::new(&dir).unwrap();
243
244		let g = mgr.try_lock("foo").await;
245		assert!(g.is_some());
246
247		let busy = mgr.try_lock("foo").await;
248		assert!(busy.is_none(), "same key should be blocked");
249
250		let g2 = mgr.try_lock("bar").await;
251		assert!(g2.is_some(), "different key should be free");
252
253		let _ = fs::remove_dir_all(&dir);
254	}
255}