339 lines
12 KiB
Rust
339 lines
12 KiB
Rust
// 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 (`<socket>.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 `<socket>.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 <path> (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<u32>,
|
|
},
|
|
/// 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<std::io::Error> for LockError {
|
|
fn from(e: std::io::Error) -> Self {
|
|
Self::Io(e)
|
|
}
|
|
}
|
|
|
|
/// Sibling-lockfile path for a given socket path: `<socket>.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<File>` 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<File>,
|
|
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 `<socket_path>.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<LockHandle, LockError> {
|
|
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<u32> {
|
|
// 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::<u32>().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();
|
|
}
|
|
}
|