use boxlite_shared::errors::{BoxliteError, BoxliteResult};
use std::path::Path;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessExit {
Code(i32),
Unknown,
}
pub struct ProcessMonitor {
pid: u32,
}
impl ProcessMonitor {
pub fn new(pid: u32) -> Self {
Self { pid }
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn is_alive(&self) -> bool {
is_process_alive(self.pid)
}
pub fn try_wait(&self) -> Option<ProcessExit> {
let mut status: i32 = 0;
let result = unsafe { libc::waitpid(self.pid as i32, &mut status, libc::WNOHANG) };
if result > 0 {
Some(ProcessExit::Code(decode_wait_status(status)))
} else if result < 0 && !self.is_alive() {
Some(ProcessExit::Unknown)
} else {
None
}
}
pub async fn wait_for_exit(&self) -> ProcessExit {
let poll_interval = Duration::from_millis(500);
loop {
if let Some(exit) = self.try_wait() {
return exit;
}
tokio::time::sleep(poll_interval).await;
}
}
}
fn decode_wait_status(status: i32) -> i32 {
if libc::WIFEXITED(status) {
libc::WEXITSTATUS(status)
} else if libc::WIFSIGNALED(status) {
128 + libc::WTERMSIG(status) } else {
-1 }
}
pub fn read_pid_file(path: &Path) -> BoxliteResult<u32> {
let content = std::fs::read_to_string(path).map_err(|e| {
BoxliteError::Storage(format!("Failed to read PID file {}: {}", path.display(), e))
})?;
content.trim().parse::<u32>().map_err(|e| {
BoxliteError::Storage(format!(
"Invalid PID in file {}: '{}' - {}",
path.display(),
content.trim(),
e
))
})
}
pub fn kill_process(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 || !is_process_alive(pid) }
}
pub fn is_process_alive(pid: u32) -> bool {
if unsafe { libc::kill(pid as i32, 0) } != 0 {
return false;
}
!is_process_zombie(pid)
}
fn is_process_zombie(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
is_process_zombie_linux(pid)
}
#[cfg(target_os = "macos")]
{
is_process_zombie_macos(pid)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
false
}
}
#[cfg(target_os = "linux")]
fn is_process_zombie_linux(pid: u32) -> bool {
let status_path = format!("/proc/{pid}/status");
let Ok(status) = std::fs::read_to_string(status_path) else {
return false;
};
status.lines().find_map(|line| {
line.strip_prefix("State:")
.and_then(|state| state.trim_start().chars().next())
}) == Some('Z')
}
#[cfg(target_os = "macos")]
fn is_process_zombie_macos(pid: u32) -> bool {
let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::uninit();
let expected_size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
let bytes = unsafe {
libc::proc_pidinfo(
pid as i32,
libc::PROC_PIDTBSDINFO,
0,
info.as_mut_ptr().cast(),
expected_size,
)
};
if bytes != expected_size {
if bytes != 0 {
return false;
}
let mut path_buf = [0 as libc::c_char; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
let path_len = unsafe {
libc::proc_pidpath(
pid as i32,
path_buf.as_mut_ptr().cast(),
path_buf.len() as u32,
)
};
return path_len == 0;
}
let info = unsafe { info.assume_init() };
info.pbi_status == libc::SZOMB
}
pub fn is_same_process(pid: u32, box_id: &str) -> bool {
#[cfg(target_os = "linux")]
{
is_same_process_linux(pid, box_id)
}
#[cfg(target_os = "macos")]
{
let _ = box_id; is_same_process_macos(pid)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
is_process_alive(pid)
}
}
#[cfg(target_os = "linux")]
fn is_same_process_linux(pid: u32, box_id: &str) -> bool {
use std::fs;
let cmdline_path = format!("/proc/{}/cmdline", pid);
match fs::read_to_string(&cmdline_path) {
Ok(cmdline) => {
let args: Vec<&str> = cmdline.split('\0').collect();
args.iter().any(|arg| arg.contains("boxlite-shim")) && cmdline.contains(box_id)
}
Err(_) => false, }
}
#[cfg(target_os = "macos")]
fn is_same_process_macos(pid: u32) -> bool {
use sysinfo::{Pid, System};
let mut sys = System::new();
let pid_obj = Pid::from_u32(pid);
sys.refresh_process(pid_obj);
if let Some(process) = sys.process(pid_obj) {
let name = process.name();
name.contains("boxlite-shim")
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_process_alive_current() {
let current_pid = std::process::id();
assert!(is_process_alive(current_pid));
}
#[test]
fn test_is_process_alive_invalid() {
assert!(!is_process_alive(999999999));
assert!(!is_process_alive(888888888));
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn test_is_process_alive_false_for_zombie() {
use std::time::{Duration, Instant};
struct PidReaper {
pid: libc::pid_t,
}
impl Drop for PidReaper {
fn drop(&mut self) {
let mut status = 0;
let _ = unsafe { libc::waitpid(self.pid, &mut status, 0) };
}
}
let child_pid = unsafe { libc::fork() };
assert!(child_pid >= 0, "fork() failed");
if child_pid == 0 {
unsafe { libc::_exit(0) };
}
let _reaper = PidReaper { pid: child_pid };
let child_pid = child_pid as u32;
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline {
let raw_exists = unsafe { libc::kill(child_pid as i32, 0) == 0 };
if !raw_exists {
return;
}
if !is_process_alive(child_pid) {
return;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("Exited child remained reported as alive while still existing");
}
#[test]
fn test_is_same_process_current() {
let current_pid = std::process::id();
let result = is_same_process(current_pid, "test123");
#[cfg(any(target_os = "linux", target_os = "macos"))]
assert!(!result);
}
#[test]
fn test_is_same_process_invalid() {
assert!(!is_same_process(0, "test123"));
assert!(!is_same_process(u32::MAX, "test123"));
}
#[test]
fn test_read_pid_file_valid() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "12345").unwrap();
let pid = read_pid_file(file.path()).expect("Should parse valid PID");
assert_eq!(pid, 12345);
}
#[test]
fn test_read_pid_file_no_newline() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
write!(file, "67890").unwrap();
let pid = read_pid_file(file.path()).expect("Should parse PID without newline");
assert_eq!(pid, 67890);
}
#[test]
fn test_read_pid_file_invalid() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "not-a-pid").unwrap();
let result = read_pid_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_read_pid_file_missing() {
let result = read_pid_file(Path::new("/nonexistent/path/to/pid.file"));
assert!(result.is_err());
}
#[test]
fn test_decode_wait_status_normal_exit() {
let status = 0 << 8; assert_eq!(decode_wait_status(status), 0);
let status = 1 << 8; assert_eq!(decode_wait_status(status), 1);
let status = 42 << 8; assert_eq!(decode_wait_status(status), 42);
}
#[test]
fn test_decode_wait_status_signal() {
let sigterm = libc::SIGTERM; assert_eq!(decode_wait_status(sigterm), 128 + sigterm);
let sigkill = libc::SIGKILL; assert_eq!(decode_wait_status(sigkill), 128 + sigkill);
let sigabrt = libc::SIGABRT; assert_eq!(decode_wait_status(sigabrt), 128 + sigabrt);
}
#[test]
fn test_process_monitor_current_process() {
let monitor = ProcessMonitor::new(std::process::id());
assert!(monitor.is_alive());
assert!(monitor.try_wait().is_none());
}
#[test]
fn test_process_monitor_invalid_pid() {
let monitor = ProcessMonitor::new(999999999);
assert!(!monitor.is_alive());
assert_eq!(monitor.try_wait(), Some(ProcessExit::Unknown));
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
#[allow(clippy::zombie_processes)] fn test_process_monitor_child_exit() {
use std::process::Command;
let child = Command::new("sh")
.arg("-c")
.arg("exit 42")
.spawn()
.expect("Failed to spawn child");
let monitor = ProcessMonitor::new(child.id());
std::thread::sleep(std::time::Duration::from_millis(100));
match monitor.try_wait() {
Some(ProcessExit::Code(code)) => assert_eq!(code, 42),
other => panic!("Expected ProcessExit::Code(42), got {:?}", other),
}
}
#[test]
fn test_read_pid_file_with_whitespace() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
write!(file, " 12345\n\n").unwrap();
let pid = read_pid_file(file.path()).expect("Should parse PID with whitespace");
assert_eq!(pid, 12345);
}
#[test]
fn test_read_pid_file_empty_rejected() {
use tempfile::NamedTempFile;
let file = NamedTempFile::new().unwrap();
let result = read_pid_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_read_pid_file_large_pid() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
write!(file, "4194304").unwrap();
let pid = read_pid_file(file.path()).expect("Should parse max Linux PID");
assert_eq!(pid, 4194304);
}
#[test]
fn test_read_pid_file_negative_rejected() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
write!(file, "-1").unwrap();
let result = read_pid_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_read_pid_file_overflow_rejected() {
use std::io::Write;
use tempfile::NamedTempFile;
let mut file = NamedTempFile::new().unwrap();
write!(file, "99999999999").unwrap();
let result = read_pid_file(file.path());
assert!(result.is_err());
}
#[test]
fn test_process_exit_equality() {
assert_eq!(ProcessExit::Code(0), ProcessExit::Code(0));
assert_eq!(ProcessExit::Code(1), ProcessExit::Code(1));
assert_eq!(ProcessExit::Unknown, ProcessExit::Unknown);
assert_ne!(ProcessExit::Code(0), ProcessExit::Code(1));
assert_ne!(ProcessExit::Code(0), ProcessExit::Unknown);
}
}