#![deny(unsafe_code)]
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "linux")]
use linux as platform;
#[cfg(target_os = "macos")]
use macos as platform;
use std::{io, path::Path};
pub const PROCESS_IDENTITY_SUPPORTED: bool = cfg!(any(target_os = "linux", target_os = "macos"));
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FileIdentity {
pub device: u64,
pub inode: u64,
}
pub fn file_identity(path: &Path) -> Option<FileIdentity> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).ok().map(|metadata| FileIdentity {
device: metadata.dev(),
inode: metadata.ino(),
})
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Observation {
pub start_time: u64,
pub executable: Option<FileIdentity>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Signal {
Terminate,
Kill,
}
pub fn start_time(pid: u32) -> Option<u64> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
platform::start_time(pid)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = pid;
None
}
}
#[derive(Debug)]
pub struct Process {
pid: u32,
#[cfg(target_os = "linux")]
pidfd: Option<std::os::fd::OwnedFd>,
}
impl Process {
pub fn open(pid: u32) -> io::Result<Option<Self>> {
#[cfg(target_os = "linux")]
{
linux::open(pid).map(|opened| opened.map(|pidfd| Self { pid, pidfd }))
}
#[cfg(target_os = "macos")]
{
Ok(platform::exists(pid).then_some(Self { pid }))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = pid;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"process identity is not available on this platform",
))
}
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn signals_through_pidfd(&self) -> bool {
#[cfg(target_os = "linux")]
{
self.pidfd.is_some()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
pub fn observe(&self) -> Option<Observation> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
#[cfg(target_os = "linux")]
if !linux::pidfd_alive(self.pidfd.as_ref()) {
return None;
}
let start_time = platform::start_time(self.pid)?;
Some(Observation {
start_time,
executable: platform::executable_identity(self.pid),
})
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
pub fn signal(&self, signal: Signal) -> io::Result<bool> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
#[cfg(target_os = "linux")]
let result = linux::signal(self.pid, self.pidfd.as_ref(), signal);
#[cfg(target_os = "macos")]
let result = macos::signal(self.pid, signal);
match result {
Ok(()) => Ok(true),
Err(rustix::io::Errno::SRCH) => Ok(false),
Err(error) => Err(error.into()),
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = signal;
Err(io::Error::new(
io::ErrorKind::Unsupported,
"process signalling is not available on this platform",
))
}
}
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
use std::{
process::{Child, Command},
time::{Duration, Instant},
};
use super::*;
fn spawn_sleep() -> Child {
Command::new("sleep")
.arg("60")
.spawn()
.expect("spawn sleep")
}
fn sleep_identity() -> FileIdentity {
let path = ["/bin/sleep", "/usr/bin/sleep"]
.into_iter()
.find(|path| Path::new(path).exists())
.expect("sleep is installed");
file_identity(Path::new(path)).expect("stat sleep")
}
fn wait_for_executable(process: &Process, expected: FileIdentity) -> Observation {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let observation = process.observe().expect("child is alive");
if observation.executable == Some(expected) || Instant::now() > deadline {
return observation;
}
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn own_process_is_observable_with_its_own_image() {
let process = Process::open(std::process::id())
.expect("open own process")
.expect("own process exists");
let observation = process.observe().expect("own process is alive");
let own_image = file_identity(&std::env::current_exe().unwrap()).unwrap();
assert_eq!(observation.executable, Some(own_image));
assert_eq!(start_time(std::process::id()), Some(observation.start_time));
}
#[test]
fn child_start_time_is_stable_and_differs_from_ours() {
let mut child = spawn_sleep();
let pid = child.id();
let process = Process::open(pid).unwrap().unwrap();
let observation = wait_for_executable(&process, sleep_identity());
assert_eq!(observation.executable, Some(sleep_identity()));
assert_eq!(start_time(pid), Some(observation.start_time));
child.kill().unwrap();
child.wait().unwrap();
}
#[test]
fn a_signalled_and_unreaped_child_reads_as_exited() {
let mut child = spawn_sleep();
let process = Process::open(child.id()).unwrap().unwrap();
assert!(process.signal(Signal::Terminate).unwrap());
let deadline = Instant::now() + Duration::from_secs(5);
while process.observe().is_some() {
assert!(Instant::now() < deadline, "child still observed as alive");
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(start_time(child.id()), None);
child.wait().unwrap();
}
#[test]
fn a_reaped_child_cannot_be_opened_or_observed() {
let mut child = spawn_sleep();
let pid = child.id();
child.kill().unwrap();
child.wait().unwrap();
if let Some(process) = Process::open(pid).unwrap() {
if let Some(observation) = process.observe() {
assert_ne!(observation.executable, Some(sleep_identity()));
}
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_start_time_is_microseconds_since_the_epoch() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros() as u64;
let started = start_time(std::process::id()).unwrap();
assert!(started <= now, "start time {started} is after now {now}");
assert!(
now - started < 3_600 * 1_000_000,
"start time {started} is more than an hour before now {now}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_signals_go_through_a_pidfd() {
let mut child = spawn_sleep();
let process = Process::open(child.id()).unwrap().unwrap();
assert!(process.signals_through_pidfd());
child.kill().unwrap();
child.wait().unwrap();
assert!(!process.signal(Signal::Kill).unwrap());
}
}