use std::fs::File;
use std::io::{Seek, SeekFrom, Write};
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DaemonIdentity {
pub pid: u32,
pub pgid: i32,
#[serde(default)]
pub start_time: Option<u64>,
}
pub fn daemon_lock_path() -> Result<PathBuf> {
Ok(gflow::paths::get_runtime_dir()?.join("gflowd.lock"))
}
fn try_acquire_lock_at(path: &Path) -> Result<Option<File>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)
.with_context(|| format!("failed to open lock file {}", path.display()))?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(Some(file));
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
return Ok(None);
}
Err(err).with_context(|| format!("failed to lock {}", path.display()))
}
pub fn try_acquire_daemon_lock() -> Result<Option<File>> {
try_acquire_lock_at(&daemon_lock_path()?)
}
fn lock_held_at(path: &Path) -> bool {
match try_acquire_lock_at(path) {
Ok(Some(_)) => false,
Ok(None) => true,
Err(_) => false,
}
}
pub fn daemon_lock_held() -> bool {
match daemon_lock_path() {
Ok(path) => lock_held_at(&path),
Err(_) => false,
}
}
fn read_identity_at(path: &Path) -> Option<DaemonIdentity> {
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
pub fn read_daemon_identity() -> Option<DaemonIdentity> {
let path = daemon_lock_path().ok()?;
read_identity_at(&path)
}
fn write_identity_at(file: &mut File, identity: &DaemonIdentity) -> Result<()> {
let json = serde_json::to_string(identity)?;
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
file.write_all(json.as_bytes())?;
file.sync_data()?;
Ok(())
}
pub fn write_daemon_identity(file: &mut File, identity: &DaemonIdentity) -> Result<()> {
write_identity_at(file, identity)
}
pub fn process_alive(pid: u32) -> bool {
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
pub fn process_start_time(pid: u32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{}/stat", pid)).ok()?;
let end = stat.rfind(')')?;
let mut fields = stat.get(end + 1..)?.split_whitespace();
let _state = fields.next()?;
let _ppid = fields.next()?;
let fields: Vec<_> = fields.collect();
fields.get(17)?.parse().ok()
}
pub fn process_identity_matches(identity: &DaemonIdentity) -> bool {
if !process_alive(identity.pid) {
return false;
}
let current_pgid = unsafe { libc::getpgid(identity.pid as libc::pid_t) };
if current_pgid != identity.pgid {
return false;
}
identity
.start_time
.map(|expected| process_start_time(identity.pid) == Some(expected))
.unwrap_or(true)
}
pub fn verify_before_signal(pid: u32) -> bool {
if !process_alive(pid) {
return false;
}
match read_daemon_identity() {
Some(identity) if identity.pid == pid => process_identity_matches(&identity),
_ => false,
}
}
fn remove_file_if_exists(path: &Path) {
let _ = std::fs::remove_file(path);
}
pub fn remove_daemon_lock() {
if let Ok(path) = daemon_lock_path() {
remove_file_if_exists(&path);
}
}
pub fn direct_daemon_pid() -> Option<u32> {
if daemon_lock_held() {
if let Some(identity) = read_daemon_identity() {
if process_identity_matches(&identity) {
return Some(identity.pid);
}
}
} else {
remove_daemon_lock();
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_lock_dir() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
#[test]
fn acquire_then_release_inverts_lock() {
let dir = temp_lock_dir();
let path = dir.path().join("gflowd.lock");
let file = try_acquire_lock_at(&path).unwrap().expect("lock free");
assert!(lock_held_at(&path), "lock should be held while file open");
drop(file);
assert!(!lock_held_at(&path), "lock released after drop");
}
#[test]
fn lock_auto_released_on_crash_semantics() {
let dir = temp_lock_dir();
let path = dir.path().join("gflowd.lock");
let _held = try_acquire_lock_at(&path).unwrap().expect("lock free");
drop(_held);
assert!(!lock_held_at(&path));
assert!(path.exists());
}
#[test]
fn stale_lock_file_is_cleaned_up_and_reported_not_running() {
let dir = temp_lock_dir();
let path = dir.path().join("gflowd.lock");
std::fs::write(&path, "{\"pid\":999999,\"pgid\":-1}").unwrap();
assert!(!lock_held_at(&path));
assert!(read_identity_at(&path).is_some());
}
#[test]
fn pid_mismatch_is_rejected() {
let dir = temp_lock_dir();
let path = dir.path().join("gflowd.lock");
let mut file = try_acquire_lock_at(&path).unwrap().expect("lock free");
let identity = DaemonIdentity {
pid: u32::MAX - 1,
pgid: -1,
start_time: Some(0),
};
write_identity_at(&mut file, &identity).unwrap();
assert!(!process_identity_matches(&identity));
let self_id = DaemonIdentity {
pid: std::process::id(),
pgid: unsafe { libc::getpgid(std::process::id() as libc::pid_t) },
start_time: process_start_time(std::process::id()),
};
assert!(process_identity_matches(&self_id));
}
#[test]
fn identity_roundtrip_via_json() {
let dir = temp_lock_dir();
let path = dir.path().join("gflowd.lock");
let mut file = try_acquire_lock_at(&path).unwrap().expect("lock free");
let identity = DaemonIdentity {
pid: 7,
pgid: 7,
start_time: Some(42),
};
write_identity_at(&mut file, &identity).unwrap();
assert_eq!(read_identity_at(&path).unwrap(), identity);
}
}