use std::process::Command;
use std::time::Duration;
pub const ENV_EXIT_WITH_PARENT: &str = "TRUSTY_EXIT_WITH_PARENT";
const STAMP_SEPARATOR: char = ':';
const POLL_INTERVAL: Duration = Duration::from_millis(250);
const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
const HANDLER_WAIT: Duration = Duration::from_secs(10);
pub const EXIT_CODE_PARENT_DIED_UNGRACEFUL: i32 = 87;
pub fn exit_with_parent(cmd: &mut Command) -> &mut Command {
cmd.env(ENV_EXIT_WITH_PARENT, own_stamp())
}
#[cfg(unix)]
fn own_stamp() -> String {
let me = identify(std::process::id());
match me.start {
Some(start) => format!("{}{STAMP_SEPARATOR}{start}", me.pid),
None => me.pid.to_string(),
}
}
#[cfg(not(unix))]
fn own_stamp() -> String {
std::process::id().to_string()
}
pub fn exit_with_parent_tokio(cmd: &mut tokio::process::Command) -> &mut tokio::process::Command {
cmd.env(ENV_EXIT_WITH_PARENT, own_stamp())
}
pub fn inherited_stamp() -> Option<String> {
std::env::var(ENV_EXIT_WITH_PARENT).ok()
}
#[cfg(unix)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ParentIdentity {
pid: u32,
start: Option<u64>,
}
#[cfg(unix)]
pub fn arm_from_env(tag: &str) -> Option<u32> {
let raw = std::env::var(ENV_EXIT_WITH_PARENT).ok()?;
let parent = parent_from_stamp(&raw, tag)?;
let ppid = unsafe { libc::getppid() } as u32;
arm(parent, (ppid == parent.pid).then_some(ppid), tag);
Some(parent.pid)
}
#[cfg(unix)]
fn parent_from_stamp(raw: &str, tag: &str) -> Option<ParentIdentity> {
let trimmed = raw.trim();
let (pid_text, start_text) = match trimmed.split_once(STAMP_SEPARATOR) {
Some((pid, start)) => (pid, Some(start)),
None => (trimmed, None),
};
let pid = match pid_text.parse::<u32>() {
Ok(pid) if pid > 1 => pid,
_ => {
eprintln!(
"[{tag}] {ENV_EXIT_WITH_PARENT}={raw:?} is not a watchable pid; \
parent-death watchdog not armed"
);
return None;
}
};
let start = match start_text {
None => None,
Some(text) => match text.parse::<u64>() {
Ok(start) => Some(start),
Err(_) => {
eprintln!(
"[{tag}] {ENV_EXIT_WITH_PARENT}={raw:?} carries no readable start \
time; watching pid {pid} by liveness alone"
);
None
}
},
};
Some(ParentIdentity { pid, start })
}
#[cfg(not(unix))]
pub fn arm_from_env(_tag: &str) -> Option<u32> {
None
}
#[cfg(unix)]
pub fn arm_for_named_parent(parent_pid: u32, tag: &str) {
if parent_pid <= 1 {
eprintln!(
"[{tag}] parent-death watchdog not armed: parent pid {parent_pid} is not watchable"
);
return;
}
let armed_ppid = unsafe { libc::getppid() } as u32;
arm(identify(parent_pid), Some(armed_ppid), tag);
}
#[cfg(not(unix))]
pub fn arm_for_named_parent(_parent_pid: u32, _tag: &str) {}
#[cfg(unix)]
fn identify(pid: u32) -> ParentIdentity {
ParentIdentity {
pid,
start: process_start_secs(pid),
}
}
#[cfg(unix)]
fn arm(parent: ParentIdentity, armed_ppid: Option<u32>, tag: &str) {
let owned_tag = tag.to_string();
let watching = if armed_ppid.is_some() {
"as a direct child"
} else {
"as a detached grandchild (liveness and start time only)"
};
eprintln!(
"[{tag}] parent-death watchdog armed for pid {} {watching}",
parent.pid
);
let spawned = std::thread::Builder::new()
.name("parent-death-watchdog".to_string())
.spawn(move || {
watch_parent(parent, armed_ppid, POLL_INTERVAL);
eprintln!(
"[{owned_tag}] parent pid {} is gone; shutting down rather than \
becoming an orphan",
parent.pid
);
if wait_for_term_handler(HANDLER_WAIT) {
unsafe { libc::raise(libc::SIGTERM) };
std::thread::sleep(SHUTDOWN_GRACE);
eprintln!(
"[{owned_tag}] graceful shutdown did not complete within \
{SHUTDOWN_GRACE:?}; forcing exit {EXIT_CODE_PARENT_DIED_UNGRACEFUL}"
);
} else {
eprintln!(
"[{owned_tag}] no SIGTERM handler was installed within \
{HANDLER_WAIT:?}, so raising it would kill this process on the \
default disposition; exiting {EXIT_CODE_PARENT_DIED_UNGRACEFUL} \
instead"
);
}
std::process::exit(EXIT_CODE_PARENT_DIED_UNGRACEFUL);
});
if let Err(e) = spawned {
eprintln!("[{tag}] could not start the parent-death watchdog thread: {e}");
}
}
#[cfg(unix)]
fn watch_parent(parent: ParentIdentity, armed_ppid: Option<u32>, interval: Duration) {
while !parent_is_gone(parent, armed_ppid) {
std::thread::sleep(interval);
}
}
#[cfg(unix)]
fn wait_for_term_handler(budget: Duration) -> bool {
let deadline = std::time::Instant::now() + budget;
while !term_handler_installed() {
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(POLL_INTERVAL);
}
true
}
#[cfg(unix)]
fn term_handler_installed() -> bool {
let mut current = std::mem::MaybeUninit::<libc::sigaction>::zeroed();
let rc = unsafe { libc::sigaction(libc::SIGTERM, std::ptr::null(), current.as_mut_ptr()) };
if rc != 0 {
return false;
}
unsafe { current.assume_init() }.sa_sigaction != libc::SIG_DFL
}
#[cfg(unix)]
fn parent_is_gone(parent: ParentIdentity, armed_ppid: Option<u32>) -> bool {
if let Some(armed) = armed_ppid {
if unsafe { libc::getppid() } as u32 != armed {
return true;
}
}
if !pid_alive(parent.pid) {
return true;
}
match (parent.start, process_start_secs(parent.pid)) {
(Some(armed), Some(now)) => armed != now,
_ => false,
}
}
#[cfg(unix)]
fn pid_alive(pid: u32) -> bool {
if pid == 0 || pid > i32::MAX as u32 {
return false;
}
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
if rc == 0 {
return true;
}
matches!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::EPERM)
)
}
#[cfg(target_os = "macos")]
fn process_start_secs(pid: u32) -> Option<u64> {
if pid == 0 || pid > i32::MAX as u32 {
return None;
}
let mut info = std::mem::MaybeUninit::<libc::proc_taskallinfo>::zeroed();
let size = std::mem::size_of::<libc::proc_taskallinfo>() as libc::c_int;
let written = unsafe {
libc::proc_pidinfo(
pid as libc::c_int,
libc::PROC_PIDTASKALLINFO,
0,
info.as_mut_ptr().cast(),
size,
)
};
if written < size {
return None;
}
Some(unsafe { info.assume_init() }.pbsd.pbi_start_tvsec)
}
#[cfg(all(unix, target_os = "linux"))]
fn process_start_secs(pid: u32) -> Option<u64> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(") ")?.1;
after_comm.split_whitespace().nth(19)?.parse().ok()
}
#[cfg(all(unix, not(target_os = "macos"), not(target_os = "linux")))]
fn process_start_secs(_pid: u32) -> Option<u64> {
None
}
#[cfg(test)]
mod tests {
use super::*;
fn stamped_value(cmd: &Command) -> String {
cmd.get_envs()
.find(|(k, _)| *k == std::ffi::OsStr::new(ENV_EXIT_WITH_PARENT))
.and_then(|(_, v)| v)
.expect("exit_with_parent must set the env var")
.to_string_lossy()
.into_owned()
}
#[cfg(unix)]
#[test]
fn the_stamp_round_trips_this_process_identity() {
let mut cmd = Command::new("true");
exit_with_parent(&mut cmd);
let parsed = parent_from_stamp(&stamped_value(&cmd), "test")
.expect("the stamp this crate writes must parse");
assert_eq!(
parsed,
identify(std::process::id()),
"the stamp must name this process's pid and start time"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn a_stamped_start_time_that_does_not_match_the_live_pid_reads_as_gone() {
let me = std::process::id();
let real = process_start_secs(me).expect("this platform must report a start time");
let impostor = format!("{me}{STAMP_SEPARATOR}{}", real.wrapping_add(1));
let parsed = parent_from_stamp(&impostor, "test").expect("a well-formed stamp must parse");
assert_eq!(parsed.pid, me, "the pid half must survive parsing");
assert_eq!(
parsed.start,
Some(real.wrapping_add(1)),
"the STAMPED start time must be kept verbatim, not re-read from the pid"
);
assert!(
parent_is_gone(parsed, None),
"a live pid whose stamped start time does not match it is a RECYCLED \
pid; arming on it would watch a process nobody asked for and might \
never fire"
);
}
#[cfg(unix)]
#[test]
fn a_stamp_without_a_start_time_degrades_to_liveness() {
let parsed = parent_from_stamp("4242", "test").expect("a bare pid must parse");
assert_eq!(
parsed,
ParentIdentity {
pid: 4242,
start: None
}
);
let malformed = parent_from_stamp("4242:not-a-time", "test")
.expect("a malformed start time must degrade, not refuse");
assert_eq!(
malformed.start, None,
"an unparseable start time must never be treated as a mismatch"
);
}
#[cfg(unix)]
#[test]
fn an_unwatchable_stamp_arms_nothing() {
for raw in ["0", "1", "", "not-a-pid", ":123"] {
assert!(
parent_from_stamp(raw, "test").is_none(),
"{raw:?} must not arm a watchdog"
);
}
}
#[cfg(unix)]
#[test]
fn an_installed_sigterm_handler_is_distinguishable_from_the_default() {
let mut saved = std::mem::MaybeUninit::<libc::sigaction>::zeroed();
assert_eq!(
unsafe { libc::sigaction(libc::SIGTERM, std::ptr::null(), saved.as_mut_ptr()) },
0,
"querying SIGTERM's disposition must succeed"
);
let saved = unsafe { saved.assume_init() };
unsafe { libc::signal(libc::SIGTERM, libc::SIG_IGN) };
let with_handler = term_handler_installed();
unsafe { libc::sigaction(libc::SIGTERM, &saved, std::ptr::null_mut()) };
assert!(
with_handler,
"a disposition other than SIG_DFL must read as an installed handler"
);
assert_eq!(
term_handler_installed(),
saved.sa_sigaction != libc::SIG_DFL,
"the restored disposition must read back as whatever it was"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn start_time_is_readable_for_this_process() {
assert!(
process_start_secs(std::process::id()).is_some(),
"this platform must report a process start time"
);
assert!(
process_start_secs(u32::MAX - 1).is_none(),
"a pid nothing holds must report no start time"
);
}
#[cfg(unix)]
#[test]
fn parent_is_gone_on_reparent() {
let real_ppid = unsafe { libc::getppid() } as u32;
let me = identify(std::process::id());
assert!(
!parent_is_gone(me, Some(real_ppid)),
"our own live pid under our real ppid must read as present"
);
assert!(
parent_is_gone(me, Some(real_ppid.wrapping_add(1))),
"a ppid that no longer matches the armed one must read as gone"
);
}
#[cfg(unix)]
#[test]
fn parent_is_gone_when_named_parent_exits() {
let armed_ppid = unsafe { libc::getppid() } as u32;
let mut fake_parent = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn fake parent");
let parent = identify(fake_parent.id());
assert!(
!parent_is_gone(parent, Some(armed_ppid)),
"a live named parent must read as present"
);
fake_parent.kill().expect("kill fake parent");
fake_parent.wait().expect("reap fake parent");
assert!(
parent_is_gone(parent, Some(armed_ppid)),
"a reaped named parent must read as gone"
);
}
#[cfg(unix)]
#[test]
fn grandchild_ignores_its_own_reparent() {
let mut live_parent = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn stand-in parent");
let parent = identify(live_parent.id());
assert!(
!parent_is_gone(parent, None),
"with no reparent prong armed, a live stamped pid must read as present"
);
live_parent.kill().expect("kill stand-in parent");
live_parent.wait().expect("reap stand-in parent");
assert!(
parent_is_gone(parent, None),
"the identity prong must still fire once the stamped pid is gone"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn recycled_pid_reads_as_gone() {
let live = identify(std::process::id());
assert!(
!parent_is_gone(live, None),
"the real start time must read as present"
);
let recycled = ParentIdentity {
pid: live.pid,
start: live.start.map(|s| s.wrapping_add(1)),
};
assert!(
parent_is_gone(recycled, None),
"a live pid whose start time moved is a DIFFERENT process and must \
read as gone; without this a recycled pid keeps a grandchild alive \
forever"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_start_time_favours_alive() {
let unreadable = ParentIdentity {
pid: std::process::id(),
start: None,
};
assert!(
!parent_is_gone(unreadable, None),
"a live pid with no armed start time must read as present, not gone"
);
let dead = ParentIdentity {
pid: u32::MAX - 1,
start: None,
};
assert!(
parent_is_gone(dead, None),
"abstaining on the start time must not also suppress the liveness probe"
);
}
#[cfg(unix)]
#[test]
fn watch_parent_returns_once_parent_dies() {
let armed_ppid = unsafe { libc::getppid() } as u32;
let mut fake_parent = Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn fake parent");
let parent = identify(fake_parent.id());
let handle = std::thread::spawn(move || {
watch_parent(parent, Some(armed_ppid), Duration::from_millis(25));
});
std::thread::sleep(Duration::from_millis(150));
assert!(
!handle.is_finished(),
"the watchdog must keep waiting while its parent is alive"
);
fake_parent.kill().expect("kill fake parent");
fake_parent.wait().expect("reap fake parent");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !handle.is_finished() {
assert!(
std::time::Instant::now() < deadline,
"the watchdog must return within 5s of its parent dying"
);
std::thread::sleep(Duration::from_millis(25));
}
handle.join().expect("watchdog thread must not panic");
}
}