// lockfile.rs --- Single-instance daemon lock via flock(2). //! Single-instance lockfile lifecycle for the M5.5 daemon (T M5.5d). //! //! # Why a sibling lockfile, not the socket //! //! `bind(2)` on a Unix socket fails with `EADDRINUSE` if the socket //! file already exists, even when no daemon is holding it (e.g. after //! a crashed daemon left a stale socket on disk). We could `unlink` //! before `bind`, but doing so without a lock invites a race: two //! daemons starting in lockstep, both unlink, both bind, only one //! wins, the loser sees its bind succeed but its socket is the one //! the winner already replaced. //! //! `flock(2)` on a sibling lockfile (`.lock`) gives us //! atomicity: only one daemon holds the exclusive flock at a time, so //! the unlink-then-bind sequence happens under serialization. The //! kernel releases the flock automatically when the file descriptor //! closes, including during a process crash, so a dead daemon's lock //! never blocks recovery. //! //! # Lifecycle //! //! 1. [`acquire_lock`] opens or creates `.lock` mode 0600, //! `flock(LOCK_EX | LOCK_NB)`, writes our pid (advisory) to the //! file, and returns a [`LockHandle`]. //! 2. The daemon does its unlink-stale-socket and bind work while //! holding the [`LockHandle`]. //! 3. On clean shutdown the daemon calls [`LockHandle::release`], //! which unlinks the lockfile while still holding the lock, then //! drops it (releasing the flock). //! 4. On crash, the kernel releases the flock; the lockfile remains //! on disk and gets reused (with an updated pid) by the next //! daemon. //! //! # Pid is advisory //! //! The pid in the lockfile is for diagnostic display only ("daemon //! already running at (pid 12345)"). We never make a decision //! based on it — the `flock` is the source of truth. A stale pid that //! happens to match a live unrelated process is harmless: we just //! print a slightly misleading error. use std::fs::{self, File, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::os::unix::fs::OpenOptionsExt; use std::path::{Path, PathBuf}; use nix::errno::Errno; use nix::fcntl::{Flock, FlockArg}; /// Errors from acquiring or releasing a daemon lockfile. #[derive(Debug)] pub enum LockError { /// Another process holds the lock. The advisory pid is the value /// the holder wrote to the file (best-effort; may be `None` if /// the file was empty or unreadable, or stale if the holder /// hadn't written its pid yet). AlreadyHeld { /// Pid the holder wrote into the lockfile, or `None` if no /// readable pid was present. advisory_pid: Option, }, /// I/O error opening, locking, or writing the lockfile. Io(std::io::Error), } impl std::fmt::Display for LockError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::AlreadyHeld { advisory_pid: Some(pid), } => { write!(f, "daemon already running (pid {pid})") } Self::AlreadyHeld { advisory_pid: None } => { write!(f, "daemon already running (pid unknown)") } Self::Io(e) => write!(f, "lockfile I/O error: {e}"), } } } impl std::error::Error for LockError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Io(e) => Some(e), Self::AlreadyHeld { .. } => None, } } } impl From for LockError { fn from(e: std::io::Error) -> Self { Self::Io(e) } } /// Sibling-lockfile path for a given socket path: `.lock`. #[must_use] pub fn lockfile_path_for(socket_path: &Path) -> PathBuf { let mut s = socket_path.as_os_str().to_os_string(); s.push(".lock"); PathBuf::from(s) } /// Holder for the exclusive flock on a daemon lockfile. /// /// The wrapped `Flock` releases the kernel-level lock when /// dropped (including during a panic or crash). Use [`Self::release`] /// for the clean-shutdown path which also unlinks the lockfile. #[derive(Debug)] pub struct LockHandle { // Held only for its Drop semantics: the kernel releases the // flock when the file descriptor closes. Underscored so dead-code // lints understand the field's purpose. _flock: Flock, lockfile_path: PathBuf, } impl LockHandle { /// The lockfile this handle owns. #[must_use] pub fn lockfile_path(&self) -> &Path { &self.lockfile_path } /// Clean-shutdown release: unlink the lockfile while we still /// hold the lock, then drop the handle (releasing the flock). /// /// Unlinking before lock release is deliberate: a racing acquirer /// who starts after the unlink but before our drop will simply /// re-create the lockfile on next [`acquire_lock`], proceeding /// once our drop releases the flock. There is no window in which /// they could acquire a lock the kernel doesn't recognize as /// ours. pub fn release(self) -> std::io::Result<()> { fs::remove_file(&self.lockfile_path) // self drops here, releasing the flock as the fd closes. } } /// Acquire the exclusive flock on `.lock`. /// /// Creates the lockfile mode 0600 if it doesn't exist. On success, /// truncates the file and writes our pid (advisory). On /// `EWOULDBLOCK`, reads the holder's advisory pid (best-effort) and /// returns [`LockError::AlreadyHeld`]. pub fn acquire_lock(socket_path: &Path) -> Result { let lockfile_path = lockfile_path_for(socket_path); let file = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .mode(0o600) .open(&lockfile_path)?; let mut flock = match Flock::lock(file, FlockArg::LockExclusiveNonblock) { Ok(f) => f, Err((f, Errno::EWOULDBLOCK)) => { let advisory_pid = read_advisory_pid(&f); return Err(LockError::AlreadyHeld { advisory_pid }); } Err((_f, errno)) => { return Err(LockError::Io(std::io::Error::from_raw_os_error( errno as i32, ))); } }; // We hold the lock. Refresh the advisory pid: truncate then // write. Failures here are unusual (we just opened the file // ourselves) but we propagate them so a half-set-up lockfile // doesn't get committed. flock.set_len(0)?; flock.seek(SeekFrom::Start(0))?; let pid = std::process::id(); flock.write_all(format!("{pid}\n").as_bytes())?; Ok(LockHandle { _flock: flock, lockfile_path, }) } fn read_advisory_pid(file: &File) -> Option { // Caller has &File but the file's seek cursor may be anywhere. // Re-open the path would race with the holder's truncate; use // `read_at` semantics by cloning the fd and seeking the clone. // Simpler still: borrow the file, save+restore cursor. But the // caller passed us &File; we can't seek without &mut. Use // `try_clone` to get an owned File we can manipulate freely. let mut clone = file.try_clone().ok()?; clone.seek(SeekFrom::Start(0)).ok()?; let mut buf = String::new(); clone.read_to_string(&mut buf).ok()?; buf.trim().parse::().ok() } #[cfg(test)] mod tests { use super::*; use std::os::unix::fs::PermissionsExt; use std::sync::mpsc; use std::thread; use std::time::Duration; use tempfile::TempDir; #[test] fn lockfile_path_appends_dot_lock() { let socket = Path::new("/run/pmacs/default.sock"); let lock = lockfile_path_for(socket); assert_eq!(lock, Path::new("/run/pmacs/default.sock.lock")); } #[test] fn first_acquire_succeeds_creates_lockfile_with_mode_0600() { let tempdir = TempDir::new().unwrap(); let socket_path = tempdir.path().join("daemon.sock"); let handle = acquire_lock(&socket_path).expect("first acquire"); let lockfile_path = lockfile_path_for(&socket_path); assert!(lockfile_path.exists()); let mode = fs::metadata(&lockfile_path).unwrap().permissions().mode() & 0o7777; assert_eq!(mode, 0o600, "expected 0600, got {mode:#o}"); // Pid is written advisory. let contents = fs::read_to_string(&lockfile_path).unwrap(); let parsed: u32 = contents.trim().parse().expect("pid in file"); assert_eq!(parsed, std::process::id()); handle.release().unwrap(); } #[test] fn second_acquire_returns_already_held_with_advisory_pid() { let tempdir = TempDir::new().unwrap(); let socket_path = tempdir.path().join("daemon.sock"); let _holder = acquire_lock(&socket_path).expect("first acquire"); match acquire_lock(&socket_path) { Err(LockError::AlreadyHeld { advisory_pid: Some(pid), }) => { assert_eq!(pid, std::process::id()); } other => panic!("expected AlreadyHeld with our pid, got {other:?}"), } } #[test] fn release_unlinks_lockfile_cleanly() { let tempdir = TempDir::new().unwrap(); let socket_path = tempdir.path().join("daemon.sock"); let lockfile_path = lockfile_path_for(&socket_path); let handle = acquire_lock(&socket_path).expect("acquire"); assert!(lockfile_path.exists()); handle.release().unwrap(); assert!( !lockfile_path.exists(), "release() should have unlinked {lockfile_path:?}" ); } #[test] fn drop_releases_lock_so_next_acquire_succeeds() { let tempdir = TempDir::new().unwrap(); let socket_path = tempdir.path().join("daemon.sock"); { let _handle = acquire_lock(&socket_path).expect("first"); // Drop at end of scope — kernel releases flock as fd closes. } // Lockfile is still on disk (we didn't release()), but the // flock is gone, so the second acquire succeeds. let handle2 = acquire_lock(&socket_path).expect("second"); handle2.release().unwrap(); } #[test] fn stale_lockfile_no_holder_succeeds_and_overwrites_pid() { // Pre-create a lockfile with a bogus pid string. No process // holds a flock on it. The next acquire must succeed and // overwrite the bogus pid with our own. let tempdir = TempDir::new().unwrap(); let socket_path = tempdir.path().join("daemon.sock"); let lockfile_path = lockfile_path_for(&socket_path); fs::write(&lockfile_path, b"99999\n").unwrap(); fs::set_permissions(&lockfile_path, fs::Permissions::from_mode(0o600)).unwrap(); let handle = acquire_lock(&socket_path).expect("acquire over stale"); let contents = fs::read_to_string(&lockfile_path).unwrap(); let parsed: u32 = contents.trim().parse().unwrap(); assert_eq!(parsed, std::process::id()); handle.release().unwrap(); } #[test] fn concurrent_acquires_only_one_wins() { // Spawn a thread that tries to acquire the lock while the // main thread holds it, then expect the spawned thread to // observe AlreadyHeld with the main thread's pid. let tempdir = TempDir::new().unwrap(); let socket_path = tempdir.path().join("daemon.sock"); let holder = acquire_lock(&socket_path).expect("main acquires"); let our_pid = std::process::id(); let socket_path_clone = socket_path.clone(); let (tx, rx) = mpsc::channel(); let join = thread::spawn(move || { let result = acquire_lock(&socket_path_clone); tx.send(result).unwrap(); }); let result = rx .recv_timeout(Duration::from_secs(2)) .expect("thread reports"); match result { Err(LockError::AlreadyHeld { advisory_pid }) => { assert_eq!(advisory_pid, Some(our_pid)); } other => panic!("expected AlreadyHeld, got {other:?}"), } join.join().unwrap(); // Drop the holder; spawned thread already finished. holder.release().unwrap(); } }