Skip to main content

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}