ipc_lock/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Cross-process named locks.
4//!
5//! `ipc-lock` provides mutual exclusion that works across **both threads and
6//! processes** on the same machine.
7//!
8//! # How it works
9//!
10//! Two locking layers work together:
11//!
12//! 1. **OS-level** — keeps different *processes* out.
13//! - Unix: `flock(2)` via [`std::fs::File::lock`] on a file under `$TMPDIR`.
14//! - Windows: a `Global\` named kernel mutex via `CreateMutexW`.
15//! If the previous owner terminates without releasing the mutex, the
16//! next waiter acquires it successfully but [`LockGuard::is_abandoned`]
17//! returns `true`.
18//!
19//! 2. **Thread-level** — keeps different threads in the same process from
20//! entering concurrently, because `flock` and `CreateMutexW` are
21//! process-granular primitives that allow re-entry from the same process
22//! without blocking. Implemented with a [`Mutex<bool>`] gate and a [`Condvar`].
23//!
24//! # Example
25//!
26//! ```rust,no_run
27//! use ipc_lock::{Lock, Result};
28//!
29//! fn main() -> Result<()> {
30//! let lock = Lock::new("my-app")?;
31//! let _guard = lock.lock()?; // blocks until available
32//! // critical section …
33//! Ok(()) // _guard dropped → lock released
34//! }
35//! ```
36//!
37//! `Lock` is cheap to clone — all clones share the same underlying state.
38//!
39//! ```rust,no_run
40//! # use ipc_lock::{Lock, Result};
41//! # fn main() -> Result<()> {
42//! let lock = Lock::new("shared")?;
43//! let other = lock.clone(); // cheap Arc clone
44//! # Ok(())
45//! # }
46//! ```
47
48use std::collections::HashMap;
49use std::fmt;
50use std::io;
51use std::sync::{Arc, Condvar, LazyLock, Mutex, Weak};
52
53#[cfg(unix)]
54use std::path::{Path, PathBuf};
55
56mod error;
57mod sys;
58
59pub use error::{Error, Result};
60
61// ── Platform key type ─────────────────────────────────────────────────────────
62//
63// The registry key is the canonical OS-level identifier for the lock.
64// On Unix it is the full path to the lock file; on Windows the mutex name.
65
66#[cfg(unix)]
67type Key = PathBuf;
68#[cfg(windows)]
69type Key = String;
70
71/// Derive the OS-level key from a user-supplied name.
72#[cfg(unix)]
73fn key_from_name(name: &str) -> Key {
74 std::env::var_os("TMPDIR")
75 .map(PathBuf::from)
76 .unwrap_or_else(|| PathBuf::from("/tmp"))
77 .join(format!("{name}.lock"))
78}
79
80#[cfg(windows)]
81fn key_from_name(name: &str) -> Key {
82 format!("Global\\{name}")
83}
84
85// ── Validation ────────────────────────────────────────────────────────────────
86
87fn validate_name(name: &str) -> Result<()> {
88 if name.is_empty() {
89 return Err(Error::InvalidName);
90 }
91 // Null bytes break OS APIs on both platforms.
92 // Slashes are reserved (Unix path separator / Windows mutex namespace).
93 if name.bytes().any(|b| matches!(b, b'\0' | b'/' | b'\\')) {
94 return Err(Error::InvalidName);
95 }
96 Ok(())
97}
98
99// ── Internal shared state ─────────────────────────────────────────────────────
100
101/// Combined OS primitive + thread coordination for one named lock.
102struct LockState {
103 /// The underlying OS lock (file or named mutex).
104 os: sys::OsLock,
105 /// The canonical OS-level identifier for this lock. Kept here so
106 /// [`Lock::path`] can return it on Unix.
107 #[cfg(unix)]
108 key: Key,
109 /// `true` while a [`LockGuard`] for this state exists in this process.
110 held: Mutex<bool>,
111 /// Notified when `held` transitions from `true` to `false`.
112 released: Condvar,
113}
114
115impl fmt::Debug for LockState {
116 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117 f.debug_struct("LockState")
118 .field("os", &self.os)
119 .finish_non_exhaustive()
120 }
121}
122
123// ── Process-wide registry ─────────────────────────────────────────────────────
124//
125// Ensures that every `Lock` for the same key within a single process shares
126// the same `LockState`. A `Weak` reference is stored; when all `Lock` handles
127// and outstanding `LockGuard`s for a key are dropped the entry naturally
128// becomes dead and is recycled on the next `create` call for that key.
129
130static REGISTRY: LazyLock<Mutex<HashMap<Key, Weak<LockState>>>> =
131 LazyLock::new(|| Mutex::new(HashMap::new()));
132
133/// Return an existing live `LockState` for `key`, or create a new one by
134/// calling `create`.
135///
136/// `create` receives a reference to `key` so it can use the key value without
137/// a clone — ownership of `key` is transferred to the registry on insertion.
138fn registry_get_or_create(
139 key: Key,
140 create: impl FnOnce(&Key) -> io::Result<sys::OsLock>,
141) -> Result<Arc<LockState>> {
142 let mut map = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
143
144 // Fast path: a live state already exists.
145 if let Some(state) = map.get(&key).and_then(Weak::upgrade) {
146 return Ok(state);
147 }
148
149 // Slow path: open the OS primitive and mint a new state.
150 let os = create(&key).map_err(Error::Io)?;
151 let state = Arc::new(LockState {
152 os,
153 #[cfg(unix)]
154 key: key.clone(),
155 held: Mutex::new(false),
156 released: Condvar::new(),
157 });
158 map.insert(key, Arc::downgrade(&state));
159 Ok(state)
160}
161
162// ── Lock ──────────────────────────────────────────────────────────────────────
163
164/// A cross-process named lock.
165///
166/// `Lock` is a lightweight handle backed by an [`Arc`]; cloning it is O(1)
167/// and all clones share the same underlying state — including the
168/// process-level mutual-exclusion guarantee.
169///
170/// # Name rules
171///
172/// * Must not be empty.
173/// * Must not contain `\0`, `/`, or `\`.
174#[derive(Clone, Debug)]
175pub struct Lock {
176 state: Arc<LockState>,
177}
178
179impl Lock {
180 /// Open (or create) a named lock identified by `name`.
181 ///
182 /// # Platform behaviour
183 ///
184 /// * **Unix** — creates/opens `$TMPDIR/<name>.lock` (falls back to
185 /// `/tmp/<name>.lock` when `TMPDIR` is unset).
186 /// * **Windows** — creates/opens a kernel mutex named `Global\<name>`.
187 ///
188 /// # Errors
189 ///
190 /// Returns [`Error::InvalidName`] for illegal names, or [`Error::Io`] if
191 /// the OS operation fails.
192 pub fn new(name: &str) -> Result<Self> {
193 validate_name(name)?;
194 let key = key_from_name(name);
195 // `key` is only borrowed by the closure. On the fast path an existing
196 // live state is returned and `key` remains owned by this function; on
197 // the slow path the closure borrows it and ownership then moves into
198 // the registry.
199 let state = registry_get_or_create(key, |k| sys::OsLock::open(k))?;
200 Ok(Lock { state })
201 }
202
203 /// Open (or create) a named lock at an explicit filesystem path.
204 ///
205 /// Unlike [`Lock::new`], no `.lock` suffix is appended and the location
206 /// is not constrained to `$TMPDIR`. Parent directories must already exist.
207 ///
208 /// # Errors
209 ///
210 /// Returns [`Error::Io`] if the path cannot be opened or created.
211 #[cfg(unix)]
212 #[cfg_attr(docsrs, doc(cfg(unix)))]
213 pub fn with_path<P: AsRef<Path>>(path: P) -> Result<Self> {
214 let key: PathBuf = path.as_ref().to_owned();
215 let state = registry_get_or_create(key, |p| sys::OsLock::open(p))?;
216 Ok(Lock { state })
217 }
218
219 /// Return the filesystem path of the backing lock file (Unix only).
220 ///
221 /// This is the path used by [`Lock::new`] or [`Lock::with_path`]. It can be
222 /// used by callers to clean up the lock file when they know it is safe to do
223 /// so. The library itself intentionally leaves the file in place; deleting it
224 /// while another process may still be using the lock can break mutual
225 /// exclusion.
226 #[cfg(unix)]
227 #[cfg_attr(docsrs, doc(cfg(unix)))]
228 pub fn path(&self) -> &Path {
229 &self.state.key
230 }
231
232 /// Acquire the lock, **blocking** until it is available.
233 ///
234 /// Returns a [`LockGuard`] that releases the lock when dropped.
235 ///
236 /// # Errors
237 ///
238 /// Returns [`Error::Io`] if the underlying OS call fails.
239 pub fn lock(&self) -> Result<LockGuard> {
240 acquire(Arc::clone(&self.state), true)
241 }
242
243 /// Try to acquire the lock **without blocking**.
244 ///
245 /// Returns a [`LockGuard`] if the lock is free, or
246 /// [`Error::WouldBlock`] if it is currently held.
247 ///
248 /// # Errors
249 ///
250 /// Returns [`Error::WouldBlock`] when the lock is held, or [`Error::Io`]
251 /// for any other OS-level failure.
252 pub fn try_lock(&self) -> Result<LockGuard> {
253 acquire(Arc::clone(&self.state), false)
254 }
255}
256
257// ── Acquire helper ────────────────────────────────────────────────────────────
258
259/// Core acquire logic shared by [`Lock::lock`] and [`Lock::try_lock`].
260///
261/// When `blocking` is `true` this function waits indefinitely; when `false`
262/// it returns [`Error::WouldBlock`] immediately if either layer is busy.
263fn acquire(state: Arc<LockState>, blocking: bool) -> Result<LockGuard> {
264 // ── Layer 1: thread gate ──────────────────────────────────────────────────
265 //
266 // Claim `held` before touching the OS primitive. This prevents two
267 // threads in the same process from both entering `os.lock()`.
268 {
269 let mut held = state.held.lock().unwrap_or_else(|e| e.into_inner());
270 if blocking {
271 while *held {
272 held = state.released.wait(held).unwrap_or_else(|e| e.into_inner());
273 }
274 } else if *held {
275 return Err(Error::WouldBlock);
276 }
277 *held = true;
278 // Intentionally drop the MutexGuard here. `held == true` is now the
279 // logical claim; the actual OS lock is acquired below.
280 }
281
282 // ── Layer 2: OS lock ──────────────────────────────────────────────────────
283 let os_result = if blocking {
284 state.os.lock()
285 } else {
286 state.os.try_lock()
287 };
288
289 match os_result {
290 Ok(acquisition) => Ok(LockGuard {
291 state,
292 abandoned: acquisition == sys::LockAcquisition::Abandoned,
293 }),
294
295 Err(e) => {
296 // Release the thread gate so waiting threads can retry.
297 let mut held = state.held.lock().unwrap_or_else(|p| p.into_inner());
298 *held = false;
299 state.released.notify_one();
300
301 if e.kind() == io::ErrorKind::WouldBlock {
302 Err(Error::WouldBlock)
303 } else {
304 Err(Error::Io(e))
305 }
306 }
307 }
308}
309
310// ── LockGuard ─────────────────────────────────────────────────────────────────
311
312/// RAII guard returned by [`Lock::lock`] and [`Lock::try_lock`].
313///
314/// Releases the lock — both the OS primitive and the thread gate — when
315/// dropped. The guard keeps the [`Lock`]'s backing state alive, so it is
316/// safe to drop the originating `Lock` while the guard is still live.
317///
318/// On Windows, if the previous mutex owner terminated without releasing the
319/// lock, the waiting acquisition succeeds but [`LockGuard::is_abandoned`]
320/// returns `true`. This can indicate that shared state protected by the lock
321/// may be inconsistent. On Unix this method always returns `false`.
322pub struct LockGuard {
323 state: Arc<LockState>,
324 abandoned: bool,
325}
326
327impl LockGuard {
328 /// Returns `true` if the lock was acquired from an abandoned owner.
329 ///
330 /// This only happens on Windows when the previous owner of the named
331 /// kernel mutex terminated without releasing the mutex. It signals that
332 /// any shared state protected by the lock may be in an inconsistent
333 /// state and should be inspected before reuse.
334 ///
335 /// On Unix this method always returns `false`.
336 pub fn is_abandoned(&self) -> bool {
337 self.abandoned
338 }
339}
340
341impl Drop for LockGuard {
342 fn drop(&mut self) {
343 // Release in reverse order of acquisition.
344 // Step 1: release the cross-process OS lock.
345 let _ = self.state.os.unlock();
346
347 // Step 2: release the thread gate and wake one waiting thread.
348 let mut held = self.state.held.lock().unwrap_or_else(|e| e.into_inner());
349 *held = false;
350 self.state.released.notify_one();
351 }
352}
353
354impl fmt::Debug for LockGuard {
355 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356 f.debug_struct("LockGuard")
357 .field("abandoned", &self.abandoned)
358 .finish_non_exhaustive()
359 }
360}
361
362// ── Tests ─────────────────────────────────────────────────────────────────────
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use std::env;
368 #[cfg(unix)]
369 use std::path::PathBuf;
370 use std::process::{Child, Command};
371 use std::thread;
372 use std::time::{Duration, Instant};
373 use uuid::Uuid;
374
375 fn random_name() -> String {
376 Uuid::new_v4().as_hyphenated().to_string()
377 }
378
379 fn spawn_subprocess(num: u32, uuid: &str) -> Child {
380 let exe = env::current_exe().expect("could not locate test binary");
381 Command::new(exe)
382 .env("IPC_LOCK_TEST_PROC", num.to_string())
383 .env("IPC_LOCK_TEST_UUID", uuid)
384 .arg("tests::cross_process")
385 .spawn()
386 .expect("failed to spawn subprocess")
387 }
388
389 // ── cross-process ─────────────────────────────────────────────────────────
390
391 /// Orchestrates a three-process mutual-exclusion test:
392 ///
393 /// * Subprocess 1 holds the lock for a short period.
394 /// * Subprocess 2 asserts `try_lock` fails, then waits for the lock.
395 /// * The main process confirms both subprocesses exited successfully.
396 ///
397 /// The orchestrator polls instead of relying on exact sleep timings, so the
398 /// test remains stable under CI load on all platforms.
399 #[test]
400 fn cross_process() -> Result<()> {
401 let proc_num: u32 = env::var("IPC_LOCK_TEST_PROC")
402 .ok()
403 .and_then(|v| v.parse().ok())
404 .unwrap_or(0);
405 let uuid = env::var("IPC_LOCK_TEST_UUID").unwrap_or_else(|_| random_name());
406
407 match proc_num {
408 0 => {
409 // Orchestrator
410 let mut h1 = spawn_subprocess(1, &uuid);
411 thread::sleep(Duration::from_millis(50));
412 let mut h2 = spawn_subprocess(2, &uuid);
413
414 // Wait until subprocess 1 has actually acquired the OS lock.
415 // Polling avoids fragile timing assumptions across platforms.
416 let lock = Lock::new(&uuid)?;
417 let deadline = Instant::now() + Duration::from_secs(5);
418 let mut saw_would_block = false;
419 while Instant::now() < deadline {
420 if matches!(lock.try_lock(), Err(Error::WouldBlock)) {
421 saw_would_block = true;
422 break;
423 }
424 thread::sleep(Duration::from_millis(10));
425 }
426 assert!(
427 saw_would_block,
428 "expected WouldBlock while subprocess 1 holds the lock"
429 );
430
431 assert!(h1.wait().unwrap().success(), "subprocess 1 failed");
432 assert!(h2.wait().unwrap().success(), "subprocess 2 failed");
433 }
434
435 1 => {
436 // Holds the lock long enough for the orchestrator to observe it.
437 let lock = Lock::new(&uuid)?;
438 let _guard = lock.lock()?;
439 thread::sleep(Duration::from_millis(500));
440 }
441
442 2 => {
443 // Verifies WouldBlock, then waits for the lock.
444 let lock = Lock::new(&uuid)?;
445 assert!(matches!(lock.try_lock(), Err(Error::WouldBlock)));
446 let _guard = lock.lock()?;
447 thread::sleep(Duration::from_millis(50));
448 }
449
450 _ => unreachable!(),
451 }
452
453 Ok(())
454 }
455
456 // ── same-process edge cases ───────────────────────────────────────────────
457
458 /// Two handles for the same name share one `LockState`; holding via one
459 /// blocks the other.
460 #[test]
461 fn shared_state() -> Result<()> {
462 let name = random_name();
463 let a = Lock::new(&name)?;
464 let b = Lock::new(&name)?;
465
466 {
467 let _g = a.try_lock()?;
468 assert!(matches!(a.try_lock(), Err(Error::WouldBlock)));
469 assert!(matches!(b.try_lock(), Err(Error::WouldBlock)));
470 }
471 // After the guard drops both handles should be acquirable again.
472 let _g = b.try_lock()?;
473 Ok(())
474 }
475
476 /// Cloning a `Lock` yields another handle to the same state.
477 #[test]
478 fn clone_shares_state() -> Result<()> {
479 let name = random_name();
480 let original = Lock::new(&name)?;
481 let cloned = original.clone();
482
483 let guard = original.try_lock()?;
484 assert!(matches!(cloned.try_lock(), Err(Error::WouldBlock)));
485 drop(guard);
486 let _g = cloned.try_lock()?; // now acquirable
487 Ok(())
488 }
489
490 /// The guard keeps the lock alive even after the originating `Lock` is
491 /// dropped.
492 #[test]
493 fn guard_outlives_lock() -> Result<()> {
494 let name = random_name();
495 let a = Lock::new(&name)?;
496 let b = Lock::new(&name)?;
497
498 let guard = a.try_lock()?;
499 assert!(matches!(b.try_lock(), Err(Error::WouldBlock)));
500
501 drop(a); // drop the handle — NOT the guard
502 assert!(
503 matches!(b.try_lock(), Err(Error::WouldBlock)),
504 "lock should still be held after Lock handle is dropped"
505 );
506
507 drop(guard); // now the guard releases
508 let _g = b.try_lock()?;
509 Ok(())
510 }
511
512 /// A second thread in the same process is properly blocked and then woken.
513 #[test]
514 fn thread_mutual_exclusion() -> Result<()> {
515 let name = random_name();
516 let lock = Lock::new(&name)?;
517 let lock2 = lock.clone();
518
519 let guard = lock.lock()?;
520
521 // Spawn a thread that will block on `lock2.lock()`.
522 let handle = thread::spawn(move || -> Result<()> {
523 let _g = lock2.lock()?; // blocks until main thread drops guard
524 Ok(())
525 });
526
527 thread::sleep(Duration::from_millis(50));
528 drop(guard); // wake the spawned thread
529
530 handle
531 .join()
532 .expect("thread panicked")
533 .expect("thread returned error");
534 Ok(())
535 }
536
537 /// `try_lock` succeeds immediately when the lock is free.
538 #[test]
539 fn try_lock_succeeds_when_free() -> Result<()> {
540 let name = random_name();
541 let lock = Lock::new(&name)?;
542 let _guard = lock.try_lock()?;
543 Ok(())
544 }
545
546 /// Locks with different names are independent of each other.
547 #[test]
548 fn distinct_names_are_independent() -> Result<()> {
549 let name_a = random_name();
550 let name_b = random_name();
551
552 let a = Lock::new(&name_a)?;
553 let b = Lock::new(&name_b)?;
554
555 let _guard_a = a.lock()?;
556 let _guard_b = b.lock()?; // should not block, different OS keys
557 Ok(())
558 }
559
560 /// Many threads competing for the same lock must be mutually exclusive.
561 #[test]
562 fn concurrent_threads() -> Result<()> {
563 use std::sync::atomic::{AtomicUsize, Ordering};
564
565 let name = random_name();
566 let lock = Lock::new(&name)?;
567 let counter = Arc::new(AtomicUsize::new(0));
568 const THREADS: usize = 10;
569 const ITERATIONS: usize = 100;
570
571 let handles: Vec<_> = (0..THREADS)
572 .map(|_| {
573 let lock = lock.clone();
574 let counter = Arc::clone(&counter);
575 thread::spawn(move || -> Result<()> {
576 for _ in 0..ITERATIONS {
577 let _guard = lock.lock()?;
578 // Increment while holding the lock to guarantee
579 // no lost updates.
580 let prev = counter.load(Ordering::Relaxed);
581 counter.store(prev + 1, Ordering::Relaxed);
582 }
583 Ok(())
584 })
585 })
586 .collect();
587
588 for handle in handles {
589 handle
590 .join()
591 .expect("thread panicked")
592 .expect("thread returned error");
593 }
594
595 assert_eq!(
596 counter.load(Ordering::Relaxed),
597 THREADS * ITERATIONS,
598 "atomic counter should match total increments"
599 );
600 Ok(())
601 }
602
603 /// Heavier contention test: many threads repeatedly acquiring and releasing the
604 /// lock with no sleep. This is a regression guard against deadlocks or lost
605 /// wake-ups in the thread-gate / Condvar logic.
606 #[test]
607 fn heavy_contention() -> Result<()> {
608 use std::sync::atomic::{AtomicUsize, Ordering};
609
610 let name = random_name();
611 let lock = Lock::new(&name)?;
612 let counter = Arc::new(AtomicUsize::new(0));
613 const THREADS: usize = 32;
614 const ITERATIONS: usize = 1_000;
615
616 let handles: Vec<_> = (0..THREADS)
617 .map(|_| {
618 let lock = lock.clone();
619 let counter = Arc::clone(&counter);
620 thread::spawn(move || -> Result<()> {
621 for _ in 0..ITERATIONS {
622 let _guard = lock.lock()?;
623 let prev = counter.load(Ordering::Relaxed);
624 counter.store(prev + 1, Ordering::Relaxed);
625 }
626 Ok(())
627 })
628 })
629 .collect();
630
631 for handle in handles {
632 handle
633 .join()
634 .expect("thread panicked")
635 .expect("thread returned error");
636 }
637
638 assert_eq!(
639 counter.load(Ordering::Relaxed),
640 THREADS * ITERATIONS,
641 "counter should equal total increments after heavy contention"
642 );
643 Ok(())
644 }
645
646 /// A second `try_lock` from the same thread while a guard is live must fail
647 /// with `WouldBlock`. `Lock` is not re-entrant.
648 #[test]
649 fn try_lock_fails_while_held_by_same_thread() -> Result<()> {
650 let name = random_name();
651 let lock = Lock::new(&name)?;
652
653 let _guard = lock.lock()?;
654 assert!(
655 matches!(lock.try_lock(), Err(Error::WouldBlock)),
656 "same-thread re-entry should be rejected"
657 );
658 Ok(())
659 }
660
661 /// `lock()` blocks until the current holder releases the guard.
662 #[test]
663 fn lock_blocks_until_released() -> Result<()> {
664 let name = random_name();
665 let lock = Lock::new(&name)?;
666 let lock2 = lock.clone();
667
668 let guard = lock.lock()?;
669 let start = Instant::now();
670
671 let handle = thread::spawn(move || -> Result<Instant> {
672 let _g = lock2.lock()?; // blocks
673 Ok(Instant::now())
674 });
675
676 // Give the spawned thread time to start waiting.
677 thread::sleep(Duration::from_millis(50));
678 drop(guard);
679
680 let acquired_after = handle
681 .join()
682 .expect("thread panicked")
683 .expect("thread returned error");
684
685 assert!(
686 acquired_after >= start + Duration::from_millis(50),
687 "second thread should have blocked until the guard was dropped"
688 );
689 Ok(())
690 }
691
692 // ── invalid names ─────────────────────────────────────────────────────────
693
694 #[test]
695 fn invalid_names() {
696 for bad in ["", "a/b", "a\\b", "a\0b"] {
697 assert!(
698 matches!(Lock::new(bad), Err(Error::InvalidName)),
699 "expected InvalidName for {bad:?}"
700 );
701 }
702 }
703
704 /// Names containing spaces, dots, dashes, or underscores are accepted.
705 #[test]
706 fn valid_names() -> Result<()> {
707 for good in ["my app", "my-app", "my_app", "my.app", "123"] {
708 let lock = Lock::new(good)?;
709 let _guard = lock.try_lock()?;
710 }
711 Ok(())
712 }
713
714 // ── error display ─────────────────────────────────────────────────────────
715
716 #[test]
717 fn error_display() {
718 assert_eq!(
719 Error::InvalidName.to_string(),
720 "invalid lock name: must be non-empty and contain no '\\0', '/', or '\\'"
721 );
722 assert_eq!(
723 Error::WouldBlock.to_string(),
724 "lock is currently held by another thread or process"
725 );
726 assert!(
727 Error::Io(io::Error::new(io::ErrorKind::Other, "boom"))
728 .to_string()
729 .contains("I/O error"),
730 "Io error should mention I/O"
731 );
732 }
733
734 /// Verify the `std::error::Error::source` implementation.
735 #[test]
736 fn error_source() {
737 use std::error::Error as StdError;
738
739 let io_err = io::Error::new(io::ErrorKind::Other, "boom");
740 let err = Error::Io(io_err);
741 assert!(
742 StdError::source(&err).is_some(),
743 "Io error should have a source"
744 );
745 assert!(StdError::source(&Error::InvalidName).is_none());
746 assert!(StdError::source(&Error::WouldBlock).is_none());
747 }
748
749 // ── trait bounds ─────────────────────────────────────────────────────────
750
751 fn assert_send_sync<T: Send + Sync>() {}
752 fn assert_clone_debug<T: Clone + std::fmt::Debug>() {}
753
754 #[test]
755 fn trait_bounds() {
756 assert_send_sync::<Lock>();
757 assert_send_sync::<LockGuard>();
758 assert_clone_debug::<Lock>();
759 }
760
761 // ── platform-specific tests ───────────────────────────────────────────────
762
763 /// Unix-only: `Lock::with_path` uses the supplied filesystem path.
764 #[cfg(unix)]
765 #[test]
766 fn unix_with_path() -> Result<()> {
767 let path = std::env::temp_dir().join(format!("ipc-lock-test-{}", random_name()));
768 let a = Lock::with_path(&path)?;
769 let b = Lock::with_path(&path)?;
770
771 let _guard = a.try_lock()?;
772 assert!(
773 matches!(b.try_lock(), Err(Error::WouldBlock)),
774 "two handles for the same path should share state"
775 );
776
777 // Clean up the lock file; ignore errors if the OS already removed it.
778 let _ = std::fs::remove_file(&path);
779 Ok(())
780 }
781
782 /// Unix-only: `Lock::new` creates the backing lock file under `$TMPDIR`.
783 #[cfg(unix)]
784 #[test]
785 fn unix_lock_file_created() -> Result<()> {
786 let name = random_name();
787 let expected_path = std::env::var_os("TMPDIR")
788 .map(PathBuf::from)
789 .unwrap_or_else(|| PathBuf::from("/tmp"))
790 .join(format!("{name}.lock"));
791
792 let lock = Lock::new(&name)?;
793 assert!(
794 expected_path.exists(),
795 "lock file should be created at {expected_path:?}"
796 );
797
798 // The library intentionally leaves the lock file in place; holding and
799 // dropping the guard should not remove it.
800 {
801 let _guard = lock.try_lock()?;
802 }
803 assert!(
804 expected_path.exists(),
805 "lock file should remain after the guard is dropped"
806 );
807
808 let _ = std::fs::remove_file(&expected_path);
809 Ok(())
810 }
811
812 /// Unix-only: `Lock::path` returns the backing lock file path.
813 #[cfg(unix)]
814 #[test]
815 fn unix_lock_path() -> Result<()> {
816 let name = random_name();
817 let expected_path = std::env::var_os("TMPDIR")
818 .map(PathBuf::from)
819 .unwrap_or_else(|| PathBuf::from("/tmp"))
820 .join(format!("{name}.lock"));
821
822 let lock = Lock::new(&name)?;
823 assert_eq!(lock.path(), expected_path);
824
825 let custom_path =
826 std::env::temp_dir().join(format!("ipc-lock-path-test-{}", random_name()));
827 let lock_with_path = Lock::with_path(&custom_path)?;
828 assert_eq!(lock_with_path.path(), custom_path);
829
830 let _ = std::fs::remove_file(&expected_path);
831 let _ = std::fs::remove_file(&custom_path);
832 Ok(())
833 }
834
835 /// A normal acquisition is never reported as abandoned.
836 #[test]
837 fn abandoned_false_for_normal_lock() -> Result<()> {
838 let lock = Lock::new(&random_name())?;
839 let guard = lock.lock()?;
840 assert!(
841 !guard.is_abandoned(),
842 "normal acquisition should not be abandoned"
843 );
844 Ok(())
845 }
846
847 /// Windows-only: acquiring a mutex whose previous owner aborted reports
848 /// `is_abandoned() == true`.
849 #[cfg(windows)]
850 #[test]
851 fn windows_abandoned_lock() -> Result<()> {
852 let proc_num: u32 = env::var("IPC_LOCK_TEST_ABANDON_PROC")
853 .ok()
854 .and_then(|v| v.parse().ok())
855 .unwrap_or(0);
856 let uuid = env::var("IPC_LOCK_TEST_ABANDON_UUID").unwrap_or_else(|_| random_name());
857
858 match proc_num {
859 0 => {
860 // Parent: open the mutex first so it survives the child's death,
861 // then spawn the child, wait for it to abort, and re-acquire.
862 let _lock = Lock::new(&uuid)?;
863 let mut child = spawn_abandon_subprocess(&uuid);
864 let status = child.wait().expect("child wait failed");
865 assert!(
866 !status.success(),
867 "child process should have aborted without releasing the lock"
868 );
869
870 let lock = Lock::new(&uuid)?;
871 let guard = lock.lock()?;
872 assert!(
873 guard.is_abandoned(),
874 "expected abandoned mutex after child aborted"
875 );
876 }
877 1 => {
878 // Child: acquire the lock and abort without releasing.
879 let lock = Lock::new(&uuid).expect("child failed to create lock");
880 let _guard = lock.lock().expect("child failed to acquire lock");
881 std::process::abort();
882 }
883 _ => unreachable!(),
884 }
885
886 Ok(())
887 }
888
889 #[cfg(windows)]
890 fn spawn_abandon_subprocess(uuid: &str) -> Child {
891 let exe = env::current_exe().expect("could not locate test binary");
892 Command::new(exe)
893 .env("IPC_LOCK_TEST_ABANDON_PROC", "1")
894 .env("IPC_LOCK_TEST_ABANDON_UUID", uuid)
895 .arg("tests::windows_abandoned_lock")
896 .spawn()
897 .expect("failed to spawn abandon subprocess")
898 }
899}