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}