use std::{
fs::{self, File, OpenOptions},
io::{self, Write as _},
path::PathBuf,
};
use fs2::FileExt as _;
use tracing::{debug, warn};
use crate::{
config::DaemonConfig,
error::{DaemonError, DaemonResult},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PidfileOwnership {
WriteOwner,
Handoff,
Adopted,
}
pub struct PidfileLock {
lock: File,
pidfile: PathBuf,
lockfile: PathBuf,
ownership: PidfileOwnership,
}
impl std::fmt::Debug for PidfileLock {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PidfileLock")
.field("pidfile", &self.pidfile)
.field("lockfile", &self.lockfile)
.field("ownership", &self.ownership)
.finish_non_exhaustive()
}
}
impl PidfileLock {
#[must_use]
pub fn pidfile_path(&self) -> &PathBuf {
&self.pidfile
}
#[must_use]
pub fn lockfile_path(&self) -> &PathBuf {
&self.lockfile
}
#[must_use]
pub fn ownership(&self) -> PidfileOwnership {
self.ownership
}
#[cfg(unix)]
#[must_use]
pub fn as_raw_fd(&self) -> std::os::unix::io::RawFd {
use std::os::unix::io::AsRawFd as _;
self.lock.as_raw_fd()
}
pub fn hand_off_to_adopter(&mut self) {
assert_eq!(
self.ownership,
PidfileOwnership::WriteOwner,
"hand_off_to_adopter called on non-WriteOwner PidfileLock (ownership={:?})",
self.ownership,
);
self.ownership = PidfileOwnership::Handoff;
}
pub fn write_pid(&self, pid: u32) -> DaemonResult<()> {
write_pidfile_atomic(&self.pidfile, pid)
}
#[cfg(unix)]
pub unsafe fn adopt(fd: std::os::fd::RawFd, pidfile: PathBuf, lockfile: PathBuf) -> Self {
use std::os::unix::io::FromRawFd as _;
let lock = unsafe { File::from_raw_fd(fd) };
Self {
lock,
pidfile,
lockfile,
ownership: PidfileOwnership::Adopted,
}
}
}
impl Drop for PidfileLock {
fn drop(&mut self) {
match self.ownership {
PidfileOwnership::WriteOwner | PidfileOwnership::Adopted => {
let result = fs::remove_file(&self.pidfile);
match result {
Ok(()) => {
debug!(path = %self.pidfile.display(), "pidfile removed");
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
debug!(path = %self.pidfile.display(), "pidfile already absent on drop");
}
Err(e) => {
warn!(
path = %self.pidfile.display(),
err = %e,
"failed to remove pidfile on drop"
);
}
}
}
PidfileOwnership::Handoff => {
debug!(
path = %self.pidfile.display(),
"pidfile handoff — skipping unlink on parent drop"
);
}
}
match self.ownership {
PidfileOwnership::WriteOwner | PidfileOwnership::Adopted => {
let result = self.lock.unlock();
match result {
Ok(()) => {
debug!(lockfile = %self.lockfile.display(), "flock released");
}
Err(e) => {
warn!(
lockfile = %self.lockfile.display(),
err = %e,
"flock release failed on drop (kernel will clean up on exit)"
);
}
}
}
PidfileOwnership::Handoff => {
debug!(
lockfile = %self.lockfile.display(),
"pidfile handoff — closing parent FD without LOCK_UN (grandchild retains lock)"
);
}
}
}
}
pub fn acquire_pidfile_lock(cfg: &DaemonConfig) -> DaemonResult<PidfileLock> {
let lockfile = cfg.lock_path();
let pidfile = cfg.pid_path();
ensure_runtime_dir(&lockfile)?;
#[cfg(unix)]
let lock_file = {
use std::os::unix::fs::OpenOptionsExt as _;
OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(&lockfile)?
};
#[cfg(not(unix))]
let lock_file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lockfile)?;
#[cfg(unix)]
set_permissions_0600(&lockfile)?;
#[cfg(unix)]
warn_if_nfs(lockfile.parent().unwrap_or(&lockfile));
match lock_file.try_lock_exclusive() {
Ok(()) => {
debug!(lockfile = %lockfile.display(), "exclusive flock acquired");
}
Err(e) if is_would_block(&e) => {
let owner_pid = read_pid(&pidfile);
debug!(
lockfile = %lockfile.display(),
owner_pid = ?owner_pid,
"flock contended — daemon already running"
);
return Err(DaemonError::AlreadyRunning {
owner_pid,
socket: cfg.socket_path(),
lock: lockfile,
});
}
Err(e) => {
return Err(DaemonError::Io(e));
}
}
let pid = std::process::id();
write_pidfile_atomic(&pidfile, pid)?;
Ok(PidfileLock {
lock: lock_file,
pidfile,
lockfile,
ownership: PidfileOwnership::WriteOwner,
})
}
fn ensure_runtime_dir(lockfile: &std::path::Path) -> DaemonResult<()> {
let dir = lockfile.parent().ok_or_else(|| {
DaemonError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"lockfile path has no parent directory",
))
})?;
fs::create_dir_all(dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let perms = fs::Permissions::from_mode(0o700);
fs::set_permissions(dir, perms)?;
}
Ok(())
}
#[cfg(unix)]
fn set_permissions_0600(path: &std::path::Path) -> DaemonResult<()> {
use std::os::unix::fs::PermissionsExt as _;
let perms = fs::Permissions::from_mode(0o600);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(unix)]
fn warn_if_nfs(dir: &std::path::Path) {
if is_nfs(dir) {
warn!(
dir = %dir.display(),
"sqryd runtime directory appears to be on NFS; flock(2) semantics \
are not guaranteed on NFS mounts — consider using a local \
filesystem for SQRY_DAEMON_SOCKET / XDG_RUNTIME_DIR"
);
}
}
#[cfg(all(unix, target_os = "linux"))]
fn is_nfs(dir: &std::path::Path) -> bool {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
let c_path = match CString::new(dir.as_os_str().as_bytes()) {
Ok(p) => p,
Err(_) => return false,
};
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statfs(c_path.as_ptr(), &mut buf) };
if rc != 0 {
return false;
}
const NFS_SUPER_MAGIC: libc::c_long = 0x6969;
buf.f_type == NFS_SUPER_MAGIC
}
#[cfg(all(unix, target_os = "macos"))]
fn is_nfs(dir: &std::path::Path) -> bool {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt as _;
let c_path = match CString::new(dir.as_os_str().as_bytes()) {
Ok(p) => p,
Err(_) => return false,
};
let mut buf: libc::statfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::statfs(c_path.as_ptr(), &mut buf) };
if rc != 0 {
return false;
}
let ftype = unsafe { std::ffi::CStr::from_ptr(buf.f_fstypename.as_ptr()) };
ftype.to_bytes() == b"nfs"
}
#[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
fn is_nfs(_dir: &std::path::Path) -> bool {
false
}
fn write_pidfile_atomic(pidfile: &std::path::Path, pid: u32) -> DaemonResult<()> {
let dir = pidfile.parent().ok_or_else(|| {
DaemonError::Io(io::Error::new(
io::ErrorKind::InvalidInput,
"pidfile path has no parent directory",
))
})?;
let tmp_path = dir.join(format!(".sqryd.pid.tmp.{}", std::process::id()));
{
let mut tmp = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let perms = fs::Permissions::from_mode(0o644);
tmp.set_permissions(perms)?;
}
writeln!(tmp, "{pid}")?;
tmp.flush()?;
}
fs::rename(&tmp_path, pidfile)?;
debug!(path = %pidfile.display(), pid, "pidfile written");
Ok(())
}
pub fn read_pid(pidfile: &std::path::Path) -> Option<u32> {
let text = fs::read_to_string(pidfile).ok()?;
text.trim().parse::<u32>().ok()
}
fn is_would_block(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::WouldBlock
|| e.raw_os_error()
.is_some_and(|c| {
#[cfg(unix)]
{
c == libc::EWOULDBLOCK || c == libc::EAGAIN
}
#[cfg(not(unix))]
{
c == 33
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use crate::TEST_ENV_LOCK as ENV_LOCK;
struct TestCfg {
_tmp: TempDir,
cfg: DaemonConfig,
_guard: std::sync::MutexGuard<'static, ()>,
prior_xdg: Option<String>,
}
impl TestCfg {
fn new() -> Self {
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().expect("TempDir::new");
let prior_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
#[allow(unsafe_code)]
unsafe {
std::env::set_var("XDG_RUNTIME_DIR", tmp.path());
}
let mut cfg = DaemonConfig::default();
cfg.socket.path = Some(tmp.path().join("sqry").join("sqryd.sock"));
Self {
_tmp: tmp,
cfg,
_guard: guard,
prior_xdg,
}
}
fn cfg(&self) -> &DaemonConfig {
&self.cfg
}
}
impl Drop for TestCfg {
fn drop(&mut self) {
#[allow(unsafe_code)]
unsafe {
match self.prior_xdg.take() {
Some(v) => std::env::set_var("XDG_RUNTIME_DIR", v),
None => std::env::remove_var("XDG_RUNTIME_DIR"),
}
}
}
}
#[test]
fn acquire_succeeds_on_clean_dir() {
let fix = TestCfg::new();
let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire should succeed on clean dir");
assert_eq!(lock.ownership(), PidfileOwnership::WriteOwner);
assert!(fix.cfg().pid_path().exists(), "pidfile must be created");
drop(lock);
}
#[test]
fn acquire_rejects_already_held() {
let fix = TestCfg::new();
let _lock1 = acquire_pidfile_lock(fix.cfg()).expect("first acquire should succeed");
let err = acquire_pidfile_lock(fix.cfg()).expect_err("second acquire must fail");
match err {
DaemonError::AlreadyRunning { owner_pid, .. } => {
assert_eq!(
owner_pid,
Some(std::process::id()),
"owner_pid must match current process"
);
}
other => panic!("expected AlreadyRunning, got {other:?}"),
}
}
#[test]
fn drop_removes_pidfile_but_not_lockfile() {
let fix = TestCfg::new();
let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
let pidfile = fix.cfg().pid_path();
let lockfile = fix.cfg().lock_path();
assert!(pidfile.exists(), "pidfile must exist before drop");
assert!(lockfile.exists(), "lockfile must exist before drop");
drop(lock);
assert!(!pidfile.exists(), "pidfile must be removed after drop");
assert!(
lockfile.exists(),
"lockfile must NOT be removed after drop (§D.4)"
);
}
#[test]
fn acquire_reclaims_stale_pidfile() {
let fix = TestCfg::new();
let lockfile = fix.cfg().lock_path();
let pidfile = fix.cfg().pid_path();
fs::create_dir_all(lockfile.parent().unwrap()).unwrap();
fs::write(&lockfile, b"").unwrap();
fs::write(&pidfile, b"99999\n").unwrap();
let lock = acquire_pidfile_lock(fix.cfg()).expect("stale recovery must succeed");
assert_eq!(lock.ownership(), PidfileOwnership::WriteOwner);
let new_pid = read_pid(&pidfile).expect("pidfile must be legible");
assert_eq!(
new_pid,
std::process::id(),
"pidfile must be overwritten with current PID"
);
drop(lock);
}
#[cfg(unix)]
#[test]
fn pidfile_is_0644_lockfile_is_0600_dir_is_0700() {
use std::os::unix::fs::MetadataExt as _;
let fix = TestCfg::new();
let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
let dir = fix.cfg().lock_path();
let dir = dir.parent().expect("lock_path has parent");
let dir_mode = fs::metadata(dir).unwrap().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "runtime dir must be 0700");
let lockfile_mode = fs::metadata(fix.cfg().lock_path()).unwrap().mode() & 0o777;
assert_eq!(lockfile_mode, 0o600, "lockfile must be 0600");
let pidfile_mode = fs::metadata(fix.cfg().pid_path()).unwrap().mode() & 0o777;
assert_eq!(pidfile_mode, 0o644, "pidfile must be 0644");
drop(lock);
}
#[test]
fn hand_off_to_adopter_transitions_writeowner_to_handoff_skips_unlink() {
let fix = TestCfg::new();
let mut lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
assert_eq!(lock.ownership(), PidfileOwnership::WriteOwner);
lock.hand_off_to_adopter();
assert_eq!(lock.ownership(), PidfileOwnership::Handoff);
let pidfile = fix.cfg().pid_path();
drop(lock);
assert!(
pidfile.exists(),
"pidfile must NOT be unlinked by Handoff drop"
);
}
#[cfg(unix)]
#[test]
fn adopt_preserves_ofd_lock_via_duped_fd_flock_contention() {
use std::os::unix::io::{AsRawFd as _, RawFd};
let tmp = TempDir::new().unwrap();
let lockfile = tmp.path().join("test.lock");
let pidfile = tmp.path().join("test.pid");
let original = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&lockfile)
.unwrap();
original.lock_exclusive().expect("lock_exclusive");
let raw_fd: RawFd = original.as_raw_fd();
let duped_fd: RawFd = unsafe { libc::dup(raw_fd) };
assert!(duped_fd >= 0, "libc::dup failed");
let adopted = unsafe { PidfileLock::adopt(duped_fd, pidfile.clone(), lockfile.clone()) };
assert_eq!(adopted.ownership(), PidfileOwnership::Adopted);
let lockfile_clone = lockfile.clone();
let contention_blocked = std::thread::spawn(move || {
let f = OpenOptions::new()
.read(true)
.write(true)
.open(&lockfile_clone)
.unwrap();
let result = fs2::FileExt::try_lock_shared(&f);
match result {
Err(ref e) if is_would_block_err(e) => true,
Ok(()) => {
let _ = fs2::FileExt::unlock(&f);
false
}
Err(_) => false,
}
})
.join()
.unwrap();
assert!(
contention_blocked,
"try_lock_shared from a second thread must return WouldBlock while adopted lock is held"
);
drop(original);
drop(adopted);
let fresh = OpenOptions::new()
.read(true)
.write(true)
.open(&lockfile)
.unwrap();
fs2::FileExt::try_lock_exclusive(&fresh)
.expect("try_lock_exclusive must succeed after release");
}
#[cfg(unix)]
fn is_would_block_err(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::WouldBlock
|| e.raw_os_error()
.is_some_and(|c| c == libc::EWOULDBLOCK || c == libc::EAGAIN)
}
#[cfg(unix)]
#[test]
fn drop_of_adopted_unlinks_pidfile() {
use std::os::unix::io::{AsRawFd as _, RawFd};
let tmp = TempDir::new().unwrap();
let lockfile = tmp.path().join("test.lock");
let pidfile = tmp.path().join("test.pid");
let original = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&lockfile)
.unwrap();
original.lock_exclusive().unwrap();
fs::write(&pidfile, b"12345\n").unwrap();
let raw_fd: RawFd = original.as_raw_fd();
let duped_fd: RawFd = unsafe { libc::dup(raw_fd) };
assert!(duped_fd >= 0);
let adopted = unsafe { PidfileLock::adopt(duped_fd, pidfile.clone(), lockfile.clone()) };
assert!(pidfile.exists(), "pidfile must exist before drop");
drop(original); drop(adopted);
assert!(
!pidfile.exists(),
"Adopted drop must unlink the pidfile (design M6 fix)"
);
}
#[test]
fn write_pid_overwrites_correctly() {
let fix = TestCfg::new();
let lock = acquire_pidfile_lock(fix.cfg()).expect("acquire");
lock.write_pid(42_000).expect("write_pid");
let written = read_pid(&fix.cfg().pid_path()).expect("read_pid");
assert_eq!(written, 42_000);
drop(lock);
}
#[test]
fn is_would_block_covers_would_block_kind() {
let e = io::Error::new(io::ErrorKind::WouldBlock, "would block");
assert!(is_would_block(&e));
}
#[test]
fn is_would_block_returns_false_for_other_errors() {
let e = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
assert!(!is_would_block(&e));
}
#[cfg(unix)]
#[test]
fn handoff_drop_does_not_release_adopted_ofd_lock() {
use std::os::unix::io::{AsRawFd as _, RawFd};
let tmp = TempDir::new().unwrap();
let lockfile = tmp.path().join("test.lock");
let pidfile = tmp.path().join("test.pid");
let original = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&lockfile)
.unwrap();
original.lock_exclusive().expect("lock_exclusive");
fs::write(&pidfile, b"42\n").unwrap();
let raw_fd: RawFd = original.as_raw_fd();
let parent_fd: RawFd = unsafe { libc::dup(raw_fd) };
assert!(parent_fd >= 0, "libc::dup(parent) failed");
let child_fd: RawFd = unsafe { libc::dup(raw_fd) };
assert!(child_fd >= 0, "libc::dup(child) failed");
let child_lock = unsafe { PidfileLock::adopt(child_fd, pidfile.clone(), lockfile.clone()) };
assert_eq!(child_lock.ownership(), PidfileOwnership::Adopted);
unsafe { libc::close(parent_fd) };
drop(original);
let lockfile_clone = lockfile.clone();
let still_locked = std::thread::spawn(move || {
let f = OpenOptions::new()
.read(true)
.write(true)
.open(&lockfile_clone)
.unwrap();
let result = fs2::FileExt::try_lock_exclusive(&f);
match result {
Err(ref e) if is_would_block_err(e) => true, Ok(()) => {
let _ = fs2::FileExt::unlock(&f);
false
}
Err(_) => false,
}
})
.join()
.unwrap();
assert!(
still_locked,
"Handoff drop (close FD without LOCK_UN) must NOT release the OFD-level lock \
(M-2 fix): the adopted grandchild lock must still hold after all parent FDs close"
);
drop(child_lock);
let fresh = OpenOptions::new()
.read(true)
.write(true)
.open(&lockfile)
.unwrap();
fs2::FileExt::try_lock_exclusive(&fresh)
.expect("try_lock_exclusive must succeed after Adopted child drop");
}
}