use std::collections::HashMap;
use std::fmt;
use std::io;
use std::sync::{Arc, Condvar, LazyLock, Mutex, Weak};
#[cfg(unix)]
use std::path::{Path, PathBuf};
mod error;
mod sys;
pub use error::{Error, Result};
#[cfg(unix)]
type Key = PathBuf;
#[cfg(windows)]
type Key = String;
#[cfg(unix)]
fn key_from_name(name: &str) -> Key {
std::env::var_os("TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(format!("{name}.lock"))
}
#[cfg(windows)]
fn key_from_name(name: &str) -> Key {
format!("Global\\{name}")
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidName);
}
if name.bytes().any(|b| matches!(b, b'\0' | b'/' | b'\\')) {
return Err(Error::InvalidName);
}
Ok(())
}
struct LockState {
os: sys::OsLock,
held: Mutex<bool>,
released: Condvar,
}
impl fmt::Debug for LockState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LockState")
.field("os", &self.os)
.finish_non_exhaustive()
}
}
static REGISTRY: LazyLock<Mutex<HashMap<Key, Weak<LockState>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn registry_get_or_create(
key: Key,
create: impl FnOnce(&Key) -> io::Result<sys::OsLock>,
) -> Result<Arc<LockState>> {
let mut map = REGISTRY.lock().unwrap_or_else(|e| e.into_inner());
if let Some(state) = map.get(&key).and_then(Weak::upgrade) {
return Ok(state);
}
let os = create(&key).map_err(Error::Io)?;
let state = Arc::new(LockState {
os,
held: Mutex::new(false),
released: Condvar::new(),
});
map.insert(key, Arc::downgrade(&state));
Ok(state)
}
#[derive(Clone, Debug)]
pub struct Lock {
state: Arc<LockState>,
}
impl Lock {
pub fn new(name: &str) -> Result<Self> {
validate_name(name)?;
let key = key_from_name(name);
let state = registry_get_or_create(key, |k| sys::OsLock::open(k))?;
Ok(Lock { state })
}
#[cfg(unix)]
#[cfg_attr(docsrs, doc(cfg(unix)))]
pub fn with_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let key: PathBuf = path.as_ref().to_owned();
let state = registry_get_or_create(key, |p| sys::OsLock::open(p))?;
Ok(Lock { state })
}
pub fn lock(&self) -> Result<LockGuard> {
acquire(Arc::clone(&self.state), true)
}
pub fn try_lock(&self) -> Result<LockGuard> {
acquire(Arc::clone(&self.state), false)
}
}
fn acquire(state: Arc<LockState>, blocking: bool) -> Result<LockGuard> {
{
let mut held = state.held.lock().unwrap_or_else(|e| e.into_inner());
if blocking {
while *held {
held = state.released.wait(held).unwrap_or_else(|e| e.into_inner());
}
} else if *held {
return Err(Error::WouldBlock);
}
*held = true;
}
let os_result = if blocking {
state.os.lock()
} else {
state.os.try_lock()
};
match os_result {
Ok(()) => Ok(LockGuard { state }),
Err(e) => {
let mut held = state.held.lock().unwrap_or_else(|p| p.into_inner());
*held = false;
state.released.notify_one();
if e.kind() == io::ErrorKind::WouldBlock {
Err(Error::WouldBlock)
} else {
Err(Error::Io(e))
}
}
}
}
pub struct LockGuard {
state: Arc<LockState>,
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ = self.state.os.unlock();
let mut held = self.state.held.lock().unwrap_or_else(|e| e.into_inner());
*held = false;
self.state.released.notify_one();
}
}
impl fmt::Debug for LockGuard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LockGuard").finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[cfg(unix)]
use std::path::PathBuf;
use std::process::{Child, Command};
use std::thread;
use std::time::{Duration, Instant};
use uuid::Uuid;
fn random_name() -> String {
Uuid::new_v4().as_hyphenated().to_string()
}
fn spawn_subprocess(num: u32, uuid: &str) -> Child {
let exe = env::current_exe().expect("could not locate test binary");
Command::new(exe)
.env("IPC_LOCK_TEST_PROC", num.to_string())
.env("IPC_LOCK_TEST_UUID", uuid)
.arg("tests::cross_process")
.spawn()
.expect("failed to spawn subprocess")
}
#[test]
fn cross_process() -> Result<()> {
let proc_num: u32 = env::var("IPC_LOCK_TEST_PROC")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
let uuid = env::var("IPC_LOCK_TEST_UUID").unwrap_or_else(|_| random_name());
match proc_num {
0 => {
let mut h1 = spawn_subprocess(1, &uuid);
thread::sleep(Duration::from_millis(50));
let mut h2 = spawn_subprocess(2, &uuid);
let lock = Lock::new(&uuid)?;
let deadline = Instant::now() + Duration::from_secs(5);
let mut saw_would_block = false;
while Instant::now() < deadline {
if matches!(lock.try_lock(), Err(Error::WouldBlock)) {
saw_would_block = true;
break;
}
thread::sleep(Duration::from_millis(10));
}
assert!(
saw_would_block,
"expected WouldBlock while subprocess 1 holds the lock"
);
assert!(h1.wait().unwrap().success(), "subprocess 1 failed");
assert!(h2.wait().unwrap().success(), "subprocess 2 failed");
}
1 => {
let lock = Lock::new(&uuid)?;
let _guard = lock.lock()?;
thread::sleep(Duration::from_millis(500));
}
2 => {
let lock = Lock::new(&uuid)?;
assert!(matches!(lock.try_lock(), Err(Error::WouldBlock)));
let _guard = lock.lock()?;
thread::sleep(Duration::from_millis(50));
}
_ => unreachable!(),
}
Ok(())
}
#[test]
fn shared_state() -> Result<()> {
let name = random_name();
let a = Lock::new(&name)?;
let b = Lock::new(&name)?;
{
let _g = a.try_lock()?;
assert!(matches!(a.try_lock(), Err(Error::WouldBlock)));
assert!(matches!(b.try_lock(), Err(Error::WouldBlock)));
}
let _g = b.try_lock()?;
Ok(())
}
#[test]
fn clone_shares_state() -> Result<()> {
let name = random_name();
let original = Lock::new(&name)?;
let cloned = original.clone();
let guard = original.try_lock()?;
assert!(matches!(cloned.try_lock(), Err(Error::WouldBlock)));
drop(guard);
let _g = cloned.try_lock()?; Ok(())
}
#[test]
fn guard_outlives_lock() -> Result<()> {
let name = random_name();
let a = Lock::new(&name)?;
let b = Lock::new(&name)?;
let guard = a.try_lock()?;
assert!(matches!(b.try_lock(), Err(Error::WouldBlock)));
drop(a); assert!(
matches!(b.try_lock(), Err(Error::WouldBlock)),
"lock should still be held after Lock handle is dropped"
);
drop(guard); let _g = b.try_lock()?;
Ok(())
}
#[test]
fn thread_mutual_exclusion() -> Result<()> {
let name = random_name();
let lock = Lock::new(&name)?;
let lock2 = lock.clone();
let guard = lock.lock()?;
let handle = thread::spawn(move || -> Result<()> {
let _g = lock2.lock()?; Ok(())
});
thread::sleep(Duration::from_millis(50));
drop(guard);
handle
.join()
.expect("thread panicked")
.expect("thread returned error");
Ok(())
}
#[test]
fn try_lock_succeeds_when_free() -> Result<()> {
let name = random_name();
let lock = Lock::new(&name)?;
let _guard = lock.try_lock()?;
Ok(())
}
#[test]
fn distinct_names_are_independent() -> Result<()> {
let name_a = random_name();
let name_b = random_name();
let a = Lock::new(&name_a)?;
let b = Lock::new(&name_b)?;
let _guard_a = a.lock()?;
let _guard_b = b.lock()?; Ok(())
}
#[test]
fn concurrent_threads() -> Result<()> {
use std::sync::atomic::{AtomicUsize, Ordering};
let name = random_name();
let lock = Lock::new(&name)?;
let counter = Arc::new(AtomicUsize::new(0));
const THREADS: usize = 10;
const ITERATIONS: usize = 100;
let handles: Vec<_> = (0..THREADS)
.map(|_| {
let lock = lock.clone();
let counter = Arc::clone(&counter);
thread::spawn(move || -> Result<()> {
for _ in 0..ITERATIONS {
let _guard = lock.lock()?;
let prev = counter.load(Ordering::Relaxed);
counter.store(prev + 1, Ordering::Relaxed);
}
Ok(())
})
})
.collect();
for handle in handles {
handle
.join()
.expect("thread panicked")
.expect("thread returned error");
}
assert_eq!(
counter.load(Ordering::Relaxed),
THREADS * ITERATIONS,
"atomic counter should match total increments"
);
Ok(())
}
#[test]
fn heavy_contention() -> Result<()> {
use std::sync::atomic::{AtomicUsize, Ordering};
let name = random_name();
let lock = Lock::new(&name)?;
let counter = Arc::new(AtomicUsize::new(0));
const THREADS: usize = 32;
const ITERATIONS: usize = 1_000;
let handles: Vec<_> = (0..THREADS)
.map(|_| {
let lock = lock.clone();
let counter = Arc::clone(&counter);
thread::spawn(move || -> Result<()> {
for _ in 0..ITERATIONS {
let _guard = lock.lock()?;
let prev = counter.load(Ordering::Relaxed);
counter.store(prev + 1, Ordering::Relaxed);
}
Ok(())
})
})
.collect();
for handle in handles {
handle
.join()
.expect("thread panicked")
.expect("thread returned error");
}
assert_eq!(
counter.load(Ordering::Relaxed),
THREADS * ITERATIONS,
"counter should equal total increments after heavy contention"
);
Ok(())
}
#[test]
fn try_lock_fails_while_held_by_same_thread() -> Result<()> {
let name = random_name();
let lock = Lock::new(&name)?;
let _guard = lock.lock()?;
assert!(
matches!(lock.try_lock(), Err(Error::WouldBlock)),
"same-thread re-entry should be rejected"
);
Ok(())
}
#[test]
fn lock_blocks_until_released() -> Result<()> {
let name = random_name();
let lock = Lock::new(&name)?;
let lock2 = lock.clone();
let guard = lock.lock()?;
let start = Instant::now();
let handle = thread::spawn(move || -> Result<Instant> {
let _g = lock2.lock()?; Ok(Instant::now())
});
thread::sleep(Duration::from_millis(50));
drop(guard);
let acquired_after = handle
.join()
.expect("thread panicked")
.expect("thread returned error");
assert!(
acquired_after >= start + Duration::from_millis(50),
"second thread should have blocked until the guard was dropped"
);
Ok(())
}
#[test]
fn invalid_names() {
for bad in ["", "a/b", "a\\b", "a\0b"] {
assert!(
matches!(Lock::new(bad), Err(Error::InvalidName)),
"expected InvalidName for {bad:?}"
);
}
}
#[test]
fn valid_names() -> Result<()> {
for good in ["my app", "my-app", "my_app", "my.app", "123"] {
let lock = Lock::new(good)?;
let _guard = lock.try_lock()?;
}
Ok(())
}
#[test]
fn error_display() {
assert_eq!(
Error::InvalidName.to_string(),
"invalid lock name: must be non-empty and contain no '\\0', '/', or '\\'"
);
assert_eq!(
Error::WouldBlock.to_string(),
"lock is currently held by another thread or process"
);
assert!(
Error::Io(io::Error::new(io::ErrorKind::Other, "boom"))
.to_string()
.contains("I/O error"),
"Io error should mention I/O"
);
}
#[test]
fn error_source() {
use std::error::Error as StdError;
let io_err = io::Error::new(io::ErrorKind::Other, "boom");
let err = Error::Io(io_err);
assert!(
StdError::source(&err).is_some(),
"Io error should have a source"
);
assert!(StdError::source(&Error::InvalidName).is_none());
assert!(StdError::source(&Error::WouldBlock).is_none());
}
fn assert_send_sync<T: Send + Sync>() {}
fn assert_clone_debug<T: Clone + std::fmt::Debug>() {}
#[test]
fn trait_bounds() {
assert_send_sync::<Lock>();
assert_send_sync::<LockGuard>();
assert_clone_debug::<Lock>();
}
#[cfg(unix)]
#[test]
fn unix_with_path() -> Result<()> {
let path = std::env::temp_dir().join(format!("ipc-lock-test-{}", random_name()));
let a = Lock::with_path(&path)?;
let b = Lock::with_path(&path)?;
let _guard = a.try_lock()?;
assert!(
matches!(b.try_lock(), Err(Error::WouldBlock)),
"two handles for the same path should share state"
);
let _ = std::fs::remove_file(&path);
Ok(())
}
#[cfg(unix)]
#[test]
fn unix_lock_file_created() -> Result<()> {
let name = random_name();
let expected_path = std::env::var_os("TMPDIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(format!("{name}.lock"));
let lock = Lock::new(&name)?;
assert!(
expected_path.exists(),
"lock file should be created at {expected_path:?}"
);
{
let _guard = lock.try_lock()?;
}
assert!(
expected_path.exists(),
"lock file should remain after the guard is dropped"
);
let _ = std::fs::remove_file(&expected_path);
Ok(())
}
}