//! Single-instance guard. //! //! liftoff is a transient overlay: pressing the launch key again while it is //! already open should do nothing, not stack a second copy. We enforce that with //! an advisory file lock ([`flock`]) on a lock file in the user's runtime //! directory. Taking the lock is a single non-blocking syscall, and the kernel //! releases it automatically when the process exits — even on a crash or kill — //! so there is no stale-lock state to detect or clean up. //! //! [`flock`]: https://man7.org/linux/man-pages/man2/flock.2.html use std::fs::{File, OpenOptions}; use std::io; use std::os::fd::AsRawFd; use std::path::{Path, PathBuf}; /// A held single-instance lock. While this value is alive the process owns the /// lock; dropping it (or the process exiting) releases the underlying `flock`. pub struct InstanceLock { // The lock is bound to this open file description and lasts exactly as long // as the descriptor stays open. We never read or write the file; holding the // handle is the whole point, so the field is intentionally unused. _file: File, } impl InstanceLock { /// Try to become the sole running instance by taking an exclusive, /// non-blocking lock on `path`. /// /// Returns `Ok(Some(lock))` when we acquired it, `Ok(None)` when another /// instance already holds it, and `Err` only if the lock file itself could /// not be opened. pub fn acquire(path: &Path) -> io::Result> { let file = OpenOptions::new().create(true).write(true).open(path)?; // LOCK_EX takes an exclusive lock; LOCK_NB makes the call fail fast with // EWOULDBLOCK instead of blocking when another instance holds it. let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; if rc == 0 { return Ok(Some(Self { _file: file })); } let err = io::Error::last_os_error(); match err.raw_os_error() { Some(libc::EWOULDBLOCK) => Ok(None), _ => Err(err), } } } /// The default lock-file location: `liftoff.lock` inside `$XDG_RUNTIME_DIR`. /// /// That directory is per-user, session-scoped, and typically on tmpfs — exactly /// where a transient runtime lock belongs. If it is unset (which should not /// happen inside a Wayland session, since the display socket lives there too) we /// fall back to the system temp directory with a uid-qualified name so instances /// of different users cannot collide on a shared `/tmp`. pub fn default_lock_path() -> PathBuf { if let Some(dir) = std::env::var_os("XDG_RUNTIME_DIR") { return PathBuf::from(dir).join("liftoff.lock"); } let uid = unsafe { libc::getuid() }; std::env::temp_dir().join(format!("liftoff-{uid}.lock")) } #[cfg(test)] mod tests { use super::*; /// A second acquisition of the same lock file, while the first is still /// held, reports that another instance owns it. flock locks are per open /// file description, so two separate opens contend even within one process. #[test] fn second_acquire_is_blocked_while_first_is_held() { let path = std::env::temp_dir() .join(format!("liftoff-test-blocked-{}.lock", std::process::id())); let first = InstanceLock::acquire(&path) .expect("opening the lock file should succeed") .expect("the first instance should acquire the lock"); let second = InstanceLock::acquire(&path).expect("opening the lock file should succeed"); assert!( second.is_none(), "a second instance must not acquire the lock while the first holds it" ); drop(first); let _ = std::fs::remove_file(&path); } /// Once the holder is dropped the lock is released, so a fresh acquisition /// succeeds again. #[test] fn acquire_succeeds_again_after_release() { let path = std::env::temp_dir() .join(format!("liftoff-test-release-{}.lock", std::process::id())); let first = InstanceLock::acquire(&path) .expect("opening the lock file should succeed") .expect("the first instance should acquire the lock"); drop(first); let second = InstanceLock::acquire(&path) .expect("opening the lock file should succeed") .expect("the lock should be available again after release"); drop(second); let _ = std::fs::remove_file(&path); } }