Skip to main content

bark/lock_manager/
pid_flock.rs

1//! Named locks for a single-process-per-datadir deployment.
2//!
3//! # Safety scope
4//!
5//! Prevents concurrent access by callers within the **current OS
6//! process**. Construction additionally **refuses to start a second
7//! process** holding the same datadir: an exclusive OS-level lock is
8//! acquired on `<datadir>/LOCK` via `std::fs::File::try_lock`
9//! (`flock(2)` on Unix, `LockFileEx` on Windows). The OS releases that
10//! lock when the process exits, even on SIGKILL or a crash.
11//!
12//! From that point on, all per-key locking is in-memory: by
13//! construction, this is the only process touching `datadir`, so the
14//! cross-process semantics of file-based per-key locking would be
15//! redundant.
16//!
17//! # Platform support
18//!
19//! Linux, macOS, Android, Windows. Not available on `wasm32`.
20//!
21//! # When to use
22//!
23//! - You run a single-process-per-datadir deployment (CLIs, daemons).
24//! - You want that constraint expressed as a type rather than as a
25//!   separate setup step that callers might forget.
26
27use std::fs::{self, File, TryLockError};
28use std::io::{Read, Write};
29use std::path::{Path, PathBuf};
30
31use anyhow::Context;
32
33use super::{LockGuard, LockManager, PidLockError};
34use super::memory::MemoryLockManager;
35
36/// File name used for the datadir-level PID lock.
37pub const LOCK_FILE: &str = "LOCK";
38
39fn open_lock_file(path: &Path) -> anyhow::Result<File> {
40	File::options()
41		.read(true)
42		.write(true)
43		.create(true)
44		.truncate(false)
45		.open(path)
46		.with_context(|| format!("failed to open lock file {}", path.display()))
47}
48
49
50pub struct FlockPidLockManager {
51	// Holds the OS lock for the lifetime of the manager. Dropped on
52	// drop of the manager — at which point the OS releases the lock.
53	_pid_file: File,
54	datadir: PathBuf,
55	in_process: MemoryLockManager,
56}
57
58impl FlockPidLockManager {
59	/// Take the pid lock on `datadir` and construct a manager. Fails
60	/// with [`PidLockError::AlreadyHeld`] if another process already
61	/// holds the lock, or [`PidLockError::SetupFailed`] for any other
62	/// I/O failure.
63	pub fn new(datadir: impl Into<PathBuf>) -> Result<Self, PidLockError> {
64		Self::new_with_lock_file(datadir, LOCK_FILE)
65	}
66
67	/// Like [`new`](Self::new), but locking a caller-chosen file name
68	pub fn new_with_lock_file(
69		datadir: impl Into<PathBuf>,
70		lock_file: &str,
71	) -> Result<Self, PidLockError> {
72		let datadir = datadir.into();
73		let setup = |source: anyhow::Error| PidLockError::SetupFailed {
74			datadir: datadir.clone(),
75			source,
76		};
77
78		fs::create_dir_all(&datadir)
79			.with_context(|| format!("failed to create datadir {}", datadir.display()))
80			.map_err(setup)?;
81
82		let path = datadir.join(lock_file);
83		let mut file = open_lock_file(&path).map_err(setup)?;
84
85		match file.try_lock() {
86			Ok(()) => {}
87			Err(TryLockError::WouldBlock) => {
88				let mut holder = String::new();
89				let _ = file.read_to_string(&mut holder);
90				let pid = holder.trim().parse::<u32>().ok();
91				return Err(PidLockError::AlreadyHeld { datadir, pid });
92			}
93			Err(TryLockError::Error(e)) => {
94				return Err(setup(anyhow::Error::from(e)
95					.context(format!("failed to acquire pid lock at {}", path.display()))));
96			}
97		}
98
99		// Stamp the holder PID for diagnostics. Truncate first to drop
100		// any stale content left by a previous holder.
101		file.set_len(0).context("failed to truncate pid lock").map_err(setup)?;
102		write!(file, "{}", std::process::id())
103			.context("failed to write pid lock").map_err(&setup)?;
104		file.flush().context("failed to flush pid lock").map_err(setup)?;
105
106		Ok(Self {
107			_pid_file: file,
108			datadir,
109			in_process: MemoryLockManager::new(),
110		})
111	}
112
113	pub fn datadir(&self) -> &Path {
114		&self.datadir
115	}
116}
117
118impl std::fmt::Debug for FlockPidLockManager {
119	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120		f.debug_struct("FlockPidLockManager").field("datadir", &self.datadir).finish()
121	}
122}
123
124#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
125#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
126impl LockManager for FlockPidLockManager {
127	async fn try_lock(&self, key: &str) -> Option<Box<dyn LockGuard>> {
128		self.in_process.try_lock(key).await
129	}
130
131	async fn lock(
132		&self,
133		key: &str,
134		timeout: std::time::Duration,
135	) -> anyhow::Result<Box<dyn LockGuard>> {
136		self.in_process.lock(key, timeout).await
137	}
138}
139
140#[cfg(test)]
141mod test {
142	use super::*;
143
144	fn tmp_dir() -> PathBuf {
145		let dir = std::env::temp_dir()
146			.join(format!("bark-pid-lockmgr-{}", std::process::id()))
147			.join(format!("{}", std::time::SystemTime::now()
148				.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()));
149		let _ = fs::remove_dir_all(&dir);
150		dir
151	}
152
153	#[tokio::test]
154	async fn acquire_writes_holder_pid() {
155		let dir = tmp_dir();
156		let mgr = FlockPidLockManager::new(&dir).unwrap();
157		let contents = fs::read_to_string(dir.join(LOCK_FILE)).unwrap();
158		assert_eq!(contents, std::process::id().to_string());
159		drop(mgr);
160		let _ = fs::remove_dir_all(&dir);
161	}
162
163	#[tokio::test]
164	async fn second_acquire_in_same_process_is_refused() {
165		let dir = tmp_dir();
166		let _held = FlockPidLockManager::new(&dir).unwrap();
167		let err = FlockPidLockManager::new(&dir).unwrap_err();
168		assert!(
169			err.to_string().contains("another process is already using datadir"),
170			"unexpected error: {}", err,
171		);
172		drop(_held);
173		let _ = fs::remove_dir_all(&dir);
174	}
175
176	#[tokio::test]
177	async fn distinct_lock_files_dont_conflict() {
178		let dir = tmp_dir();
179		let _default = FlockPidLockManager::new(&dir).unwrap();
180		let _custom = FlockPidLockManager::new_with_lock_file(&dir, "other.lock").unwrap();
181		let err = FlockPidLockManager::new_with_lock_file(&dir, "other.lock").unwrap_err();
182		assert!(matches!(err, PidLockError::AlreadyHeld { .. }));
183		let _ = fs::remove_dir_all(&dir);
184	}
185
186	#[tokio::test]
187	async fn reacquire_after_drop_succeeds() {
188		let dir = tmp_dir();
189		let first = FlockPidLockManager::new(&dir).unwrap();
190		drop(first);
191		let _second = FlockPidLockManager::new(&dir).unwrap();
192		let _ = fs::remove_dir_all(&dir);
193	}
194
195	#[tokio::test]
196	async fn per_key_locking_works_in_process() {
197		let dir = tmp_dir();
198		let mgr = FlockPidLockManager::new(&dir).unwrap();
199
200		let g = mgr.try_lock("foo").await;
201		assert!(g.is_some());
202
203		let busy = mgr.try_lock("foo").await;
204		assert!(busy.is_none(), "same key should be blocked");
205
206		let g2 = mgr.try_lock("bar").await;
207		assert!(g2.is_some(), "different key should be free");
208
209		let _ = fs::remove_dir_all(&dir);
210	}
211}