#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(target_os = "linux")]
use processkit::Command;
#[cfg(target_os = "linux")]
fn pid_alive(pid: i32) -> bool {
let probed = unsafe { libc::kill(pid, 0) };
probed == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(target_os = "linux")]
fn reaped_or_gone(pid: i32) -> bool {
let mut status = 0i32;
let reaped = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
reaped == pid || reaped == -1
}
#[cfg(target_os = "linux")]
fn spawn_leaked_from_short_lived_thread(armed: bool) -> i32 {
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build runtime");
let pid = rt.block_on(async {
let mut cmd = Command::new("sleep").arg("300");
if armed {
cmd = cmd.kill_on_parent_death();
}
let process = cmd.start().await.expect("spawn sleeper");
let pid = process.pid().expect("sleeper pid") as i32;
std::mem::forget(process);
pid
});
drop(rt);
pid
})
.join()
.expect("spawner thread")
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[ignore = "leaks a real containment group to isolate the pdeathsig knob"]
async fn dead_spawner_takes_its_armed_child_down() {
let pid = spawn_leaked_from_short_lived_thread(true);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while !reaped_or_gone(pid) && std::time::Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(
reaped_or_gone(pid),
"armed child {pid} must die with its spawning thread"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[ignore = "leaks a real containment group to isolate the pdeathsig knob"]
async fn dead_spawner_leaves_an_unarmed_child_alive() {
let pid = spawn_leaked_from_short_lived_thread(false);
tokio::time::sleep(Duration::from_secs(1)).await;
let alive = pid_alive(pid);
unsafe {
libc::kill(pid, libc::SIGKILL);
let mut status = 0i32;
libc::waitpid(pid, &mut status, 0);
}
assert!(
alive,
"unarmed child {pid} must outlive its spawning thread"
);
}
mod owner_death {
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{Duration, Instant};
use processkit::{Command, ParentDeathCleanup};
const OWNER_FLAG: &str = "PK_PDEATH_OWNER";
const OWNER_DIR: &str = "PK_PDEATH_DIR";
const OWNER_TAG: &str = "PK_PDEATH_TAG";
const OWNER_TEST: &str = "parent_death::owner_death::owner_process";
static TAG_COUNTER: AtomicU32 = AtomicU32::new(0);
fn unique_tag() -> String {
format!(
"{}_{}",
std::process::id(),
TAG_COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
struct Paths {
ready: PathBuf,
error: PathBuf,
gc_pidfile: PathBuf,
}
impl Paths {
fn new(dir: &Path, tag: &str) -> Self {
let at = |name: &str| dir.join(format!("processkit_pdeath_{tag}_{name}"));
Self {
ready: at("ready"),
error: at("error"),
gc_pidfile: at("gc.pid"),
}
}
fn cleanup(&self) {
for p in [&self.ready, &self.error, &self.gc_pidfile] {
let _ = std::fs::remove_file(p);
}
let _ = std::fs::remove_file(self.ready.with_extension("tmp"));
}
}
struct Ready {
child_pid: u32,
grandchild_pid: u32,
}
impl Ready {
fn parse(text: &str) -> Option<Ready> {
let mut child_pid = None;
let mut grandchild_pid = None;
for line in text.lines() {
let Some((key, val)) = line.split_once('=') else {
continue;
};
match key.trim() {
"child_pid" => child_pid = val.trim().parse().ok(),
"grandchild_pid" => grandchild_pid = val.trim().parse().ok(),
_ => {}
}
}
Some(Ready {
child_pid: child_pid?,
grandchild_pid: grandchild_pid?,
})
}
}
fn pid_alive(pid: u32) -> bool {
let probed = unsafe { libc::kill(pid as i32, 0) };
probed == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
fn kill_pid(pid: u32) {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
#[cfg(target_os = "linux")]
fn is_running(pid: u32) -> bool {
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
return false; };
let Some((_, after_comm)) = stat.rsplit_once(')') else {
return false;
};
match after_comm.split_whitespace().next() {
Some("Z") => false, Some(_) => true, None => false,
}
}
async fn poll_bool(max: Duration, mut cond: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + max;
loop {
if cond() {
return true;
}
if Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn spawn_owner(dir: &Path, tag: &str) -> std::process::Child {
let exe = std::env::current_exe().expect("locate the integration-test binary");
std::process::Command::new(exe)
.args([OWNER_TEST, "--exact", "--ignored"])
.env(OWNER_FLAG, "1")
.env(OWNER_DIR, dir)
.env(OWNER_TAG, tag)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("re-exec this test binary in owner mode")
}
async fn wait_ready(paths: &Paths, timeout: Duration) -> Ready {
let deadline = Instant::now() + timeout;
loop {
if let Ok(err) = std::fs::read_to_string(&paths.error) {
let err = err.trim();
if !err.is_empty() {
panic!("parent-death owner reported a hard failure: {err}");
}
}
if let Ok(text) = std::fs::read_to_string(&paths.ready)
&& let Some(ready) = Ready::parse(&text)
{
return ready;
}
assert!(
Instant::now() < deadline,
"parent-death owner never became ready within {timeout:?} (no ready/error \
file — the re-exec filter may not have matched `{OWNER_TEST}`)"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[tokio::test]
#[ignore = "re-execs a real owner process and force-kills it; asserts the parent-death cleanup scope"]
async fn owner_sigkill_cleanup_matches_reported_scope() {
let dir = std::env::temp_dir();
let tag = unique_tag();
let paths = Paths::new(&dir, &tag);
paths.cleanup();
let mut owner = spawn_owner(&dir, &tag);
let owner_pid = owner.id();
let ready = wait_ready(&paths, Duration::from_secs(40)).await;
assert!(
pid_alive(ready.child_pid),
"child {} must be alive before the owner is killed",
ready.child_pid
);
assert!(
pid_alive(ready.grandchild_pid),
"grandchild {} must be alive before the owner is killed",
ready.grandchild_pid
);
owner.kill().expect("SIGKILL the owner");
owner.wait().expect("reap the killed owner");
assert!(
poll_bool(Duration::from_secs(10), || !pid_alive(owner_pid)).await,
"owner {owner_pid} must be gone after SIGKILL + reap"
);
let scope = Command::kill_on_parent_death_scope();
match scope {
ParentDeathCleanup::DirectChildOnly => {
#[cfg(target_os = "linux")]
{
let child_cleaned =
poll_bool(Duration::from_secs(15), || !is_running(ready.child_pid)).await;
let grandchild_survived = is_running(ready.grandchild_pid);
kill_pid(ready.child_pid);
kill_pid(ready.grandchild_pid);
paths.cleanup();
assert!(
child_cleaned,
"direct child {} must die when the owner is SIGKILLed (PDEATHSIG)",
ready.child_pid
);
assert!(
grandchild_survived,
"grandchild {} must survive direct-child-only cleanup (nothing tears the \
leaked cgroup/pgroup down)",
ready.grandchild_pid
);
}
}
ParentDeathCleanup::Unsupported => {
tokio::time::sleep(Duration::from_secs(2)).await;
let child_alive = pid_alive(ready.child_pid);
let grandchild_alive = pid_alive(ready.grandchild_pid);
kill_pid(ready.child_pid);
kill_pid(ready.grandchild_pid);
paths.cleanup();
assert!(
child_alive,
"child {} must survive where parent-death cleanup is Unsupported",
ready.child_pid
);
assert!(
grandchild_alive,
"grandchild {} must survive too where parent-death cleanup is Unsupported",
ready.grandchild_pid
);
}
other => {
kill_pid(ready.child_pid);
kill_pid(ready.grandchild_pid);
paths.cleanup();
panic!("unexpected parent-death cleanup scope on this unix target: {other:?}");
}
}
}
#[tokio::test]
#[ignore = "re-exec target: spawns child+grandchild only when the harness sets PK_PDEATH_OWNER"]
async fn owner_process() {
if std::env::var_os(OWNER_FLAG).is_none() {
return;
}
let dir = PathBuf::from(std::env::var(OWNER_DIR).expect("owner dir env"));
let tag = std::env::var(OWNER_TAG).expect("owner tag env");
let paths = Paths::new(&dir, &tag);
if let Err(msg) = run_owner(&paths).await {
let _ = std::fs::write(&paths.error, &msg);
panic!("parent-death owner failed: {msg}");
}
}
async fn run_owner(paths: &Paths) -> Result<(), String> {
let gc = paths.gc_pidfile.display();
let script = format!("sleep 300 & echo $! > '{gc}'; wait");
let running = Command::new("sh")
.args(["-c", &script])
.kill_on_parent_death()
.start()
.await
.map_err(|e| format!("start the direct child: {e}"))?;
let child_pid = running.pid().ok_or("the direct child reported no pid")?;
let grandchild_pid =
wait_for_pid(&paths.gc_pidfile, Duration::from_secs(30), "grandchild pid").await?;
let owner_pid = std::process::id();
write_atomic(
&paths.ready,
format!(
"owner_pid={owner_pid}\nchild_pid={child_pid}\ngrandchild_pid={grandchild_pid}\n"
),
)
.map_err(|e| format!("publish the ready file: {e}"))?;
tokio::time::sleep(Duration::from_secs(120)).await;
drop(running);
Ok(())
}
async fn wait_for_pid(path: &Path, timeout: Duration, what: &str) -> Result<u32, String> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(text) = std::fs::read_to_string(path)
&& let Ok(pid) = text.trim().parse::<u32>()
{
return Ok(pid);
}
if Instant::now() >= deadline {
return Err(format!("{what} was not published within {timeout:?}"));
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
fn write_atomic(path: &Path, content: String) -> std::io::Result<()> {
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, content)?;
std::fs::rename(&tmp, path)
}
}