Skip to main content

bark/lock_manager/
mod.rs

1//! Named locks usable across async tasks, threads, processes, or browser
2//! tabs — depending on the backend you pick.
3//!
4//! # What it is
5//!
6//! bark needs to coordinate access to a shared dataset (e.g. a wallet
7//! database) so that two callers don't trample each other. The
8//! [`LockManager`] trait is where you plug in *how that coordination is
9//! enforced* on the target platform.
10//!
11//! Pick a manager whose enforcement scope matches the reach of the
12//! dataset bark is opening:
13//!
14//! - A wallet that only ever runs in a single process? An in-memory
15//!   manager is enough.
16//! - A wallet on disk that another process might also open? You need a
17//!   cross-process file-based manager.
18//! - A wallet running in the browser, possibly opened in multiple tabs?
19//!   You need the Web Locks backend.
20//!
21//! Pick the wrong scope and bark will silently allow concurrent access.
22//! The rest of this page is the picking guide.
23//!
24//! # Platform support
25//!
26//! | Backend                                                  | Linux | macOS | iOS | Android | Windows | Web (wasm32) |
27//! |----------------------------------------------------------|:-----:|:-----:|:---:|:-------:|:-------:|:------------:|
28//! | [`MemoryLockManager`](memory::MemoryLockManager)         |   ✓   |   ✓   |  ✓  |    ✓    |    ✓    |      ✓       |
29//! | [`FlockPidLockManager`](pid_flock::FlockPidLockManager)  |   ✓   |   ✓   |     |    ✓    |    ✓    |              |
30//! | [`FcntlPidLockManager`](pid_fcntl::FcntlPidLockManager)  |   ✓   |   ✓   |  ✓  |    ✓    |         |              |
31//! | [`WebLockManager`](web_locks::WebLockManager)            |       |       |     |         |         |      ✓       |
32//!
33//! # Safety scope
34//!
35//! Each backend prevents concurrent access by callers under a different
36//! scope. Pick the one that matches the threat you actually have:
37//!
38//! | Backend          | Same async runtime | Same OS process | Across processes | Across machines (NFS/SMB) | Across browser tabs |
39//! |------------------|:------------------:|:---------------:|:----------------:|:-------------------------:|:-------------------:|
40//! | `Memory`         |         ✓          |        ✓        |                  |                           |                     |
41//! | `FlockPidLock`   |         ✓          |        ✓        |    refuses 2nd   |           ⚠               |                     |
42//! | `FcntlPidLock`   |         ✓          |        ✓        |    refuses 2nd   |  ✓ (POSIX-compliant NFS)  |                     |
43//! | `WebLocks`       |         ✓          |    (n/a)        |     (n/a)        |           (n/a)           |          ✓          |
44//!
45//! ⚠ `FlockPidLock` uses `flock(2)` on Unix, whose behavior over networked
46//! filesystems is implementation-defined; use `FcntlPidLock` there.
47//!
48//! # Picking a backend
49//!
50//! - **Don't want to think about it?** Call [`platform_default`] —
51//!   it returns the sensible PidLock-family backend for your build
52//!   target (wasm gets Web Locks). Override with a specific backend
53//!   only when you have a non-default deployment shape (e.g.
54//!   multi-process access to the same datadir).
55//! - **Single-process apps and tests** —
56//!   [`MemoryLockManager`](memory::MemoryLockManager) is the safe
57//!   default: every instance in the process shares one key map, so two
58//!   callers cannot accidentally end up with disjoint lock universes.
59//! - **Single-process-per-datadir CLIs / daemons** — pick a `PidLock`
60//!   variant: [`FlockPidLockManager`](pid_flock::FlockPidLockManager)
61//!   on Linux/macOS/Android/Windows desktops, or
62//!   [`FcntlPidLockManager`](pid_fcntl::FcntlPidLockManager) when the
63//!   datadir may live on networked storage. One OS-level lock on
64//!   `<datadir>/LOCK` guarantees single-process exclusivity; per-key
65//!   locking is in-memory.
66//! - **Web (wasm32)** — only [`WebLockManager`](web_locks::WebLockManager)
67//!   (which delegates to `navigator.locks`) is available. Prevents
68//!   concurrent access across same-origin tabs in the same browser;
69//!   gives no guarantees across different browsers or incognito
70//!   sessions.
71//!
72//! # What callers must guarantee
73//!
74//! - **Use one backend per dataset, forever.** Two distinct managers do
75//!   not exclude each other; mixing backends or directories on the same
76//!   data is silently unsafe.
77//! - **Use the same lock directory in every instance** for a given
78//!   dataset.
79
80mod key;
81mod internal_memory;
82pub mod memory;
83#[cfg(target_arch = "wasm32")]
84pub mod web_locks;
85#[cfg(all(any(unix, windows), not(target_arch = "wasm32")))]
86pub mod pid_flock;
87#[cfg(all(any(unix), not(target_arch = "wasm32")))]
88pub mod pid_fcntl;
89
90use std::time::Duration;
91use std::path::PathBuf;
92
93use anyhow::bail;
94use bitcoin::bip32::Fingerprint;
95
96use crate::utils::time;
97
98const POLL_INTERVAL: Duration = Duration::from_millis(50);
99
100/// Errors from constructing a pid-lock-based [`LockManager`]
101/// ([`pid_flock::FlockPidLockManager`] or [`pid_fcntl::FcntlPidLockManager`]).
102///
103/// Pattern-match on this when you want to surface "another process is
104/// already using this datadir" differently from setup-failure cases.
105#[derive(thiserror::Error, Debug)]
106pub enum PidLockError {
107	/// Another instance — same process or otherwise — already holds
108	/// the pid lock for this datadir. The `pid` is the value that
109	/// instance wrote into the LOCK file (best-effort; may be absent
110	/// or stale).
111	#[error("another process is already using datadir {datadir}{}",
112		match pid {
113			Some(p) => format!(" (holder PID: {})", p),
114			None => String::new(),
115		})]
116	AlreadyHeld {
117		datadir: PathBuf,
118		pid: Option<u32>,
119	},
120
121	/// Anything else that went wrong setting up the datadir or
122	/// opening the lock file (filesystem permission, ENOENT, etc.).
123	#[error("failed to set up datadir {datadir}")]
124	SetupFailed {
125		datadir: PathBuf,
126		#[source]
127		source: anyhow::Error,
128	},
129}
130
131/// A handle that holds a named lock until dropped.
132///
133/// Trait objects are returned from [`LockManager`] methods so callers do
134/// not need to spell the backend's concrete guard type.
135pub trait LockGuard: Send + Sync + std::fmt::Debug {}
136
137/// Acquire and release named locks.
138///
139/// Implementations only need to provide [`try_lock`](Self::try_lock); the
140/// default [`lock`](Self::lock) polls it under a [`tokio::time::timeout`].
141#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
142#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
143pub trait LockManager: Send + Sync + std::fmt::Debug {
144	/// Try to acquire the named lock without waiting. Returns `None` if
145	/// it is already held, the key is rejected by [`validate_key`], or
146	/// the backend cannot acquire the lock for any other reason.
147	async fn try_lock(&self, key: &str) -> Option<Box<dyn LockGuard>>;
148
149	/// Acquire the named lock, polling [`try_lock`](Self::try_lock) until
150	/// it succeeds or `timeout` elapses.
151	///
152	/// `timeout` is mandatory to make accidental deadlocks impossible at
153	/// the API level. Pass [`Duration::MAX`] if you really want to wait
154	/// indefinitely.
155	async fn lock(&self, key: &str, timeout: Duration)
156		-> anyhow::Result<Box<dyn LockGuard>>
157	{
158		let result = time::timeout(timeout, async {
159			loop {
160				if let Some(g) = self.try_lock(key).await {
161					return g;
162				}
163				time::sleep(POLL_INTERVAL).await;
164			}
165		}).await;
166		match result {
167			Ok(g) => Ok(g),
168			Err(_) => bail!("timed out acquiring lock {:?} after {:?}", key, timeout),
169		}
170	}
171}
172
173/// Return the recommended [`LockManager`] backend for the current
174/// build target. Most platforms will result a `LockManager` that
175/// can only be instantiated once per wallet.
176///
177/// UNIX and Windows platforms require datadir, wasm32 requires fingerprint.
178#[allow(unreachable_code)]
179pub fn platform_default(
180	datadir: Option<impl Into<PathBuf>>,
181	fingerprint: Option<Fingerprint>,
182) -> anyhow::Result<Box<dyn LockManager>> {
183	#[cfg(target_arch = "wasm32")]
184	{
185		// Use navigator.locks via WebLockManager. An in-memory variant
186		// wouldn't be safe — the user can open the app in multiple
187		// tabs, each a separate wasm instance. navigator.locks is the
188		// only cross-tab coordination primitive in the browser.
189		// `datadir` is ignored.
190		let _ = datadir;
191		let mgr = if let Some(fp) = fingerprint {
192			self::web_locks::WebLockManager::new_with_fingerprint(fp)
193		} else {
194			self::web_locks::WebLockManager::new()
195		};
196		return Ok(Box::new(mgr));
197	}
198
199	#[cfg(all(unix, not(target_arch = "wasm32")))]
200	{
201		let _ = fingerprint;
202		if let Some(datadir) = datadir {
203			// Use fcntl: it has wider support than flock across the unix
204			// family.
205			//
206			// We pick a PidLock variant over per-key fcntl files because:
207			// 1. It doesn't pollute the datadir with `<key>.lock` files.
208			// 2. It's faster — one OS-level lock at construction, then
209			//    in-memory locking per key (no syscall per try_lock).
210			// 3. It avoids cross-process footguns like notifications not
211			//    firing when a second process is doing the work.
212			//
213			return Ok(Box::new(self::pid_fcntl::FcntlPidLockManager::new(datadir)?));
214		} else {
215			return Ok(Box::new(self::memory::MemoryLockManager::new()));
216		}
217	}
218
219	#[cfg(all(windows, not(target_arch = "wasm32")))]
220	{
221		let _ = fingerprint;
222		if let Some(datadir) = datadir {
223			// Use std::fs::File::try_lock (LockFileEx under the hood):
224			// fcntl doesn't exist on Windows, and LockFileEx is the
225			// direct equivalent.
226			//
227			// We pick a PidLock variant over per-key file locks because:
228			// 1. It doesn't pollute the datadir with `<key>.lock` files.
229			// 2. It's faster — one OS-level lock at construction, then
230			//    in-memory locking per key (no syscall per try_lock).
231			// 3. It avoids cross-process footguns like notifications not
232			//    firing when a second process is doing the work.
233			return Ok(Box::new(self::pid_flock::FlockPidLockManager::new(datadir)?));
234		} else {
235			return Ok(Box::new(self::memory::MemoryLockManager::new()));
236		}
237	}
238
239	bail!("lock_manager::platform_default: no default backend for this target");
240}
241
242// The shared test harness uses `tokio::spawn` / `tokio::sync::Barrier`
243// / `tokio::time::timeout`, all of which require the `rt` feature that
244// is desktop-only. The web_locks backend has its own wasm-bindgen-test
245// suite in its module.
246#[cfg(all(test, not(target_arch = "wasm32")))]
247mod test {
248	use super::*;
249
250	use std::path::PathBuf;
251	use std::fs;
252	use std::sync::Arc;
253
254	const TEST_TIMEOUT: Duration = Duration::from_secs(5);
255
256	struct TestBackend {
257		name: &'static str,
258		mgr: Arc<dyn LockManager>,
259		// `None` for backends that don't use a directory (Memory).
260		dir: Option<PathBuf>,
261	}
262
263	impl Drop for TestBackend {
264		fn drop(&mut self) {
265			if let Some(d) = &self.dir {
266				let _ = fs::remove_dir_all(d);
267			}
268		}
269	}
270
271	fn tmp_dir() -> PathBuf {
272		let dir = std::env::temp_dir()
273			.join(format!("bark-lock-test-{}", rand::random::<u64>()));
274		fs::create_dir_all(&dir).unwrap();
275		dir
276	}
277
278	/// Every backend available on this target.
279	fn managers() -> Vec<TestBackend> {
280		let mut v = Vec::new();
281
282		v.push(TestBackend {
283			name: "InternalMemory",
284			mgr: Arc::new(internal_memory::InternalMemoryLockManager::new()),
285			dir: None,
286		});
287
288		v.push(TestBackend {
289			name: "Memory",
290			mgr: Arc::new(memory::MemoryLockManager::new()),
291			dir: None,
292		});
293
294		#[cfg(all(any(unix, windows), not(target_arch = "wasm32")))]
295		{
296			let dir = tmp_dir();
297			v.push(TestBackend {
298				name: "FlockPidLock",
299				mgr: Arc::new(pid_flock::FlockPidLockManager::new(&dir).unwrap()),
300				dir: Some(dir),
301			});
302		}
303
304		#[cfg(all(unix, not(target_arch = "wasm32")))]
305		{
306			let dir = tmp_dir();
307			v.push(TestBackend {
308				name: "FcntlPidLock",
309				mgr: Arc::new(pid_fcntl::FcntlPidLockManager::new(&dir).unwrap()),
310				dir: Some(dir),
311			});
312		}
313
314		#[cfg(target_arch = "wasm32")]
315		{
316			v.push(TestBackend {
317				name: "Web",
318				mgr: Arc::new(web_locks::WebLockManager::new()),
319				dir: None,
320			});
321		}
322
323		v
324	}
325
326	#[tokio::test]
327	async fn acquire_and_release() {
328		for tb in managers() {
329			let g = tb.mgr.lock("bark.ln_receive.1", TEST_TIMEOUT).await.unwrap();
330			drop(g);
331			let _g2 = tb.mgr.lock("bark.ln_receive.1", TEST_TIMEOUT).await.unwrap();
332		}
333	}
334
335	#[tokio::test]
336	async fn try_lock_returns_none_when_held() {
337		for tb in managers() {
338			let g = tb.mgr.lock("k", TEST_TIMEOUT).await.unwrap();
339			let busy = tb.mgr.try_lock("k").await;
340			assert!(busy.is_none(), "{}: second try_lock should be blocked", tb.name);
341			drop(g);
342			let g2 = tb.mgr.try_lock("k").await;
343			assert!(g2.is_some(), "{}: try_lock should succeed after release", tb.name);
344		}
345	}
346
347	#[tokio::test]
348	async fn distinct_keys_dont_block() {
349		for tb in managers() {
350			let _g1 = tb.mgr.lock("a", TEST_TIMEOUT).await.unwrap();
351			let _g2 = tb.mgr.lock("b", TEST_TIMEOUT).await.unwrap();
352		}
353	}
354
355	#[tokio::test]
356	async fn lock_returns_timeout_error() {
357		for tb in managers() {
358			let _held = tb.mgr.lock("k", TEST_TIMEOUT).await.unwrap();
359
360			// Acquire from another task so holding `_held` doesn't block
361			// the test on its own memory-mutex wait.
362			let mgr = Arc::clone(&tb.mgr);
363			let result = tokio::spawn(async move {
364				mgr.lock("k", Duration::from_millis(150)).await
365			}).await.unwrap();
366
367			assert!(result.is_err(), "{}: expected timeout, got {:?}", tb.name, result);
368			assert!(result.unwrap_err().to_string().contains("timed out"));
369		}
370	}
371
372	#[tokio::test]
373	async fn waiter_unblocks_after_drop() {
374		for tb in managers() {
375			let g = tb.mgr.lock("k", TEST_TIMEOUT).await.unwrap();
376
377			let mgr = Arc::clone(&tb.mgr);
378			let waiter = tokio::spawn(async move {
379				mgr.lock("k", TEST_TIMEOUT).await.unwrap()
380			});
381
382			tokio::time::sleep(Duration::from_millis(150)).await;
383			drop(g);
384
385			let result = time::timeout(Duration::from_secs(2), waiter).await;
386			assert!(result.is_ok(), "{}: waiter should succeed after holder dropped", tb.name);
387		}
388	}
389
390	#[tokio::test]
391	async fn ten_concurrent_try_lock_only_one_wins() {
392		// Asserts that `try_lock` is atomic under contention: when N
393		// callers race for the same key, exactly one observes it as free.
394		//
395		// Force 10 tasks to call try_lock at the same point via a barrier.
396		// Whichever the executor polls first will hold the guard for
397		// 100 ms; that is long enough for the other 9 tasks to be polled
398		// and observe the lock as held.
399		use tokio::sync::Barrier;
400		const N: usize = 10;
401
402		for tb in managers() {
403			let barrier = Arc::new(Barrier::new(N));
404			let mut handles = Vec::with_capacity(N);
405
406			for _ in 0..N {
407				let mgr = Arc::clone(&tb.mgr);
408				let barrier = Arc::clone(&barrier);
409				handles.push(tokio::spawn(async move {
410					barrier.wait().await;
411					let guard = mgr.try_lock("contested").await;
412					let acquired = guard.is_some();
413					if acquired {
414						tokio::time::sleep(Duration::from_millis(100)).await;
415					}
416					acquired
417				}));
418			}
419
420			let mut successes = 0usize;
421			for h in handles {
422				successes += h.await.unwrap() as usize;
423			}
424			assert_eq!(
425				successes, 1,
426				"{}: expected exactly 1 successful try_lock out of {}, got {}",
427				tb.name, N, successes,
428			);
429		}
430	}
431
432	#[tokio::test]
433	async fn reject_bad_keys() {
434		for tb in managers() {
435			// Empty.
436			assert!(tb.mgr.try_lock("").await.is_none(), "{}: empty", tb.name);
437			// Disallowed character (path separator).
438			assert!(tb.mgr.try_lock("a/b").await.is_none(), "{}: slash", tb.name);
439			// Disallowed character (angle bracket).
440			assert!(tb.mgr.try_lock("a<b>").await.is_none(), "{}: angle", tb.name);
441			// Disallowed start (dot).
442			assert!(tb.mgr.try_lock(".abc").await.is_none(), "{}: leading dot", tb.name);
443			// Disallowed start (underscore).
444			assert!(tb.mgr.try_lock("_abc").await.is_none(), "{}: leading underscore", tb.name);
445			// Disallowed end (dash).
446			assert!(tb.mgr.try_lock("abc-").await.is_none(), "{}: trailing dash", tb.name);
447			// Disallowed end (dot).
448			assert!(tb.mgr.try_lock("abc.").await.is_none(), "{}: trailing dot", tb.name);
449			// Path-traversal sentinels.
450			assert!(tb.mgr.try_lock(".").await.is_none(), "{}: dot", tb.name);
451			assert!(tb.mgr.try_lock("..").await.is_none(), "{}: dotdot", tb.name);
452
453			// Allowed: bark's actual key shapes.
454			assert!(tb.mgr.try_lock("bark.lightning.send.42").await.is_some(),
455				"{}: bark.lightning.send.42 should be valid", tb.name);
456			// Allowed: digit start (hex wallet fingerprint).
457			assert!(tb.mgr.try_lock("01abcdef.round.7").await.is_some(),
458				"{}: 01abcdef.round.7 should be valid", tb.name);
459		}
460	}
461
462	#[test]
463	fn managers_covers_every_compiled_backend() {
464		// If a backend is dropped from `managers()`, this assertion goes red.
465		let names: Vec<_> = managers().iter().map(|tb| tb.name).collect();
466		assert!(names.contains(&"Memory"), "missing Memory: {:?}", names);
467		#[cfg(target_arch = "wasm32")]
468		assert!(names.contains(&"Web"), "missing Web: {:?}", names);
469	}
470
471	#[tokio::test]
472	async fn platform_default_returns_a_working_manager() {
473		let dir = tmp_dir();
474		let mgr = super::platform_default(Some(&dir), None)
475			.expect("platform_default should construct a manager");
476		let g = mgr.try_lock("bark.platform.default.test").await;
477		assert!(g.is_some(), "platform_default's manager should grant a fresh lock");
478		drop(g);
479		let _ = fs::remove_dir_all(&dir);
480	}
481}