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