use std::fs::File;
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, Weak};
use std::time::{Duration, Instant};
use crate::process::BridgeError;
pub(crate) const WATCHDOG_SCRIPT: &str =
"trap \"\" TERM; while read -r _; do :; done; kill -KILL -0";
const ACK_FD: RawFd = 9;
const ARM_TIMEOUT: Duration = Duration::from_secs(2);
const MONITOR_POLL: Duration = Duration::from_millis(100);
const WAIT_POLL: Duration = Duration::from_millis(10);
const REAP_TIMEOUT: Duration = Duration::from_secs(5);
static PIPE_SPAWN_LOCK: Mutex<()> = Mutex::new(());
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn armed_script(ack_fd: RawFd) -> String {
format!("trap \"\" TERM; printf '\\001' >&{ack_fd}; {WATCHDOG_SCRIPT}")
}
fn make_pipe() -> io::Result<(OwnedFd, OwnedFd)> {
let mut fds = [-1 as libc::c_int; 2];
#[cfg(any(target_os = "linux", target_os = "android"))]
{
if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
return Err(io::Error::last_os_error());
}
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
{
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return Err(io::Error::last_os_error());
}
for i in 0..2 {
if unsafe { libc::fcntl(fds[i], libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
let err = io::Error::last_os_error();
unsafe {
libc::close(fds[0]);
libc::close(fds[1]);
}
return Err(err);
}
}
}
Ok(unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) })
}
fn child_exited(pid: u32) -> bool {
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
)
};
if rc != 0 {
return true;
}
unsafe { info.si_pid() == pid as libc::pid_t }
}
fn read_ack(fd: RawFd, timeout: Duration) -> io::Result<u8> {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"watchdog arming acknowledgement timed out",
));
}
let millis = remaining.as_millis().min(i32::MAX as u128) as i32;
let mut pollfd = libc::pollfd {
fd,
events: libc::POLLIN,
revents: 0,
};
let rc = unsafe { libc::poll(&mut pollfd, 1, millis.max(1)) };
if rc < 0 {
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(err);
}
if rc == 0 {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"watchdog arming acknowledgement timed out",
));
}
let mut byte = [0u8; 1];
let n = unsafe { libc::read(fd, byte.as_mut_ptr().cast(), 1) };
if n == 1 {
return Ok(byte[0]);
}
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"watchdog closed the arming pipe before acknowledging",
));
}
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::Interrupted {
continue;
}
return Err(err);
}
}
fn abort_watchdog(child: &mut tokio::process::Child, pid: u32) {
unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
let deadline = Instant::now() + ARM_TIMEOUT;
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(_)) | Err(_) => return,
Ok(None) => {}
}
std::thread::sleep(WAIT_POLL);
}
}
fn supervision(message: impl Into<String>) -> BridgeError {
BridgeError::Supervision(message.into())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReapMode {
Blocking,
NonBlocking,
}
struct GroupShared {
pgid: Mutex<Option<i32>>,
write_end: Mutex<Option<OwnedFd>>,
watchdog: Mutex<Option<tokio::process::Child>>,
bridge: Mutex<Option<tokio::process::Child>>,
cleanup_exec: Mutex<()>,
stop: AtomicBool,
#[cfg(test)]
monitor_paused: AtomicBool,
}
impl GroupShared {
fn cleanup_locked(&self, wait: Option<Duration>, reap: ReapMode) -> Result<(), BridgeError> {
self.stop.store(true, Ordering::SeqCst);
let pgid = *lock(&self.pgid);
if let Some(pgid) = pgid {
unsafe { libc::kill(-pgid, libc::SIGTERM) };
let bridge_pid = lock(&self.bridge)
.as_ref()
.and_then(tokio::process::Child::id);
if let Some(grace) = wait
&& let Some(bridge_pid) = bridge_pid
{
let deadline = Instant::now() + grace;
while !child_exited(bridge_pid) && Instant::now() < deadline {
std::thread::sleep(WAIT_POLL);
}
}
unsafe { libc::kill(-pgid, libc::SIGKILL) };
*lock(&self.pgid) = None;
}
reap_slot(&self.bridge, "bridge", reap)?;
reap_slot(&self.watchdog, "watchdog", reap)?;
Ok(())
}
fn cleanup_sync(&self, wait: Option<Duration>, reap: ReapMode) -> Result<(), BridgeError> {
let _exec = lock(&self.cleanup_exec);
self.cleanup_locked(wait, reap)
}
pub(crate) async fn stop_group(self: &Arc<Self>, grace: Duration) -> Result<(), BridgeError> {
let shared = Arc::clone(self);
tokio::task::spawn_blocking(move || shared.cleanup_sync(Some(grace), ReapMode::Blocking))
.await
.map_err(|err| supervision(format!("cleanup worker join failed: {err}")))?
}
fn shutdown_classified(
&self,
wait: Duration,
) -> Result<Option<std::process::ExitStatus>, BridgeError> {
let bridge_pid = lock(&self.bridge)
.as_ref()
.and_then(tokio::process::Child::id);
if let Some(pid) = bridge_pid {
let deadline = Instant::now() + wait;
while !child_exited(pid) && Instant::now() < deadline {
std::thread::sleep(WAIT_POLL);
}
}
let _exec = lock(&self.cleanup_exec);
self.stop.store(true, Ordering::SeqCst);
let pgid = *lock(&self.pgid);
if let Some(pgid) = pgid {
unsafe { libc::kill(-pgid, libc::SIGTERM) };
unsafe { libc::kill(-pgid, libc::SIGKILL) };
*lock(&self.pgid) = None;
}
let status = match lock(&self.bridge).as_mut() {
Some(child) => child
.try_wait()
.map_err(|err| supervision(format!("reap bridge failed: {err}")))?,
None => None,
};
reap_slot(&self.watchdog, "watchdog", ReapMode::Blocking)?;
Ok(status)
}
}
fn reap_slot(
slot: &Mutex<Option<tokio::process::Child>>,
label: &str,
mode: ReapMode,
) -> Result<(), BridgeError> {
let mut guard = lock(slot);
let Some(child) = guard.as_mut() else {
return Ok(());
};
match mode {
ReapMode::NonBlocking => {
child
.try_wait()
.map_err(|err| supervision(format!("reap {label} failed: {err}")))?;
Ok(())
}
ReapMode::Blocking => {
let deadline = Instant::now() + REAP_TIMEOUT;
loop {
match child.try_wait() {
Ok(Some(_)) => return Ok(()),
Ok(None) => {}
Err(err) => return Err(supervision(format!("reap {label} failed: {err}"))),
}
if Instant::now() >= deadline {
tracing::error!(
child = label,
"bridge supervision: child reap timed out post-KILL"
);
return Err(supervision("reap timed out post-KILL"));
}
std::thread::sleep(WAIT_POLL);
}
}
}
}
fn monitor_loop(weak: Weak<GroupShared>, watchdog_pid: u32) {
loop {
std::thread::sleep(MONITOR_POLL);
let Some(shared) = weak.upgrade() else {
return;
};
if shared.stop.load(Ordering::SeqCst) {
return;
}
#[cfg(test)]
if shared.monitor_paused.load(Ordering::SeqCst) {
continue;
}
if child_exited(watchdog_pid) {
let _ = shared.cleanup_sync(None, ReapMode::Blocking);
tracing::error!("bridge watchdog exited early; group cleaned");
return;
}
}
}
pub(crate) struct GroupGuard {
shared: Arc<GroupShared>,
_monitor: Option<std::thread::JoinHandle<()>>,
}
impl GroupGuard {
pub(crate) fn establish() -> Result<Self, BridgeError> {
Self::establish_with_program("sh")
}
pub(crate) fn establish_with_program(program: &str) -> Result<Self, BridgeError> {
let spawn_lock = lock(&PIPE_SPAWN_LOCK);
let (live_read, live_write) =
make_pipe().map_err(|err| supervision(format!("liveness pipe failed: {err}")))?;
let (ack_read, ack_write) =
make_pipe().map_err(|err| supervision(format!("arming pipe failed: {err}")))?;
let ack_raw = ack_write.as_raw_fd();
let script = armed_script(ACK_FD);
let mut cmd = tokio::process::Command::new(program);
cmd.arg("-c")
.arg(script)
.stdin(Stdio::from(File::from(live_read)))
.stdout(Stdio::null())
.stderr(Stdio::null());
unsafe {
cmd.pre_exec(move || {
if libc::setpgid(0, 0) != 0 {
return Err(io::Error::last_os_error());
}
if ack_raw == ACK_FD {
if libc::fcntl(ACK_FD, libc::F_SETFD, 0) < 0 {
return Err(io::Error::last_os_error());
}
} else {
if libc::dup2(ack_raw, ACK_FD) < 0 {
return Err(io::Error::last_os_error());
}
libc::close(ack_raw);
}
Ok(())
});
}
let mut child = cmd
.spawn()
.map_err(|err| supervision(format!("watchdog spawn ({program}) failed: {err}")))?;
drop(ack_write);
drop(spawn_lock);
let watchdog_pid = match child.id() {
Some(pid) => pid,
None => {
return Err(supervision("watchdog exited before it could be tracked"));
}
};
let ack_file = File::from(ack_read);
match read_ack(ack_file.as_raw_fd(), ARM_TIMEOUT) {
Ok(1) => {}
Ok(byte) => {
abort_watchdog(&mut child, watchdog_pid);
return Err(supervision(format!(
"unexpected arming acknowledgement byte {byte}"
)));
}
Err(err) => {
abort_watchdog(&mut child, watchdog_pid);
return Err(supervision(format!("arming acknowledgement failed: {err}")));
}
}
drop(ack_file);
let watchdog_pid_i32 = watchdog_pid as i32;
let actual_pgid = unsafe { libc::getpgid(watchdog_pid_i32) };
if actual_pgid != watchdog_pid_i32 {
abort_watchdog(&mut child, watchdog_pid);
return Err(supervision(
"watchdog is not its own process group leader after arming",
));
}
if unsafe { libc::kill(watchdog_pid_i32, 0) } != 0 {
abort_watchdog(&mut child, watchdog_pid);
return Err(supervision("watchdog is not alive after arming"));
}
let shared = Arc::new(GroupShared {
pgid: Mutex::new(Some(watchdog_pid_i32)),
write_end: Mutex::new(Some(live_write)),
watchdog: Mutex::new(Some(child)),
bridge: Mutex::new(None),
cleanup_exec: Mutex::new(()),
stop: AtomicBool::new(false),
#[cfg(test)]
monitor_paused: AtomicBool::new(false),
});
match spawn_monitor(&shared, watchdog_pid) {
Ok(handle) => Ok(Self {
shared,
_monitor: Some(handle),
}),
Err(err) => {
let _ = shared.cleanup_sync(None, ReapMode::Blocking);
Err(supervision(format!(
"watchdog monitor thread spawn failed: {err}"
)))
}
}
}
pub(crate) fn spawn_bridge(&self, mut cmd: tokio::process::Command) -> Result<(), BridgeError> {
let _exec = lock(&self.shared.cleanup_exec);
let pgid = match *lock(&self.shared.pgid) {
Some(pgid) => pgid,
None => return Err(supervision("group already invalidated; refusing spawn")),
};
let watchdog_pid = lock(&self.shared.watchdog)
.as_ref()
.and_then(tokio::process::Child::id)
.ok_or_else(|| supervision("watchdog handle unavailable; refusing spawn"))?;
if child_exited(watchdog_pid) {
return Err(supervision("watchdog exited before bridge spawn"));
}
unsafe {
cmd.pre_exec(move || {
if libc::setpgid(0, pgid) != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
});
}
let child = cmd
.spawn()
.map_err(|err| supervision(format!("bridge spawn failed: {err}")))?;
*lock(&self.shared.bridge) = Some(child);
Ok(())
}
#[cfg(test)]
pub(crate) fn watchdog_pid(&self) -> Option<u32> {
lock(&self.shared.watchdog)
.as_ref()
.and_then(tokio::process::Child::id)
}
pub(crate) fn bridge_pid(&self) -> Option<u32> {
lock(&self.shared.bridge)
.as_ref()
.and_then(tokio::process::Child::id)
}
pub(crate) fn take_bridge_stdout(&self) -> Option<tokio::process::ChildStdout> {
lock(&self.shared.bridge)
.as_mut()
.and_then(|child| child.stdout.take())
}
pub(crate) fn take_bridge_stderr(&self) -> Option<tokio::process::ChildStderr> {
lock(&self.shared.bridge)
.as_mut()
.and_then(|child| child.stderr.take())
}
pub(crate) async fn stop_group(&self, grace: Duration) -> Result<(), BridgeError> {
self.shared.stop_group(grace).await
}
pub(crate) async fn shutdown_classified(
&self,
wait: Duration,
) -> Result<Option<std::process::ExitStatus>, BridgeError> {
let shared = Arc::clone(&self.shared);
tokio::task::spawn_blocking(move || shared.shutdown_classified(wait))
.await
.map_err(|err| supervision(format!("bridge shutdown worker join failed: {err}")))?
}
#[cfg(test)]
fn group_pgid(&self) -> Option<i32> {
*lock(&self.shared.pgid)
}
}
fn spawn_monitor(
shared: &Arc<GroupShared>,
watchdog_pid: u32,
) -> io::Result<std::thread::JoinHandle<()>> {
let weak = Arc::downgrade(shared);
std::thread::Builder::new()
.name("camel-bridge-watchdog-monitor".into())
.spawn(move || monitor_loop(weak, watchdog_pid))
}
impl Drop for GroupGuard {
fn drop(&mut self) {
let shared = Arc::clone(&self.shared);
shared.stop.store(true, Ordering::SeqCst);
match shared.cleanup_exec.try_lock() {
Ok(_exec) => {
if let Err(err) = shared.cleanup_locked(None, ReapMode::NonBlocking) {
tracing::error!(error = %err, "bridge supervision: drop cleanup failed");
}
}
Err(std::sync::TryLockError::WouldBlock) => {
}
Err(std::sync::TryLockError::Poisoned(poisoned)) => {
let _exec = poisoned.into_inner();
let _ = shared.cleanup_locked(None, ReapMode::NonBlocking);
}
}
if let Ok(mut write_end) = shared.write_end.lock() {
let _ = write_end.take();
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::process::Stdio;
use tokio::process::Command;
struct PauseGuard<'a>(&'a AtomicBool);
impl<'a> PauseGuard<'a> {
fn new(flag: &'a AtomicBool) -> Self {
flag.store(true, Ordering::SeqCst);
Self(flag)
}
}
impl Drop for PauseGuard<'_> {
fn drop(&mut self) {
self.0.store(false, Ordering::SeqCst);
}
}
fn sleep_cmd(secs: u64) -> Command {
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg(format!("sleep {secs}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
cmd
}
fn wait_until(timeout: Duration, mut condition: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + timeout;
loop {
if condition() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(WAIT_POLL);
}
}
#[cfg(target_os = "linux")]
fn process_state(pid: u32) -> Option<char> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (_, rest) = stat.rsplit_once(')')?;
rest.trim_start().chars().next()
}
#[cfg(target_os = "linux")]
fn pid_running(pid: u32) -> bool {
!matches!(process_state(pid), None | Some('Z') | Some('X'))
}
#[cfg(target_os = "linux")]
fn group_running(pgid: i32) -> bool {
let Ok(entries) = std::fs::read_dir("/proc") else {
return false;
};
for entry in entries.flatten() {
let Some(pid) = entry
.file_name()
.to_str()
.and_then(|n| n.parse::<u32>().ok())
else {
continue;
};
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
continue;
};
let Some((_, rest)) = stat.rsplit_once(')') else {
continue;
};
let fields: Vec<&str> = rest.split_whitespace().collect();
if fields.len() < 3 {
continue;
}
let state = fields[0].chars().next();
let Ok(pgrp) = fields[2].parse::<i32>() else {
continue;
};
if pgrp == pgid && !matches!(state, Some('Z') | Some('X')) {
return true;
}
}
false
}
#[cfg(not(target_os = "linux"))]
fn pid_running(pid: u32) -> bool {
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(not(target_os = "linux"))]
fn group_running(pgid: i32) -> bool {
unsafe { libc::kill(-pgid, 0) == 0 }
}
#[test]
fn watchdog_script_const_traps_term() {
assert!(WATCHDOG_SCRIPT.contains("trap \"\" TERM"));
assert!(WATCHDOG_SCRIPT.contains("kill -KILL -0"));
let armed = armed_script(ACK_FD);
let trap_at = armed
.find("trap \"\" TERM")
.expect("armed script must trap TERM first");
let ack_at = armed
.find("printf")
.expect("armed script must acknowledge after trapping");
assert!(trap_at < ack_at, "trap must precede the ack: {armed}");
}
fn has_multi_digit_redirect(script: &str) -> bool {
script.match_indices(">&").any(|(idx, _)| {
script[idx + 2..]
.bytes()
.take(2)
.filter(u8::is_ascii_digit)
.count()
>= 2
})
}
fn alternative_posix_shell() -> Option<&'static str> {
["dash", "mksh", "yash"].into_iter().find(|program| {
std::process::Command::new("sh")
.arg("-c")
.arg(format!("command -v {program}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
})
}
#[test]
fn ack_script_is_single_digit_fd() {
let armed = armed_script(ACK_FD);
assert!(
armed.contains(">&9"),
"armed script must acknowledge on a single-digit fd: {armed}"
);
assert!(
!has_multi_digit_redirect(&armed),
"armed script must not use a multi-digit redirection target: {armed}"
);
}
#[tokio::test]
async fn dash_shell_establishes_and_drops() {
let Some(program) = alternative_posix_shell() else {
println!("skipped: no alternative POSIX shell (dash/mksh/yash) on this system");
return;
};
let guard = GroupGuard::establish_with_program(program).unwrap_or_else(|err| {
panic!("{program} could not arm the watchdog (non-POSIX ack redirect?): {err}")
});
let pgid = guard.group_pgid().expect("pgid present after establish");
guard
.spawn_bridge(sleep_cmd(300))
.expect("spawn bridge under alternative-shell watchdog");
let bridge_pid = guard.bridge_pid().expect("bridge pid");
drop(guard);
assert!(
wait_until(Duration::from_secs(5), || {
!group_running(pgid) && !pid_running(bridge_pid)
}),
"{program} watchdog left a survivor after drop (pgid {pgid}, bridge {bridge_pid})"
);
}
#[tokio::test]
async fn establish_and_drop_kills_group() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid present after establish");
guard.spawn_bridge(sleep_cmd(300)).expect("spawn bridge");
let bridge_pid = guard.bridge_pid().expect("bridge pid");
drop(guard);
assert!(
wait_until(Duration::from_secs(5), || {
!group_running(pgid) && !pid_running(bridge_pid)
}),
"group {pgid} or bridge {bridge_pid} still running after drop"
);
}
#[tokio::test]
async fn group_sigterm_leaves_watchdog_alive() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid");
let watchdog_pid = guard.watchdog_pid().expect("watchdog pid");
assert_eq!(
unsafe { libc::kill(-pgid, libc::SIGTERM) },
0,
"send SIGTERM"
);
let died = wait_until(Duration::from_millis(300), || !pid_running(watchdog_pid));
assert!(!died, "watchdog must be TERM-immune after arming");
assert_eq!(
unsafe { libc::kill(watchdog_pid as i32, 0) },
0,
"watchdog must still answer after group SIGTERM"
);
}
#[tokio::test]
async fn no_signal_after_invalidation() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid");
guard
.shared
.stop_group(Duration::from_millis(100))
.await
.expect("first stop");
assert_eq!(guard.group_pgid(), None, "pgid must be invalidated");
guard
.shared
.stop_group(Duration::from_millis(100))
.await
.expect("second stop is a no-op success");
assert!(!group_running(pgid));
}
#[tokio::test]
async fn early_watchdog_death_surfaces() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid");
guard.spawn_bridge(sleep_cmd(300)).expect("spawn bridge");
let bridge_pid = guard.bridge_pid().expect("bridge pid");
let watchdog_pid = guard.watchdog_pid().expect("watchdog pid");
assert_eq!(unsafe { libc::kill(watchdog_pid as i32, libc::SIGKILL) }, 0);
assert!(
wait_until(Duration::from_secs(5), || {
guard.group_pgid().is_none() && !group_running(pgid) && !pid_running(bridge_pid)
}),
"early watchdog death left group {pgid} or bridge {bridge_pid} uncleaned within 5s"
);
}
#[tokio::test]
async fn establish_failure_is_fail_closed() {
if let Err(BridgeError::Supervision(_)) =
GroupGuard::establish_with_program("/nonexistent/sh")
{
return;
}
panic!("expected fail-closed Supervision error");
}
#[tokio::test]
async fn graceful_stop_reaps_both_children() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid");
guard.spawn_bridge(sleep_cmd(30)).expect("spawn bridge");
guard
.shared
.stop_group(Duration::from_secs(2))
.await
.expect("graceful stop");
assert_eq!(guard.group_pgid(), None);
let bridge_reaped = lock(&guard.shared.bridge)
.as_mut()
.expect("bridge handle retained")
.try_wait()
.expect("bridge try_wait")
.is_some();
let watchdog_reaped = lock(&guard.shared.watchdog)
.as_mut()
.expect("watchdog handle retained")
.try_wait()
.expect("watchdog try_wait")
.is_some();
assert!(bridge_reaped, "bridge must be reaped, not left a zombie");
assert!(
watchdog_reaped,
"watchdog must be reaped, not left a zombie"
);
assert!(!group_running(pgid), "no group member may remain running");
}
#[tokio::test]
async fn drop_reaps_without_signals() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid");
guard.spawn_bridge(sleep_cmd(300)).expect("spawn bridge");
let bridge_pid = guard.bridge_pid().expect("bridge pid");
let watchdog_pid = guard.watchdog_pid().expect("watchdog pid");
drop(guard);
assert!(
wait_until(Duration::from_secs(5), || {
!group_running(pgid) && !pid_running(bridge_pid) && !pid_running(watchdog_pid)
}),
"survivors after drop: bridge {bridge_pid}, watchdog {watchdog_pid}, pgid {pgid}"
);
}
#[tokio::test]
async fn spawn_after_watchdog_death_fails_closed() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid");
let watchdog_pid = guard.watchdog_pid().expect("watchdog pid");
let pause = PauseGuard::new(&guard.shared.monitor_paused);
assert_eq!(unsafe { libc::kill(watchdog_pid as i32, libc::SIGKILL) }, 0);
assert!(
wait_until(Duration::from_secs(2), || child_exited(watchdog_pid)),
"watchdog exit was not observed (zombie state not reached)"
);
assert_eq!(
guard.group_pgid(),
Some(pgid),
"pgid must still be Some while the monitor is paused"
);
let err = guard
.spawn_bridge(sleep_cmd(300))
.expect_err("zombie watchdog must reject a bridge spawn");
assert!(matches!(err, BridgeError::Supervision(_)));
drop(pause);
assert!(
wait_until(Duration::from_secs(2), || {
guard.group_pgid().is_none() && !pid_running(watchdog_pid)
}),
"monitor did not clean the group after unpause"
);
}
#[test]
fn zombie_probe_contract() {
let mut child = std::process::Command::new("sh")
.arg("-c")
.arg("exit 0")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn zombie fixture");
let pid = child.id();
let mut probe_error: Option<io::Error> = None;
let observed = wait_until(Duration::from_secs(5), || {
if probe_error.is_some() {
return false;
}
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
)
};
if rc < 0 {
let err = io::Error::last_os_error();
if err.kind() == io::ErrorKind::Interrupted {
return false;
}
probe_error = Some(err);
return false;
}
unsafe { info.si_pid() == pid as libc::pid_t }
});
assert!(
observed,
"strict waitid did not observe the unreaped zombie within 5s: {probe_error:?}"
);
#[cfg(target_os = "linux")]
assert!(
!pid_running(pid),
"linux helper must treat a zombie as a pending reap, not a survivor"
);
#[cfg(target_os = "macos")]
assert!(
pid_running(pid),
"bare probe answers 0 for a zombie; tests must wait for the reap"
);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
}
let _status = child.wait().expect("reap zombie fixture");
assert!(
!pid_running(pid),
"reaped child must be gone on every platform"
);
}
#[tokio::test]
async fn monitor_reaps_after_early_watchdog_death() {
let guard = GroupGuard::establish().expect("establish guard");
let pgid = guard.group_pgid().expect("pgid");
guard.spawn_bridge(sleep_cmd(300)).expect("spawn bridge");
let bridge_pid = guard.bridge_pid().expect("bridge pid");
let watchdog_pid = guard.watchdog_pid().expect("watchdog pid");
assert_eq!(unsafe { libc::kill(watchdog_pid as i32, libc::SIGKILL) }, 0);
assert!(
wait_until(Duration::from_secs(5), || {
guard.group_pgid().is_none()
&& lock(&guard.shared.bridge)
.as_ref()
.and_then(tokio::process::Child::id)
.is_none()
&& lock(&guard.shared.watchdog)
.as_ref()
.and_then(tokio::process::Child::id)
.is_none()
}),
"monitor left an unreaped child after early watchdog death (permanent zombie): \
bridge {bridge_pid}, watchdog {watchdog_pid}, pgid {pgid}"
);
}
}