mirror of
https://github.com/telemt/telemt.git
synced 2026-11-04 06:42:00 +03:00
Races in admission + accounting + publication,+ PID fixed
This commit is contained in:
+28
-279
@@ -4,14 +4,20 @@
|
||||
//! and privilege dropping for running telemt as a background service.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Read, Write};
|
||||
use std::io;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nix::errno::Errno;
|
||||
use nix::fcntl::{Flock, FlockArg};
|
||||
use nix::unistd::{self, ForkResult, Gid, Pid, Uid, chdir, close, fork, getpid, setsid};
|
||||
use tracing::{debug, info, warn};
|
||||
use nix::unistd::{self, ForkResult, Gid, Uid, chdir, close, fork, getpid, setsid};
|
||||
use tracing::info;
|
||||
|
||||
// PID file ownership and process-control helpers.
|
||||
mod pid_file;
|
||||
|
||||
pub use pid_file::DaemonStatus;
|
||||
#[allow(unused_imports)]
|
||||
pub use pid_file::{PidFile, check_status, read_pid_file, signal_pid_file};
|
||||
|
||||
/// Default PID file location.
|
||||
pub const DEFAULT_PID_FILE: &str = "/var/run/telemt.pid";
|
||||
@@ -51,36 +57,47 @@ impl DaemonOptions {
|
||||
/// Error types for daemon operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DaemonError {
|
||||
/// A daemonization fork failed.
|
||||
#[error("fork failed: {0}")]
|
||||
ForkFailed(#[source] nix::Error),
|
||||
|
||||
/// Creation of the detached session failed.
|
||||
#[error("setsid failed: {0}")]
|
||||
SetsidFailed(#[source] nix::Error),
|
||||
|
||||
/// Switching to the configured working directory failed.
|
||||
#[error("chdir failed: {0}")]
|
||||
ChdirFailed(#[source] nix::Error),
|
||||
|
||||
/// Opening `/dev/null` for standard-stream redirection failed.
|
||||
#[error("failed to open /dev/null: {0}")]
|
||||
DevNullFailed(#[source] io::Error),
|
||||
|
||||
/// Redirecting a standard file descriptor failed.
|
||||
#[error("failed to redirect stdio: {0}")]
|
||||
RedirectFailed(#[source] nix::Error),
|
||||
|
||||
/// A PID lifecycle operation failed.
|
||||
#[error("PID file error: {0}")]
|
||||
PidFile(String),
|
||||
|
||||
/// Another process owns the daemon PID lifecycle.
|
||||
#[error("another instance is already running (pid {0})")]
|
||||
AlreadyRunning(i32),
|
||||
|
||||
/// The configured runtime user does not exist.
|
||||
#[error("user '{0}' not found")]
|
||||
UserNotFound(String),
|
||||
|
||||
/// The configured runtime group does not exist.
|
||||
#[error("group '{0}' not found")]
|
||||
GroupNotFound(String),
|
||||
|
||||
/// Applying the configured runtime identity failed.
|
||||
#[error("failed to set uid/gid: {0}")]
|
||||
PrivilegeDrop(#[source] nix::Error),
|
||||
|
||||
/// An underlying filesystem operation failed.
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
@@ -106,38 +123,28 @@ pub enum DaemonizeResult {
|
||||
/// Returns `DaemonizeResult::Parent` in the original parent (which should exit),
|
||||
/// or `DaemonizeResult::Child` in the final daemon child.
|
||||
pub fn daemonize(working_dir: Option<&Path>) -> Result<DaemonizeResult, DaemonError> {
|
||||
// First fork
|
||||
match unsafe { fork() } {
|
||||
Ok(ForkResult::Parent { .. }) => {
|
||||
// Parent exits
|
||||
return Ok(DaemonizeResult::Parent);
|
||||
}
|
||||
Ok(ForkResult::Child) => {
|
||||
// Child continues
|
||||
}
|
||||
Ok(ForkResult::Child) => {}
|
||||
Err(e) => return Err(DaemonError::ForkFailed(e)),
|
||||
}
|
||||
|
||||
// Create new session, become session leader
|
||||
setsid().map_err(DaemonError::SetsidFailed)?;
|
||||
|
||||
// Second fork to ensure we can never acquire a controlling terminal
|
||||
match unsafe { fork() } {
|
||||
Ok(ForkResult::Parent { .. }) => {
|
||||
// Intermediate parent exits
|
||||
std::process::exit(0);
|
||||
}
|
||||
Ok(ForkResult::Child) => {
|
||||
// Final daemon child continues
|
||||
}
|
||||
Ok(ForkResult::Child) => {}
|
||||
Err(e) => return Err(DaemonError::ForkFailed(e)),
|
||||
}
|
||||
|
||||
// Change working directory
|
||||
let target_dir = working_dir.unwrap_or(Path::new("/"));
|
||||
chdir(target_dir).map_err(DaemonError::ChdirFailed)?;
|
||||
|
||||
// Redirect stdin, stdout, stderr to /dev/null
|
||||
redirect_stdio_to_devnull()?;
|
||||
|
||||
Ok(DaemonizeResult::Child)
|
||||
@@ -156,21 +163,17 @@ fn redirect_stdio_to_devnull() -> Result<(), DaemonError> {
|
||||
// Use libc::dup2 directly for redirecting standard file descriptors
|
||||
// nix 0.31's dup2 requires OwnedFd which doesn't work well with stdio fds
|
||||
unsafe {
|
||||
// Redirect stdin (fd 0)
|
||||
if libc::dup2(devnull_fd, 0) < 0 {
|
||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||
}
|
||||
// Redirect stdout (fd 1)
|
||||
if libc::dup2(devnull_fd, 1) < 0 {
|
||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||
}
|
||||
// Redirect stderr (fd 2)
|
||||
if libc::dup2(devnull_fd, 2) < 0 {
|
||||
return Err(DaemonError::RedirectFailed(Errno::last()));
|
||||
}
|
||||
}
|
||||
|
||||
// Close original devnull fd if it's not one of the standard fds
|
||||
if devnull_fd > 2 {
|
||||
let _ = close(devnull_fd);
|
||||
}
|
||||
@@ -178,166 +181,6 @@ fn redirect_stdio_to_devnull() -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// PID file manager with flock-based locking.
|
||||
pub struct PidFile {
|
||||
path: PathBuf,
|
||||
file: Option<File>,
|
||||
locked: bool,
|
||||
}
|
||||
|
||||
impl PidFile {
|
||||
/// Creates a new PID file manager for the given path.
|
||||
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
||||
Self {
|
||||
path: path.as_ref().to_path_buf(),
|
||||
file: None,
|
||||
locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if another instance is already running.
|
||||
///
|
||||
/// Returns the PID of the running instance if one exists.
|
||||
pub fn check_running(&self) -> Result<Option<i32>, DaemonError> {
|
||||
if !self.path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Try to read existing PID
|
||||
let mut contents = String::new();
|
||||
File::open(&self.path)
|
||||
.and_then(|mut f| f.read_to_string(&mut contents))
|
||||
.map_err(|e| {
|
||||
DaemonError::PidFile(format!("cannot read {}: {}", self.path.display(), e))
|
||||
})?;
|
||||
|
||||
let pid: i32 = contents
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", self.path.display())))?;
|
||||
|
||||
// Check if process is still running
|
||||
if is_process_running(pid) {
|
||||
Ok(Some(pid))
|
||||
} else {
|
||||
// Stale PID file
|
||||
debug!(pid, path = %self.path.display(), "Removing stale PID file");
|
||||
let _ = fs::remove_file(&self.path);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquires the PID file lock and writes the current PID.
|
||||
///
|
||||
/// Fails if another instance is already running.
|
||||
pub fn acquire(&mut self) -> Result<(), DaemonError> {
|
||||
// Check for running instance first
|
||||
if let Some(pid) = self.check_running()? {
|
||||
return Err(DaemonError::AlreadyRunning(pid));
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = self.path.parent() {
|
||||
if !parent.exists() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
DaemonError::PidFile(format!(
|
||||
"cannot create directory {}: {}",
|
||||
parent.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
// Open/create PID file with exclusive lock
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o644)
|
||||
.open(&self.path)
|
||||
.map_err(|e| {
|
||||
DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), e))
|
||||
})?;
|
||||
|
||||
// Try to acquire exclusive lock (non-blocking)
|
||||
let flock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
||||
// Check if another instance grabbed the lock
|
||||
if let Some(pid) = self.check_running().ok().flatten() {
|
||||
DaemonError::AlreadyRunning(pid)
|
||||
} else {
|
||||
DaemonError::PidFile(format!("cannot lock {}: {}", self.path.display(), errno))
|
||||
}
|
||||
})?;
|
||||
|
||||
// Write our PID
|
||||
let pid = getpid();
|
||||
let mut file = flock
|
||||
.unlock()
|
||||
.map_err(|(_, errno)| DaemonError::PidFile(format!("unlock failed: {}", errno)))?;
|
||||
|
||||
writeln!(file, "{}", pid).map_err(|e| {
|
||||
DaemonError::PidFile(format!(
|
||||
"cannot write PID to {}: {}",
|
||||
self.path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Re-acquire lock and keep it
|
||||
let flock = Flock::lock(file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
||||
DaemonError::PidFile(format!("cannot re-lock {}: {}", self.path.display(), errno))
|
||||
})?;
|
||||
|
||||
self.file = Some(flock.unlock().map_err(|(_, errno)| {
|
||||
DaemonError::PidFile(format!("unlock for storage failed: {}", errno))
|
||||
})?);
|
||||
self.locked = true;
|
||||
|
||||
info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Releases the PID file lock and removes the file.
|
||||
pub fn release(&mut self) -> Result<(), DaemonError> {
|
||||
if let Some(file) = self.file.take() {
|
||||
drop(file);
|
||||
}
|
||||
self.locked = false;
|
||||
|
||||
if self.path.exists() {
|
||||
fs::remove_file(&self.path).map_err(|e| {
|
||||
DaemonError::PidFile(format!("cannot remove {}: {}", self.path.display(), e))
|
||||
})?;
|
||||
debug!(path = %self.path.display(), "PID file removed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the path to this PID file.
|
||||
#[allow(dead_code)]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PidFile {
|
||||
fn drop(&mut self) {
|
||||
if self.locked {
|
||||
if let Err(e) = self.release() {
|
||||
warn!(error = %e, "Failed to clean up PID file on drop");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a process with the given PID is running.
|
||||
fn is_process_running(pid: i32) -> bool {
|
||||
// kill(pid, 0) checks if process exists without sending a signal
|
||||
nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok()
|
||||
}
|
||||
|
||||
// macOS gates nix::unistd::setgroups differently in the current dependency set,
|
||||
// so call libc directly there while preserving the original nix path elsewhere.
|
||||
fn set_supplementary_groups(gid: Gid) -> Result<(), nix::Error> {
|
||||
@@ -383,9 +226,11 @@ pub fn drop_privileges(
|
||||
};
|
||||
|
||||
if (target_uid.is_some() || target_gid.is_some())
|
||||
&& let Some(file) = pid_file.and_then(|pid| pid.file.as_ref())
|
||||
&& let Some(pid_file) = pid_file
|
||||
{
|
||||
unistd::fchown(file, target_uid, target_gid).map_err(DaemonError::PrivilegeDrop)?;
|
||||
for file in pid_file.ownership_file_handles().into_iter().flatten() {
|
||||
unistd::fchown(file, target_uid, target_gid).map_err(DaemonError::PrivilegeDrop)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(gid) = target_gid {
|
||||
@@ -401,7 +246,7 @@ pub fn drop_privileges(
|
||||
if uid.as_raw() != 0
|
||||
&& let Some(pid) = pid_file
|
||||
{
|
||||
let parent = pid.path.parent().unwrap_or(Path::new("."));
|
||||
let parent = pid.path().parent().unwrap_or(Path::new("."));
|
||||
let probe_path = parent.join(format!(
|
||||
".telemt_pid_probe_{}_{}",
|
||||
std::process::id(),
|
||||
@@ -436,7 +281,6 @@ pub fn drop_privileges(
|
||||
|
||||
/// Looks up a user by name and returns their UID.
|
||||
fn lookup_user(name: &str) -> Result<Uid, DaemonError> {
|
||||
// Use libc getpwnam
|
||||
let c_name =
|
||||
std::ffi::CString::new(name).map_err(|_| DaemonError::UserNotFound(name.to_string()))?;
|
||||
|
||||
@@ -480,76 +324,6 @@ fn lookup_group(name: &str) -> Result<Gid, DaemonError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads PID from a PID file.
|
||||
#[allow(dead_code)]
|
||||
pub fn read_pid_file<P: AsRef<Path>>(path: P) -> Result<i32, DaemonError> {
|
||||
let path = path.as_ref();
|
||||
let mut contents = String::new();
|
||||
File::open(path)
|
||||
.and_then(|mut f| f.read_to_string(&mut contents))
|
||||
.map_err(|e| DaemonError::PidFile(format!("cannot read {}: {}", path.display(), e)))?;
|
||||
|
||||
contents
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display())))
|
||||
}
|
||||
|
||||
/// Sends a signal to the process specified in a PID file.
|
||||
#[allow(dead_code)]
|
||||
pub fn signal_pid_file<P: AsRef<Path>>(
|
||||
path: P,
|
||||
signal: nix::sys::signal::Signal,
|
||||
) -> Result<(), DaemonError> {
|
||||
let pid = read_pid_file(&path)?;
|
||||
|
||||
if !is_process_running(pid) {
|
||||
return Err(DaemonError::PidFile(format!(
|
||||
"process {} from {} is not running",
|
||||
pid,
|
||||
path.as_ref().display()
|
||||
)));
|
||||
}
|
||||
|
||||
nix::sys::signal::kill(Pid::from_raw(pid), signal)
|
||||
.map_err(|e| DaemonError::PidFile(format!("cannot signal process {}: {}", pid, e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the status of the daemon based on PID file.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DaemonStatus {
|
||||
/// Daemon is running with the given PID.
|
||||
Running(i32),
|
||||
/// PID file exists but process is not running.
|
||||
Stale(i32),
|
||||
/// No PID file exists.
|
||||
NotRunning,
|
||||
}
|
||||
|
||||
/// Checks the daemon status from a PID file.
|
||||
#[allow(dead_code)]
|
||||
pub fn check_status<P: AsRef<Path>>(path: P) -> DaemonStatus {
|
||||
let path = path.as_ref();
|
||||
|
||||
if !path.exists() {
|
||||
return DaemonStatus::NotRunning;
|
||||
}
|
||||
|
||||
match read_pid_file(path) {
|
||||
Ok(pid) => {
|
||||
if is_process_running(pid) {
|
||||
DaemonStatus::Running(pid)
|
||||
} else {
|
||||
DaemonStatus::Stale(pid)
|
||||
}
|
||||
}
|
||||
Err(_) => DaemonStatus::NotRunning,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -571,29 +345,4 @@ mod tests {
|
||||
};
|
||||
assert!(!opts.should_daemonize());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_status_not_running() {
|
||||
let path = "/tmp/telemt_test_nonexistent.pid";
|
||||
assert_eq!(check_status(path), DaemonStatus::NotRunning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pid_file_basic() {
|
||||
let path = "/tmp/telemt_test_pidfile.pid";
|
||||
let _ = fs::remove_file(path);
|
||||
|
||||
let mut pf = PidFile::new(path);
|
||||
assert!(pf.check_running().unwrap().is_none());
|
||||
|
||||
pf.acquire().unwrap();
|
||||
assert!(Path::new(path).exists());
|
||||
|
||||
// Read it back
|
||||
let pid = read_pid_file(path).unwrap();
|
||||
assert_eq!(pid, std::process::id() as i32);
|
||||
|
||||
pf.release().unwrap();
|
||||
assert!(!Path::new(path).exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{ErrorKind, Read, Write};
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nix::fcntl::{Flock, FlockArg};
|
||||
use nix::unistd::{Pid, getpid};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use super::DaemonError;
|
||||
|
||||
/// PID file manager backed by a persistent sibling lock file.
|
||||
pub struct PidFile {
|
||||
path: PathBuf,
|
||||
lock_path: PathBuf,
|
||||
pid_file: Option<File>,
|
||||
lock_file: Option<Flock<File>>,
|
||||
}
|
||||
|
||||
impl PidFile {
|
||||
/// Creates a new PID file manager for the given path.
|
||||
pub fn new<P: AsRef<Path>>(path: P) -> Self {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let lock_path = sibling_lock_path(&path);
|
||||
Self {
|
||||
path,
|
||||
lock_path,
|
||||
pid_file: None,
|
||||
lock_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether the PID file names a running process without modifying either file.
|
||||
pub fn check_running(&self) -> Result<Option<i32>, DaemonError> {
|
||||
let Some(pid) = read_pid_file_if_exists(&self.path)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(is_process_running(pid).then_some(pid))
|
||||
}
|
||||
|
||||
/// Acquires the persistent sibling lock and writes the current PID.
|
||||
///
|
||||
/// Fails if another owner holds the lock or the existing PID names a running process.
|
||||
pub fn acquire(&mut self) -> Result<(), DaemonError> {
|
||||
if let Some(parent) = self.path.parent()
|
||||
&& !parent.exists()
|
||||
{
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
DaemonError::PidFile(format!(
|
||||
"cannot create directory {}: {}",
|
||||
parent.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let lock_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.mode(0o644)
|
||||
.open(&self.lock_path)
|
||||
.map_err(|error| {
|
||||
DaemonError::PidFile(format!(
|
||||
"cannot open lock file {}: {}",
|
||||
self.lock_path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
let lock_file =
|
||||
Flock::lock(lock_file, FlockArg::LockExclusiveNonblock).map_err(|(_, errno)| {
|
||||
if let Some(pid) = self.check_running().ok().flatten() {
|
||||
DaemonError::AlreadyRunning(pid)
|
||||
} else {
|
||||
DaemonError::PidFile(format!(
|
||||
"cannot lock {}: {}",
|
||||
self.lock_path.display(),
|
||||
errno
|
||||
))
|
||||
}
|
||||
})?;
|
||||
|
||||
if let Some(pid) = self.check_running()? {
|
||||
return Err(DaemonError::AlreadyRunning(pid));
|
||||
}
|
||||
|
||||
let mut pid_file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o644)
|
||||
.open(&self.path)
|
||||
.map_err(|error| {
|
||||
DaemonError::PidFile(format!("cannot open {}: {}", self.path.display(), error))
|
||||
})?;
|
||||
let pid = getpid();
|
||||
writeln!(pid_file, "{}", pid).map_err(|error| {
|
||||
DaemonError::PidFile(format!(
|
||||
"cannot write PID to {}: {}",
|
||||
self.path.display(),
|
||||
error
|
||||
))
|
||||
})?;
|
||||
|
||||
self.pid_file = Some(pid_file);
|
||||
self.lock_file = Some(lock_file);
|
||||
info!(pid = pid.as_raw(), path = %self.path.display(), "PID file created");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the PID file while retaining exclusive lock ownership until cleanup completes.
|
||||
pub fn release(&mut self) -> Result<(), DaemonError> {
|
||||
if self.lock_file.is_none() {
|
||||
self.pid_file = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let removal = match fs::remove_file(&self.path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(DaemonError::PidFile(format!(
|
||||
"cannot remove {}: {}",
|
||||
self.path.display(),
|
||||
error
|
||||
))),
|
||||
};
|
||||
self.pid_file = None;
|
||||
self.lock_file = None;
|
||||
removal?;
|
||||
debug!(path = %self.path.display(), "PID file removed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the path to this PID file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Returns open files whose ownership must follow the target runtime identity.
|
||||
pub(super) fn ownership_file_handles(&self) -> [Option<&File>; 2] {
|
||||
[self.pid_file.as_ref(), self.lock_file.as_deref()]
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PidFile {
|
||||
fn drop(&mut self) {
|
||||
if self.lock_file.is_some()
|
||||
&& let Err(error) = self.release()
|
||||
{
|
||||
warn!(error = %error, "Failed to clean up PID file on drop");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sibling_lock_path(path: &Path) -> PathBuf {
|
||||
let mut lock_path = path.as_os_str().to_os_string();
|
||||
lock_path.push(".lock");
|
||||
lock_path.into()
|
||||
}
|
||||
|
||||
fn read_pid_file_if_exists(path: &Path) -> Result<Option<i32>, DaemonError> {
|
||||
let mut file = match File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => {
|
||||
return Err(DaemonError::PidFile(format!(
|
||||
"cannot read {}: {}",
|
||||
path.display(),
|
||||
error
|
||||
)));
|
||||
}
|
||||
};
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents).map_err(|error| {
|
||||
DaemonError::PidFile(format!("cannot read {}: {}", path.display(), error))
|
||||
})?;
|
||||
let pid = contents
|
||||
.trim()
|
||||
.parse()
|
||||
.map_err(|_| DaemonError::PidFile(format!("invalid PID in {}", path.display())))?;
|
||||
Ok(Some(pid))
|
||||
}
|
||||
|
||||
/// Reads a PID from a PID file.
|
||||
#[allow(dead_code)]
|
||||
pub fn read_pid_file<P: AsRef<Path>>(path: P) -> Result<i32, DaemonError> {
|
||||
let path = path.as_ref();
|
||||
read_pid_file_if_exists(path)?.ok_or_else(|| {
|
||||
DaemonError::PidFile(format!(
|
||||
"cannot read {}: file does not exist",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a signal to the process specified in a PID file.
|
||||
#[allow(dead_code)]
|
||||
pub fn signal_pid_file<P: AsRef<Path>>(
|
||||
path: P,
|
||||
signal: nix::sys::signal::Signal,
|
||||
) -> Result<(), DaemonError> {
|
||||
let pid = read_pid_file(&path)?;
|
||||
if !is_process_running(pid) {
|
||||
return Err(DaemonError::PidFile(format!(
|
||||
"process {} from {} is not running",
|
||||
pid,
|
||||
path.as_ref().display()
|
||||
)));
|
||||
}
|
||||
nix::sys::signal::kill(Pid::from_raw(pid), signal)
|
||||
.map_err(|error| DaemonError::PidFile(format!("cannot signal process {}: {}", pid, error)))
|
||||
}
|
||||
|
||||
/// Daemon state derived from the PID file.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DaemonStatus {
|
||||
/// Daemon is running with the given PID.
|
||||
Running(i32),
|
||||
/// PID file exists but the named process is not running.
|
||||
Stale(i32),
|
||||
/// No readable PID file exists.
|
||||
NotRunning,
|
||||
}
|
||||
|
||||
/// Checks daemon status without modifying the PID or lock file.
|
||||
#[allow(dead_code)]
|
||||
pub fn check_status<P: AsRef<Path>>(path: P) -> DaemonStatus {
|
||||
let path = path.as_ref();
|
||||
match read_pid_file_if_exists(path) {
|
||||
Ok(Some(pid)) if is_process_running(pid) => DaemonStatus::Running(pid),
|
||||
Ok(Some(pid)) => DaemonStatus::Stale(pid),
|
||||
Ok(None) | Err(_) => DaemonStatus::NotRunning,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_process_running(pid: i32) -> bool {
|
||||
nix::sys::signal::kill(Pid::from_raw(pid), None).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
const HELPER_PID_PATH: &str = "TELEMT_PID_LOCK_HELPER_PATH";
|
||||
const HELPER_READY_PATH: &str = "TELEMT_PID_LOCK_HELPER_READY";
|
||||
const HELPER_STOP_PATH: &str = "TELEMT_PID_LOCK_HELPER_STOP";
|
||||
|
||||
fn wait_for_path(path: &Path, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if path.exists() {
|
||||
return true;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn wait_for_child(child: &mut Child, timeout: Duration) -> Option<std::process::ExitStatus> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
return Some(status);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_file_remains_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
|
||||
assert_send_sync::<PidFile>();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_holder_subprocess() {
|
||||
let Some(pid_path) = std::env::var_os(HELPER_PID_PATH) else {
|
||||
return;
|
||||
};
|
||||
let ready_path = PathBuf::from(std::env::var_os(HELPER_READY_PATH).unwrap());
|
||||
let stop_path = PathBuf::from(std::env::var_os(HELPER_STOP_PATH).unwrap());
|
||||
let mut pid_file = PidFile::new(PathBuf::from(pid_path));
|
||||
pid_file.acquire().unwrap();
|
||||
fs::write(&ready_path, b"ready").unwrap();
|
||||
assert!(wait_for_path(&stop_path, Duration::from_secs(10)));
|
||||
pid_file.release().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_sibling_lock_serializes_processes_after_pid_unlink() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pid_path = directory.path().join("telemt.pid");
|
||||
let lock_path = sibling_lock_path(&pid_path);
|
||||
let ready_path = directory.path().join("ready");
|
||||
let stop_path = directory.path().join("stop");
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"daemon::pid_file::tests::lock_holder_subprocess",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(HELPER_PID_PATH, &pid_path)
|
||||
.env(HELPER_READY_PATH, &ready_path)
|
||||
.env(HELPER_STOP_PATH, &stop_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
if !wait_for_path(&ready_path, Duration::from_secs(5)) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
panic!("PID lock holder did not become ready");
|
||||
}
|
||||
let lock_inode = fs::metadata(&lock_path).unwrap().ino();
|
||||
fs::remove_file(&pid_path).unwrap();
|
||||
|
||||
let mut contender = PidFile::new(&pid_path);
|
||||
assert!(contender.acquire().is_err());
|
||||
|
||||
fs::write(&stop_path, b"stop").unwrap();
|
||||
let status = wait_for_child(&mut child, Duration::from_secs(5)).unwrap_or_else(|| {
|
||||
let _ = child.kill();
|
||||
child.wait().unwrap()
|
||||
});
|
||||
assert!(status.success());
|
||||
assert_eq!(fs::metadata(&lock_path).unwrap().ino(), lock_inode);
|
||||
|
||||
contender.acquire().unwrap();
|
||||
assert_eq!(fs::metadata(&lock_path).unwrap().ino(), lock_inode);
|
||||
contender.release().unwrap();
|
||||
assert!(!pid_path.exists());
|
||||
assert!(lock_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_pid_checks_are_read_only() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pid_path = directory.path().join("telemt.pid");
|
||||
fs::write(&pid_path, b"2000000000\n").unwrap();
|
||||
let pid_file = PidFile::new(&pid_path);
|
||||
|
||||
assert_eq!(pid_file.check_running().unwrap(), None);
|
||||
assert_eq!(check_status(&pid_path), DaemonStatus::Stale(2_000_000_000));
|
||||
assert!(pid_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unowned_release_does_not_remove_pid_file() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pid_path = directory.path().join("telemt.pid");
|
||||
fs::write(&pid_path, b"2000000000\n").unwrap();
|
||||
let mut pid_file = PidFile::new(&pid_path);
|
||||
|
||||
pid_file.release().unwrap();
|
||||
|
||||
assert!(pid_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pid_file_release_keeps_lock_inode() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let pid_path = directory.path().join("telemt.pid");
|
||||
let lock_path = sibling_lock_path(&pid_path);
|
||||
let mut pid_file = PidFile::new(&pid_path);
|
||||
|
||||
pid_file.acquire().unwrap();
|
||||
assert!(
|
||||
pid_file
|
||||
.ownership_file_handles()
|
||||
.into_iter()
|
||||
.all(|file| file.is_some())
|
||||
);
|
||||
assert_eq!(read_pid_file(&pid_path).unwrap(), std::process::id() as i32);
|
||||
let lock_inode = fs::metadata(&lock_path).unwrap().ino();
|
||||
pid_file.release().unwrap();
|
||||
|
||||
assert!(!pid_path.exists());
|
||||
assert!(lock_path.exists());
|
||||
pid_file.acquire().unwrap();
|
||||
assert_eq!(fs::metadata(&lock_path).unwrap().ino(), lock_inode);
|
||||
pid_file.release().unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user