use std::time::Duration;
#[cfg(target_os = "linux")]
use processkit::Mechanism;
use processkit::{Command, ProcessGroup, Signal};
use crate::common::*;
#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real subprocess and signals it"]
async fn unix_signal_reaches_the_tree() {
use tokio_stream::StreamExt;
let group = ProcessGroup::new().expect("create group");
let cmd = Command::new("sh").args([
"-c",
"trap 'echo got-hup' HUP; echo ready; while :; do sleep 0.1; done",
]);
let mut process = group.start(&cmd).await.expect("start trap child");
let mut lines = process.stdout_lines().unwrap();
let ready = tokio::time::timeout(Duration::from_secs(10), lines.next())
.await
.expect("readiness line in time")
.expect("readiness line");
assert!(ready.contains("ready"), "line: {ready:?}");
group.signal(Signal::Hup).expect("broadcast SIGHUP");
let got = tokio::time::timeout(Duration::from_secs(10), lines.next())
.await
.expect("trap line in time")
.expect("trap line");
assert!(got.contains("got-hup"), "line: {got:?}");
}
#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real subprocess and freezes it"]
async fn unix_suspend_freezes_progress() {
use tokio_stream::StreamExt;
let group = ProcessGroup::new().expect("create group");
let cmd = Command::new("sh").args([
"-c",
"i=0; while :; do i=$((i+1)); echo $i; sleep 0.05; done",
]);
let mut process = group.start(&cmd).await.expect("start ticker");
let mut lines = process.stdout_lines().unwrap();
tokio::time::timeout(Duration::from_secs(10), lines.next())
.await
.expect("first tick in time")
.expect("first tick");
group.suspend().expect("suspend");
tokio::time::sleep(Duration::from_millis(200)).await;
while let Ok(Some(_)) = tokio::time::timeout(Duration::from_millis(100), lines.next()).await {}
let stalled = tokio::time::timeout(Duration::from_millis(400), lines.next()).await;
assert!(stalled.is_err(), "frozen tree kept producing output");
group.resume().expect("resume");
let resumed = tokio::time::timeout(Duration::from_secs(10), lines.next()).await;
assert!(
resumed.is_ok_and(|line| line.is_some()),
"tree did not resume ticking"
);
}
#[cfg(unix)]
#[test]
#[ignore = "creates an OS job/cgroup"]
fn signal_on_empty_group_is_ok() {
let group = ProcessGroup::new().expect("create group");
group.signal(Signal::Term).expect("signal on empty group");
group.suspend().expect("suspend on empty group");
group.resume().expect("resume on empty group");
}
#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a real subprocess; cross-checks soft_stop_scope against signal"]
async fn unix_soft_stop_scope_is_whole_tree_and_matches_signal() {
use processkit::SoftStopScope;
let group = ProcessGroup::new().expect("create group");
assert_eq!(
group.soft_stop_scope(),
SoftStopScope::WholeTree,
"a Unix group always offers a whole-tree soft stop"
);
group
.signal(Signal::Term)
.expect("Term on an empty Unix group is Ok — matching the WholeTree report");
let cmd = Command::new("sh").args(["-c", "while :; do sleep 0.1; done"]);
let _run = group.start(&cmd).await.expect("start sleeper");
assert_eq!(
group.soft_stop_scope(),
SoftStopScope::WholeTree,
"the POSIX/cgroup soft stop reaches the whole tree with a live member too"
);
group
.signal(Signal::Term)
.expect("a real Term reaches the tree, matching the whole-tree report");
}
#[cfg(unix)]
#[tokio::test]
#[ignore = "spawns a fork storm and broadcasts SIGKILL to the group"]
async fn unix_fork_storm_is_swept_by_group_broadcast() {
let tmp = std::env::temp_dir();
let dir = tmp.join(format!("processkit_fork_storm_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create storm dir");
let script = r#"i=0; while [ "$i" -lt 40 ]; do sh -c 'echo live > "$PK_DIR/$$"; exec sleep 30' & i=$((i + 1)); sleep 0.02; done; wait"#;
let group = ProcessGroup::new().expect("create group");
let forker = group
.start(&Command::new("sh").args(["-c", script]).env("PK_DIR", &dir))
.await
.expect("fork-storm leader spawns");
let alive_registered = || -> usize {
let Ok(entries) = std::fs::read_dir(&dir) else {
return 0;
};
entries
.flatten()
.filter_map(|e| e.file_name().to_string_lossy().parse::<i32>().ok())
.filter(|&pid| unsafe { libc::kill(pid, 0) } == 0)
.count()
};
poll_until(
Duration::from_secs(5),
Duration::from_millis(20),
"fork storm never ramped up",
|| alive_registered() >= 6,
)
.await;
let before = alive_registered();
assert!(
before >= 4,
"expected a live fork storm, saw {before} grandchildren"
);
group
.signal(Signal::Kill)
.expect("broadcast SIGKILL to the group");
poll_until(
Duration::from_secs(5),
Duration::from_millis(50),
"one whole-group sweep did not catch the bulk of the storm",
|| alive_registered() * 2 <= before,
)
.await;
let survived_one_sweep = alive_registered();
completes_within(Duration::from_secs(10), "leader reap", forker.wait())
.await
.expect("leader waits");
group.kill_all().expect("final whole-tree sweep");
poll_until(
Duration::from_secs(10),
Duration::from_millis(50),
"fork storm did not fully drain — a grandchild escaped the group",
|| group.members().is_ok_and(|m| m.is_empty()),
)
.await;
eprintln!(
"fork storm: {before} grandchildren alive before broadcast, \
{survived_one_sweep} survived one sweep, group fully drained after teardown"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(windows)]
#[test]
#[ignore = "creates an OS job"]
fn windows_signal_non_kill_is_unsupported() {
let group = ProcessGroup::new().expect("create group");
for sig in [Signal::Term, Signal::Hup, Signal::Other(9)] {
let err = group
.signal(sig)
.expect_err("a non-Kill signal with no soft-close target must be rejected on Windows");
assert!(
matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
"expected ErrorReason::Unsupported for {sig:?}, got {err:?}"
);
}
}
#[cfg(windows)]
#[test]
#[ignore = "creates an OS job"]
fn windows_soft_stop_scope_on_empty_group_is_unsupported() {
use processkit::SoftStopScope;
let group = ProcessGroup::new().expect("create group");
assert_eq!(
group.soft_stop_scope(),
SoftStopScope::Unsupported,
"an empty Windows group has no soft-stop target"
);
let err = group
.signal(Signal::Term)
.expect_err("no soft-close target on an empty Windows group");
assert!(
matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
"the report and the real signal outcome agree on Unsupported, got {err:?}"
);
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real opt-in console child; probes soft-stop availability at the ProcessGroup API"]
async fn windows_soft_stop_scope_reports_opt_in_members_for_a_live_child() {
use processkit::SoftStopScope;
let group = ProcessGroup::new().expect("create group");
let _run = group
.start(
&Command::new("ping")
.args(["-n", "30", "127.0.0.1"])
.windows_graceful_ctrl_break(),
)
.await
.expect("start opt-in console child");
assert_eq!(
group.soft_stop_scope(),
SoftStopScope::OptInMembers,
"a live opt-in console child makes a soft stop available"
);
group
.signal(Signal::Term)
.expect("a real Term reaches the live opt-in leader the probe reported");
group.kill_all().expect("tear the tree down");
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real subprocess and kills it via Signal::Kill"]
async fn windows_signal_kill_kills_tree() {
let group = ProcessGroup::new().expect("create group");
let process = group.start(&sleeper()).await.expect("start sleeper");
assert!(process.pid().is_some());
group
.signal(Signal::Kill)
.expect("Signal::Kill maps to job terminate");
completes_within(Duration::from_secs(5), "Signal::Kill reap", process.wait())
.await
.expect("wait");
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real subprocess and suspends/resumes its threads"]
async fn windows_suspend_resume_stalls_output() {
use tokio_stream::StreamExt;
let group = ProcessGroup::new().expect("create group");
let cmd = Command::new("ping").args(["-n", "30", "127.0.0.1"]);
let mut process = group.start(&cmd).await.expect("start ping");
let mut lines = process.stdout_lines().unwrap();
tokio::time::timeout(Duration::from_secs(10), lines.next())
.await
.expect("first ping line in time")
.expect("first ping line");
group.suspend().expect("suspend");
tokio::time::sleep(Duration::from_millis(200)).await;
while let Ok(Some(_)) = tokio::time::timeout(Duration::from_millis(100), lines.next()).await {}
let stalled = tokio::time::timeout(Duration::from_secs(2), lines.next()).await;
assert!(stalled.is_err(), "suspended tree kept producing output");
group.resume().expect("resume");
let resumed = tokio::time::timeout(Duration::from_secs(10), lines.next()).await;
assert!(
resumed.is_ok_and(|line| line.is_some()),
"tree did not resume output"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess outside the group and adopts it"]
async fn adopt_brings_an_external_child_under_containment() {
let mut cmd = if cfg!(windows) {
let mut c = tokio::process::Command::new("ping");
c.args(["-n", "30", "127.0.0.1"]);
c
} else {
let mut c = tokio::process::Command::new("sleep");
c.arg("30");
c
};
cmd.stdout(std::process::Stdio::null());
let mut child = cmd.spawn().expect("spawn external child");
let group = ProcessGroup::new().expect("create group");
group.adopt(&child).expect("adopt external child");
group.kill_all().expect("hard-kill the adopted tree");
let _ = completes_within(Duration::from_secs(5), "adopted child reap", child.wait()).await;
}
#[tokio::test]
#[ignore = "spawns real subprocesses and lists the group's members"]
async fn members_lists_live_children() {
let group = ProcessGroup::new().expect("create group");
let _a = group.start(&sleeper()).await.expect("start first sleeper");
let _b = group.start(&sleeper()).await.expect("start second sleeper");
let members = group.members().expect("members");
assert!(members.len() >= 2, "members: {members:?}");
}
#[tokio::test]
#[ignore = "spawns real subprocesses and watches the member list shrink"]
async fn members_shrinks_when_a_child_dies() {
let group = ProcessGroup::new().expect("create group");
let _keep = group.start(&sleep_secs(30)).await.expect("start survivor");
let mut dying = group.start(&sleep_secs(30)).await.expect("start victim");
let before = group.members().expect("members").len();
assert!(before >= 2, "expected at least two members, got {before}");
dying.start_kill().expect("kill victim");
let _ = completes_within(Duration::from_secs(10), "victim reap", dying.wait()).await;
poll_until(
Duration::from_secs(5),
Duration::from_millis(50),
&format!("member count never dropped below {before}"),
|| group.members().expect("members").len() < before,
)
.await;
}
#[tokio::test]
#[ignore = "creates an OS job/cgroup"]
async fn members_on_empty_group_is_empty() {
let group = ProcessGroup::new().expect("create group");
let members = group.members().expect("members");
assert!(members.is_empty(), "fresh group has members: {members:?}");
}
#[tokio::test]
#[ignore = "spawns a real subprocess and reads its enriched member snapshot"]
async fn members_info_enriches_a_live_child() {
let group = ProcessGroup::new().expect("create group");
let child = group.start(&sleeper()).await.expect("start sleeper");
let child_pid = child.pid().expect("child pid");
let infos = group.members_info().expect("members_info");
assert!(!infos.is_empty(), "members_info empty for a live child");
let mine = infos
.iter()
.find(|m| m.pid() == child_pid)
.unwrap_or_else(|| panic!("child pid {child_pid} not in members_info {infos:?}"));
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
{
assert!(
mine.ppid().is_some(),
"ppid should be reported here: {mine:?}"
);
assert!(
mine.exe_name().is_some(),
"exe_name should be reported here: {mine:?}"
);
assert!(
mine.start_time().is_some(),
"start_time should be reported here: {mine:?}"
);
}
#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))]
let _ = mine;
}
#[tokio::test]
#[ignore = "creates an OS job/cgroup"]
async fn members_info_on_empty_group_is_empty() {
let group = ProcessGroup::new().expect("create group");
let infos = group.members_info().expect("members_info");
assert!(infos.is_empty(), "fresh group has members: {infos:?}");
}
#[tokio::test]
#[ignore = "spawns a real subprocess and queries its identity by pid"]
async fn process_info_identifies_a_live_child() {
let group = ProcessGroup::new().expect("create group");
let child = group.start(&sleeper()).await.expect("start sleeper");
let pid = child.pid().expect("child pid");
let info = processkit::process_info(pid)
.expect("process_info must not error on a live child we own")
.expect("a live child must be found by pid");
assert_eq!(info.pid(), pid, "process_info reported the wrong pid");
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
{
assert!(
info.ppid().is_some(),
"ppid should be reported here: {info:?}"
);
assert!(
info.exe_name().is_some(),
"exe_name should be reported here: {info:?}"
);
assert!(
info.start_time().is_some(),
"start_time should be reported here: {info:?}"
);
}
assert!(
processkit::process_is_alive(pid, info.start_time())
.expect("liveness query must not error on a live child"),
"the live child must read as alive by its (pid, start-time) pair",
);
}
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
#[tokio::test]
#[ignore = "spawns a real subprocess to model a recycled pid without waiting for a real recycle"]
async fn process_is_alive_rejects_a_recycled_number() {
let group = ProcessGroup::new().expect("create group");
let child = group.start(&sleeper()).await.expect("start sleeper");
let pid = child.pid().expect("child pid");
let start = processkit::process_info(pid)
.expect("process_info")
.and_then(|i| i.start_time())
.expect("a start-time token is reported on this platform");
assert!(
processkit::process_is_alive(pid, Some(start)).expect("liveness with the real token"),
"the real (pid, start-time) pair must read as alive",
);
let stale = start ^ 0x5A5A_5A5A;
assert_ne!(
stale, start,
"the stale token must differ from the real one"
);
assert!(
!processkit::process_is_alive(pid, Some(stale))
.expect("liveness must not error on a live pid with a stale token"),
"a mismatched start-time must read as gone — the number was recycled",
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess, tears it down, and confirms its pid reads as gone"]
async fn process_is_alive_reports_gone_after_teardown() {
let group = ProcessGroup::new().expect("create group");
let child = group.start(&sleeper()).await.expect("start sleeper");
let pid = child.pid().expect("child pid");
let start = processkit::process_info(pid)
.expect("process_info")
.and_then(|i| i.start_time());
assert!(
processkit::process_is_alive(pid, start).expect("liveness while running"),
"the child must read alive before teardown",
);
group.kill_all().expect("terminate the tree");
let _ = child.wait().await.expect("reap the killed child");
drop(group);
poll_until(
Duration::from_secs(10),
Duration::from_millis(100),
"the reaped child's pid still read as alive",
|| {
!processkit::process_is_alive(pid, start)
.expect("liveness after teardown must not error")
},
)
.await;
match processkit::process_info(pid).expect("process_info after teardown must not error") {
None => {}
Some(info) => assert_ne!(
info.start_time(),
start,
"the saved instance is gone; any Some here is a recycled stranger",
),
}
}
#[tokio::test]
#[ignore = "queries a pid past any valid range"]
async fn process_info_on_a_nonexistent_pid_is_a_clean_none() {
let bogus = 2_000_000_000u32;
assert_eq!(
processkit::process_info(bogus).expect("a nonexistent pid is not an error"),
None,
"a nonexistent pid must be a clean None, not Some/Err",
);
assert!(
!processkit::process_is_alive(bogus, None).expect("liveness on a gone pid"),
"a nonexistent pid must read as not alive",
);
assert!(
!processkit::process_is_alive(bogus, Some(12_345)).expect("liveness on a gone pid"),
"a token cannot resurrect a nonexistent pid",
);
}
#[tokio::test]
#[ignore = "queries a known privileged system process by pid"]
async fn process_info_on_a_privileged_process_is_never_a_false_gone() {
#[cfg(windows)]
let pid = 4; #[cfg(not(windows))]
let pid = 1;
match processkit::process_info(pid) {
Ok(Some(info)) => assert_eq!(info.pid(), pid, "reported the wrong pid"),
Ok(None) => panic!(
"a live privileged process (pid {pid}) must never read as a nonexistent pid — \
'can't look' is not 'dead'"
),
Err(_) => {}
}
}
#[tokio::test]
#[ignore = "spawns a short subprocess and adopts it after reaping"]
async fn adopt_of_a_reaped_child_errors_instead_of_tracking_nothing() {
let group = ProcessGroup::new().expect("create group");
let mut cmd = if cfg!(windows) {
let mut c = tokio::process::Command::new("cmd");
c.args(["/c", "exit", "0"]);
c
} else {
let mut c = tokio::process::Command::new("sh");
c.args(["-c", "exit 0"]);
c
};
let mut child = cmd.spawn().expect("spawn short child");
let _ = tokio::time::timeout(Duration::from_secs(10), child.wait())
.await
.expect("short child exits");
let err = group
.adopt(&child)
.expect_err("adopting a reaped child must error");
assert!(
matches!(err.reason(), processkit::ErrorReason::Io(_)),
"expected the no-pid Io error, got {err:?}"
);
}
#[tokio::test]
#[ignore = "spawns a child, kills it UNREAPED, then adopts the zombie"]
async fn adopt_of_an_exited_unreaped_child_is_ok() {
let group = ProcessGroup::new().expect("create group");
let mut cmd = if cfg!(windows) {
let mut c = tokio::process::Command::new("ping");
c.args(["-n", "60", "127.0.0.1"]);
c
} else {
let mut c = tokio::process::Command::new("sleep");
c.arg("60");
c
};
let mut child = cmd.spawn().expect("spawn long-lived child");
child
.start_kill()
.expect("kill the child without reaping it");
tokio::time::sleep(Duration::from_millis(500)).await;
group
.adopt(&child)
.expect("adopting an exited-but-unreaped (zombie) child must be a no-op Ok");
let _ = child.wait().await;
drop(group);
}
#[tokio::test]
#[ignore = "creates an OS job/cgroup"]
async fn empty_group_accepts_lifecycle_calls() {
let group = ProcessGroup::new().expect("create group");
group.signal(Signal::Kill).expect("Kill on an empty group");
if cfg!(windows) {
let err = group
.signal(Signal::Term)
.expect_err("Term on an empty Windows group has no soft-close target");
assert!(
matches!(err.reason(), processkit::ErrorReason::Unsupported { .. }),
"expected Unsupported, got {err:?}"
);
} else {
group.signal(Signal::Term).expect("Term on an empty group");
}
group.suspend().expect("suspend an empty group");
group.resume().expect("resume an empty group");
#[cfg(feature = "stats")]
{
let stats = group.stats().expect("stats on an empty group");
assert_eq!(stats.active_process_count, 0);
}
}
#[cfg(windows)]
#[tokio::test]
#[ignore = "spawns a real subprocess and nests suspend/resume"]
async fn windows_nested_suspend_needs_matching_resumes() {
use tokio_stream::StreamExt;
let group = ProcessGroup::new().expect("create group");
let mut run = group
.start(&Command::new("ping").args(["-n", "31", "127.0.0.1"]))
.await
.expect("start ticker");
let mut lines = run.stdout_lines().unwrap();
tokio::time::timeout(Duration::from_secs(15), lines.next())
.await
.expect("ticker prints")
.expect("first line");
group.suspend().expect("suspend #1");
group.suspend().expect("suspend #2");
group.resume().expect("resume #1 of 2");
loop {
match tokio::time::timeout(Duration::from_secs(2), lines.next()).await {
Ok(Some(_)) => continue,
Ok(None) => panic!("ticker exited while suspended"),
Err(_) => break,
}
}
assert!(
tokio::time::timeout(Duration::from_secs(3), lines.next())
.await
.is_err(),
"one resume must not thaw two suspends"
);
group.resume().expect("resume #2 of 2");
let line = tokio::time::timeout(Duration::from_secs(15), lines.next())
.await
.expect("a balanced resume thaws the tree");
assert!(line.is_some(), "ticker resumed output");
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[ignore = "adopts a real subprocess into a suspended cgroup"]
async fn linux_cgroup_adopt_into_suspended_group_freezes_the_child() {
use tokio::io::AsyncBufReadExt;
let group = ProcessGroup::new().expect("create group");
if !matches!(group.mechanism(), Mechanism::CgroupV2) {
eprintln!("skipping: needs the cgroup mechanism");
return;
}
let mut ticker = tokio::process::Command::new("sh")
.args(["-c", "while :; do echo tick; sleep 0.25; done"])
.stdout(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.expect("spawn ticker");
let stdout = ticker.stdout.take().expect("ticker stdout");
let mut lines = tokio::io::BufReader::new(stdout).lines();
tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.expect("ticker prints")
.expect("read line")
.expect("a tick before adoption");
group.suspend().expect("suspend the empty group");
group
.adopt(&ticker)
.expect("adopt the ticker into the frozen cgroup");
loop {
match tokio::time::timeout(Duration::from_secs(1), lines.next_line()).await {
Ok(Ok(Some(_))) => continue,
Ok(_) => panic!("ticker exited while frozen"),
Err(_) => break,
}
}
assert!(
tokio::time::timeout(Duration::from_secs(2), lines.next_line())
.await
.is_err(),
"a child adopted into a suspended cgroup must freeze on attach"
);
group.resume().expect("resume");
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
.await
.expect("thawed ticker resumes output")
.expect("read line");
assert_eq!(line.as_deref(), Some("tick"));
let _ = ticker.kill().await;
}