instance.rs raw

//! 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<Option<Self>> {
        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);
    }
}