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