use std::io;
use std::os::unix::process::CommandExt;
use std::sync::Mutex;
use std::time::Duration;
#[cfg(all(test, feature = "process-control"))]
use std::sync::{Arc, Condvar};
use tokio::process::{Child, Command};
#[cfg(all(test, feature = "process-control"))]
struct AdoptionPause {
state: Mutex<AdoptionPauseState>,
changed: Condvar,
}
#[cfg(all(test, feature = "process-control"))]
#[derive(Default)]
struct AdoptionPauseState {
entered: bool,
released: bool,
}
#[cfg(all(test, feature = "process-control"))]
impl AdoptionPause {
fn new() -> Self {
Self {
state: Mutex::new(AdoptionPauseState::default()),
changed: Condvar::new(),
}
}
fn pause(&self) {
let mut state = self.state.lock().expect("adoption pause poisoned");
state.entered = true;
self.changed.notify_all();
while !state.released {
state = self.changed.wait(state).expect("adoption pause poisoned");
}
}
fn wait_until_entered(&self, timeout: Duration) -> bool {
let state = self.state.lock().expect("adoption pause poisoned");
let (state, result) = self
.changed
.wait_timeout_while(state, timeout, |state| !state.entered)
.expect("adoption pause poisoned");
state.entered && !result.timed_out()
}
fn release(&self) {
let mut state = self.state.lock().expect("adoption pause poisoned");
state.released = true;
self.changed.notify_all();
}
}
#[cfg(feature = "process-control")]
use crate::member::MemberInfo;
#[cfg(feature = "stats")]
use crate::stats::ProcessGroupStats;
#[cfg(any(target_os = "linux", target_os = "android"))]
fn read_identity(pid: i32) -> Option<u64> {
super::procfs::read_starttime(pid as u32)
}
#[cfg(target_vendor = "apple")]
fn read_identity(pid: i32) -> Option<u64> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let want = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
let got = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
std::ptr::addr_of_mut!(info).cast::<libc::c_void>(),
want,
)
};
if got != want {
return None;
}
Some(
info.pbi_start_tvsec
.saturating_mul(1_000_000)
.saturating_add(info.pbi_start_tvusec),
)
}
#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
fn read_identity(_pid: i32) -> Option<u64> {
None
}
pub(crate) fn is_recycled(tracked: Option<u64>, current: Option<u64>) -> bool {
matches!((tracked, current), (Some(a), Some(b)) if a != b)
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
fn capture_adoption_anchor(pid: i32) -> io::Result<u64> {
if let Some(token) = read_identity(pid) {
return Ok(token);
}
if unsafe { libc::kill(pid, 0) } != 0
&& io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
{
return Err(no_such_process(pid));
}
Err(io::Error::other(format!(
"cannot adopt pid {pid}: its start-time identity could not be read (a hidepid /proc \
mount, or a proc_pidinfo denial for another uid's process on macOS), and this group \
will not track an external process by number alone"
)))
}
#[cfg(all(
feature = "process-control",
not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))
))]
fn capture_adoption_anchor(pid: i32) -> io::Result<u64> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
format!(
"adopting pid {pid} by number needs a start-time identity reader, and none is wired \
up on this target (the BSDs other than macOS); adopt a Child you hold instead"
),
))
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
fn no_such_process(pid: i32) -> io::Error {
io::Error::new(
io::ErrorKind::NotFound,
format!("no process with pid {pid} to adopt"),
)
}
#[cfg(feature = "process-control")]
fn recycled_during_adoption(pid: i32) -> io::Error {
io::Error::other(format!(
"pid {pid} was recycled while it was being adopted: its start-time identity differs \
from the one captured at the start of the call, so the process the caller named is \
not the one this call acted on; the entry this call left behind carries that identity, \
so this group prunes it unsignalled rather than tearing it down"
))
}
#[cfg(any(target_os = "linux", target_os = "android"))]
fn is_live_non_zombie(pid: i32) -> bool {
super::procfs::read_state(pid as u32).is_some_and(|s| !matches!(s, 'Z' | 'X' | 'x'))
}
#[cfg(target_vendor = "apple")]
fn is_live_non_zombie(pid: i32) -> bool {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let want = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
let got = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
std::ptr::addr_of_mut!(info).cast::<libc::c_void>(),
want,
)
};
got == want
&& matches!(
info.pbi_status,
libc::SIDL | libc::SRUN | libc::SSLEEP | libc::SSTOP
)
}
#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
fn is_live_non_zombie(_pid: i32) -> bool {
false
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android")
))]
fn read_member_info(pid: i32) -> Option<MemberInfo> {
let m = super::procfs::read_stat_meta(pid as u32)?;
Some(MemberInfo::new(pid as u32, m.ppid, m.comm, m.starttime))
}
#[cfg(all(feature = "process-control", target_vendor = "apple"))]
fn fill_bsdinfo(pid: i32) -> io::Result<Option<libc::proc_bsdinfo>> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let want = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
let got = unsafe {
libc::proc_pidinfo(
pid,
libc::PROC_PIDTBSDINFO,
0,
std::ptr::addr_of_mut!(info).cast::<libc::c_void>(),
want,
)
};
if got == want {
return Ok(Some(info));
}
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
Ok(None)
} else {
Err(err)
}
}
#[cfg(all(feature = "process-control", target_vendor = "apple"))]
fn build_member_info(pid: u32, info: &libc::proc_bsdinfo) -> MemberInfo {
let start_time = info
.pbi_start_tvsec
.saturating_mul(1_000_000)
.saturating_add(info.pbi_start_tvusec);
MemberInfo::new(
pid,
Some(info.pbi_ppid),
comm_to_string(&info.pbi_comm),
Some(start_time),
)
}
#[cfg(all(feature = "process-control", target_vendor = "apple"))]
fn read_member_info(pid: i32) -> Option<MemberInfo> {
fill_bsdinfo(pid)
.ok()
.flatten()
.map(|info| build_member_info(pid as u32, &info))
}
#[cfg(all(feature = "process-control", target_vendor = "apple"))]
pub(crate) fn process_info(pid: u32) -> io::Result<Option<MemberInfo>> {
let Ok(spid) = i32::try_from(pid) else {
return Ok(None);
};
Ok(fill_bsdinfo(spid)?.map(|info| build_member_info(pid, &info)))
}
#[cfg(all(
feature = "process-control",
not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))
))]
pub(crate) fn process_info(pid: u32) -> io::Result<Option<MemberInfo>> {
let Ok(spid) = i32::try_from(pid) else {
return Ok(None);
};
if spid == 0 {
return Ok(None);
}
let rc = unsafe { libc::kill(spid, 0) };
if rc == 0 {
return Ok(Some(MemberInfo::new(pid, None, None, None)));
}
let err = io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EPERM) => Ok(Some(MemberInfo::new(pid, None, None, None))),
Some(libc::ESRCH) => Ok(None),
_ => Err(err),
}
}
#[cfg(all(feature = "process-control", target_vendor = "apple"))]
fn comm_to_string(comm: &[libc::c_char]) -> Option<String> {
let bytes: Vec<u8> = comm
.iter()
.take_while(|&&c| c != 0)
.map(|&c| c as u8)
.collect();
if bytes.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&bytes).into_owned())
}
}
#[cfg(all(
feature = "process-control",
not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))
))]
fn read_member_info(pid: i32) -> Option<MemberInfo> {
Some(MemberInfo::new(pid as u32, None, None, None))
}
struct Entry {
id: i32,
group_seen: bool,
identity: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SignalTarget {
Group,
Pid,
}
fn deliver_signal(id: i32, sig: i32, target: SignalTarget) -> io::Result<()> {
#[cfg(test)]
if let Some(injected) = crate::sys::fault_injection::check(
crate::sys::fault_injection::Site::PgroupSignalDelivery,
match target {
SignalTarget::Group => "killpg",
SignalTarget::Pid => "kill",
},
) {
return Err(injected);
}
let rc = unsafe {
match target {
SignalTarget::Group => libc::killpg(id, sig),
SignalTarget::Pid => libc::kill(id, sig),
}
};
if rc == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
struct Tracked {
ids: Mutex<Vec<Entry>>,
group: bool,
}
impl Tracked {
const fn new(group: bool) -> Self {
Tracked {
ids: Mutex::new(Vec::new()),
group,
}
}
fn probe_raw(&self, id: i32, group_seen: bool) -> (bool, bool) {
let probe = if self.group { -id } else { id };
if unsafe { libc::kill(probe, 0) } == 0 {
return (true, group_seen || self.group);
}
let err = std::io::Error::last_os_error().raw_os_error();
if err == Some(libc::EPERM) {
return (true, group_seen || self.group);
}
if self.group && !group_seen && err == Some(libc::ESRCH) {
if unsafe { libc::kill(id, 0) } == 0 {
return (true, false);
}
let alive = std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
return (alive, false);
}
(false, group_seen)
}
fn probe_entry(&self, entry: &mut Entry) -> bool {
let (alive, group_seen) = self.probe_raw(entry.id, entry.group_seen);
entry.group_seen = group_seen;
if alive && entry.identity.is_some() && is_recycled(entry.identity, read_identity(entry.id))
{
return false;
}
alive
}
#[cfg(feature = "process-control")]
fn holds_same_process(&self, id: i32, identity: Option<u64>) -> bool {
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.retain_mut(|e| self.probe_entry(e));
ids.iter().any(|e| e.id == id && e.identity == identity)
}
#[cfg(feature = "process-control")]
fn prepare_identity_adoption(&self, id: i32, identity: u64) -> bool {
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.retain_mut(|e| self.probe_entry(e));
let mut same_process = false;
ids.retain(|entry| {
if entry.id != id {
return true;
}
if entry.identity == Some(identity) {
if same_process {
false
} else {
same_process = true;
true
}
} else {
false
}
});
same_process
}
fn track(&self, id: i32, group_seen: bool) {
self.track_with(id, group_seen, read_identity(id));
}
fn track_with(&self, id: i32, group_seen: bool, identity: Option<u64>) {
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.retain_mut(|e| self.probe_entry(e));
if let Some(identity) = identity {
let mut same_process = false;
ids.retain(|entry| {
if entry.id != id {
return true;
}
if entry.identity == Some(identity) {
if same_process {
false
} else {
same_process = true;
true
}
} else {
false
}
});
if same_process {
return;
}
} else if ids.iter().any(|entry| entry.id == id) {
return;
}
ids.push(Entry {
id,
group_seen,
identity,
});
}
fn signal_all(&self, sig: i32) -> io::Result<()> {
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
let mut surfaced: Option<io::Error> = None;
ids.retain_mut(|e| {
if !self.probe_entry(e) {
return false; }
let id = e.id;
let delivery = if self.group {
match deliver_signal(id, sig, SignalTarget::Group) {
Ok(()) => None,
Err(err) if err.raw_os_error() == Some(libc::ESRCH) && !e.group_seen => {
deliver_signal(id, sig, SignalTarget::Pid).err()
}
Err(err) => Some(err),
}
} else {
deliver_signal(id, sig, SignalTarget::Pid).err()
};
if let Some(err) = delivery
&& surfaced.is_none()
{
let code = err.raw_os_error();
if code == Some(libc::EINVAL)
|| (code == Some(libc::EPERM) && is_live_non_zombie(id))
{
surfaced = Some(err);
}
}
true
});
match surfaced {
Some(err) => Err(err),
None => Ok(()),
}
}
fn any_alive(&self) -> bool {
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.iter_mut().any(|e| self.probe_entry(e))
}
#[cfg(feature = "process-control")]
fn live_snapshot(&self) -> Vec<i32> {
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.retain_mut(|e| self.probe_entry(e));
ids.iter().map(|e| e.id).collect()
}
#[cfg(feature = "pty")]
fn remove(&self, id: i32) {
self.ids
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|entry| entry.id != id);
}
fn count_alive(&self) -> usize {
let mut ids = self.ids.lock().unwrap_or_else(|e| e.into_inner());
let mut alive = 0;
for e in ids.iter_mut() {
if self.probe_entry(e) {
alive += 1;
}
}
alive
}
}
pub(crate) struct ProcessGroup {
ownership: Mutex<()>,
groups: Tracked,
solos: Tracked,
skip_drop_kill: super::SkipDropKill,
#[cfg(all(test, feature = "process-control"))]
adoption_pause: Option<Arc<AdoptionPause>>,
#[cfg(all(test, feature = "process-control"))]
group_adoption_pause: Option<Arc<AdoptionPause>>,
}
impl ProcessGroup {
pub(crate) fn new() -> Self {
ProcessGroup {
ownership: Mutex::new(()),
groups: Tracked::new(true),
solos: Tracked::new(false),
skip_drop_kill: super::SkipDropKill::new(),
#[cfg(all(test, feature = "process-control"))]
adoption_pause: None,
#[cfg(all(test, feature = "process-control"))]
group_adoption_pause: None,
}
}
#[cfg(feature = "pty")]
pub(crate) fn rollback_pty_spawn(&self, pid: u32, displaced: super::DisplacedSpare) {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
hard_kill_fresh_spawn(pid as i32);
self.groups.remove(pid as i32);
self.skip_drop_kill.restore(displaced);
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub(crate) fn spawn(
&self,
cmd: &mut Command,
opts: &crate::sys::SpawnOptions,
) -> io::Result<Child> {
self.spawn_displacing_spare(cmd, opts)
.map(|(child, _displaced)| child)
}
pub(crate) fn spawn_displacing_spare(
&self,
cmd: &mut Command,
opts: &crate::sys::SpawnOptions,
) -> io::Result<(Child, super::DisplacedSpare)> {
if !opts.setsid {
cmd.as_std_mut().process_group(0);
}
let guard = UntrackedChildGuard::arm(cmd.spawn()?);
if let Some(pid) = guard.child().id() {
self.groups.track(pid as i32, false);
}
let displaced = self.skip_drop_kill.clear();
Ok((guard.disarm(), displaced))
}
#[cfg(all(feature = "process-control", test))]
fn reconcile_solo_adoption(&self, pid: i32, identity: Option<u64>) {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
self.reconcile_solo_adoption_locked(pid, identity);
}
#[cfg(feature = "process-control")]
fn reconcile_solo_adoption_locked(&self, pid: i32, identity: Option<u64>) {
let same_process = match identity {
Some(identity) => self.groups.prepare_identity_adoption(pid, identity),
None => self.groups.holds_same_process(pid, None),
};
if same_process {
return;
}
self.skip_drop_kill.clear();
#[cfg(all(test, feature = "process-control"))]
if let Some(pause) = &self.adoption_pause {
pause.pause();
}
self.solos.track_with(pid, false, identity);
}
#[cfg(feature = "process-control")]
pub(crate) fn adopt(&self, child: &Child) -> io::Result<()> {
let pid = child
.id()
.ok_or_else(|| io::Error::other("child has no pid (already exited?)"))?
as i32;
self.adopt_pid(pid)
}
#[cfg(feature = "process-control")]
fn adopt_pid(&self, pid: i32) -> io::Result<()> {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let rc = unsafe { libc::setpgid(pid, 0) };
if rc == 0 {
self.skip_drop_kill.clear();
#[cfg(all(test, feature = "process-control"))]
if let Some(pause) = &self.group_adoption_pause {
pause.pause();
}
self.groups.track(pid, true);
return Ok(());
}
let err = io::Error::last_os_error();
match err.raw_os_error().unwrap_or(0) {
code if code == libc::ESRCH => Ok(()),
code if code == libc::EACCES || code == libc::EPERM => {
let identity = read_identity(pid);
self.reconcile_solo_adoption_locked(pid, identity);
Ok(())
}
_ => Err(err),
}
}
#[cfg(feature = "process-control")]
pub(crate) fn adopt_external(&self, pid: u32) -> io::Result<()> {
let Ok(pid) = i32::try_from(pid) else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no process with pid {pid} to adopt"),
));
};
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let anchor = capture_adoption_anchor(pid)?;
let rc = unsafe { libc::setpgid(pid, 0) };
if rc == 0 {
self.skip_drop_kill.clear();
#[cfg(all(test, feature = "process-control"))]
if let Some(pause) = &self.group_adoption_pause {
pause.pause();
}
self.groups.track_with(pid, true, Some(anchor));
} else {
let err = io::Error::last_os_error();
match err.raw_os_error().unwrap_or(0) {
code if code == libc::EACCES || code == libc::EPERM || code == libc::ESRCH => {
self.reconcile_solo_adoption_locked(pid, Some(anchor));
}
_ => return Err(err),
}
}
if is_recycled(Some(anchor), read_identity(pid)) {
return Err(recycled_during_adoption(pid));
}
Ok(())
}
pub(crate) fn kill_all(&self) -> io::Result<()> {
self.broadcast(libc::SIGKILL)
}
#[cfg(feature = "process-control")]
pub(crate) fn signal(&self, sig: i32) -> io::Result<()> {
self.broadcast(sig)
}
#[cfg(feature = "process-control")]
pub(crate) fn suspend(&self) -> io::Result<()> {
self.broadcast(libc::SIGSTOP)
}
#[cfg(feature = "process-control")]
pub(crate) fn resume(&self) -> io::Result<()> {
self.broadcast(libc::SIGCONT)
}
fn broadcast(&self, sig: i32) -> io::Result<()> {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let groups = self.groups.signal_all(sig);
let solos = self.solos.signal_all(sig);
groups.and(solos)
}
fn any_alive(&self) -> bool {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
self.groups.any_alive() || self.solos.any_alive()
}
#[cfg(feature = "process-control")]
pub(crate) fn members(&self) -> Vec<i32> {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let mut members = self.groups.live_snapshot();
members.extend_from_slice(&self.solos.live_snapshot());
members
}
#[cfg(feature = "process-control")]
pub(crate) fn members_info(&self) -> Vec<MemberInfo> {
self.members()
.into_iter()
.filter_map(read_member_info)
.collect()
}
pub(crate) async fn graceful_shutdown(
&self,
signal: i32,
timeout: Duration,
escalate: bool,
) -> io::Result<super::graceful::GracefulOutcome> {
super::graceful::run(self, &self.skip_drop_kill, signal, timeout, escalate).await
}
#[cfg(target_os = "freebsd")]
pub(crate) fn skip_drop_kill(&self) -> &super::SkipDropKill {
&self.skip_drop_kill
}
#[cfg(feature = "stats")]
pub(crate) fn stats(&self) -> io::Result<ProcessGroupStats> {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
Ok(ProcessGroupStats {
active_process_count: self.groups.count_alive() + self.solos.count_alive(),
total_cpu_time: None,
peak_memory_bytes: None,
io_read_bytes: None,
io_write_bytes: None,
peak_process_count: None,
})
}
}
impl super::graceful::GracefulTarget for ProcessGroup {
fn signal_all(&self, signal: i32) -> super::graceful::SoftDelivery {
match self.broadcast(signal) {
Ok(()) => super::graceful::SoftDelivery::Sent,
Err(_) => super::graceful::SoftDelivery::Failed,
}
}
fn is_drained(&self) -> bool {
!self.any_alive()
}
fn alive_count(&self) -> Option<usize> {
let _ownership = self
.ownership
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
Some(self.groups.count_alive() + self.solos.count_alive())
}
fn hard_kill(&self) -> io::Result<()> {
self.broadcast(libc::SIGKILL)
}
}
impl Drop for ProcessGroup {
fn drop(&mut self) {
if !self.skip_drop_kill.is_set() {
let _ = self.broadcast(libc::SIGKILL);
}
}
}
struct UntrackedChildGuard {
child: Option<Child>,
}
impl UntrackedChildGuard {
fn arm(child: Child) -> Self {
Self { child: Some(child) }
}
fn child(&self) -> &Child {
self.child
.as_ref()
.expect("the guarded child is present until disarm")
}
fn disarm(mut self) -> Child {
self.child
.take()
.expect("the guarded child is taken exactly once")
}
}
impl Drop for UntrackedChildGuard {
fn drop(&mut self) {
let Some(child) = self.child.take() else {
return; };
if let Some(pid) = child.id() {
hard_kill_fresh_spawn(pid as i32);
}
drop(child);
}
}
pub(crate) fn hard_kill_fresh_spawn(pid: i32) {
unsafe {
if libc::killpg(pid, libc::SIGKILL) == -1
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH)
{
libc::kill(pid, libc::SIGKILL);
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use tokio::process::Command;
use super::*;
#[cfg(all(
feature = "process-control",
not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))
))]
#[test]
fn zero_pid_is_not_a_process_lookup() {
assert!(process_info(0).expect("zero pid lookup").is_none());
assert!(!crate::process_is_alive(0, None).expect("zero pid liveness"));
}
#[cfg(feature = "stats")]
#[test]
fn every_measurement_is_absent_on_the_process_group_mechanism() {
let group = ProcessGroup::new();
let stats = group
.stats()
.expect("an empty process group reports its count");
assert_eq!(stats.active_process_count, 0);
assert_eq!(stats.total_cpu_time, None);
assert_eq!(stats.peak_memory_bytes, None);
assert_eq!(stats.io_read_bytes, None);
assert_eq!(stats.io_write_bytes, None);
assert_eq!(stats.peak_process_count, None);
}
#[cfg(feature = "process-control")]
fn make_session_leader(command: &mut Command) {
unsafe {
command.as_std_mut().pre_exec(|| {
if libc::setsid() == -1 {
Err(std::io::Error::last_os_error())
} else {
Ok(())
}
});
}
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
struct AdoptionRaceChild {
pid: libc::pid_t,
release: Option<std::fs::File>,
ready: Option<std::fs::File>,
exec_ready: Option<std::fs::File>,
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
impl AdoptionRaceChild {
fn make_pipe() -> [libc::c_int; 2] {
let mut fds = [0; 2];
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
for fd in &mut fds {
let moved = unsafe { libc::fcntl(*fd, libc::F_DUPFD, 10) };
assert!(moved >= 0, "duplicate adoption-race pipe fd");
unsafe {
libc::close(*fd);
}
*fd = moved;
}
fds
}
fn spawn() -> Self {
use std::os::fd::FromRawFd;
let [ready_read, ready_write] = Self::make_pipe();
let [release_read, release_write] = Self::make_pipe();
let [exec_ready_read, exec_ready_write] = Self::make_pipe();
assert_eq!(
unsafe { libc::fcntl(exec_ready_write, libc::F_SETFD, libc::FD_CLOEXEC) },
0,
"mark adoption-race exec pipe close-on-exec"
);
let pid = unsafe { libc::fork() };
if pid == -1 {
unsafe {
libc::close(ready_read);
libc::close(ready_write);
libc::close(release_read);
libc::close(release_write);
libc::close(exec_ready_read);
libc::close(exec_ready_write);
}
panic!(
"fork adoption race child: {}",
std::io::Error::last_os_error()
);
}
if pid == 0 {
unsafe {
libc::close(ready_read);
libc::close(release_write);
libc::close(exec_ready_read);
let ready = [1u8];
if libc::write(ready_write, ready.as_ptr().cast(), ready.len())
!= ready.len() as isize
{
libc::_exit(127);
}
let mut release = [0u8];
if libc::read(release_read, release.as_mut_ptr().cast(), release.len())
!= release.len() as isize
{
libc::_exit(127);
}
let shell = b"/bin/sh\0";
let argv0 = b"sh\0";
let command = b"exec sleep 60\0";
libc::execl(
shell.as_ptr().cast::<libc::c_char>(),
argv0.as_ptr().cast::<libc::c_char>(),
c"-c".as_ptr().cast::<libc::c_char>(),
command.as_ptr().cast::<libc::c_char>(),
std::ptr::null::<libc::c_char>(),
);
libc::_exit(127);
}
}
unsafe {
libc::close(ready_write);
libc::close(release_read);
libc::close(exec_ready_write);
}
Self {
pid,
release: Some(unsafe { std::fs::File::from_raw_fd(release_write) }),
ready: Some(unsafe { std::fs::File::from_raw_fd(ready_read) }),
exec_ready: Some(unsafe { std::fs::File::from_raw_fd(exec_ready_read) }),
}
}
fn pid(&self) -> i32 {
self.pid
}
fn release(&mut self) -> &mut std::fs::File {
self.release
.as_mut()
.expect("adoption race release pipe is present")
}
fn take_ready(&mut self) -> std::fs::File {
self.ready
.take()
.expect("adoption race ready pipe is present")
}
fn take_exec_ready(&mut self) -> std::fs::File {
self.exec_ready
.take()
.expect("adoption race exec-ready pipe is present")
}
fn kill_and_reap(&mut self) {
let pid = std::mem::replace(&mut self.pid, -1);
if pid <= 0 {
return;
}
unsafe {
let _ = libc::kill(pid, libc::SIGKILL);
loop {
let waited = libc::waitpid(pid, std::ptr::null_mut(), 0);
if waited == pid {
break;
}
if waited == -1
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR)
{
continue;
}
break;
}
}
}
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
impl Drop for AdoptionRaceChild {
fn drop(&mut self) {
self.kill_and_reap();
}
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
struct AdoptionPauseRelease(Arc<AdoptionPause>);
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
impl Drop for AdoptionPauseRelease {
fn drop(&mut self) {
self.0.release();
}
}
const BOGUS_SIGNAL: i32 = 4096;
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn escalate_false_does_not_kill_survivors() {
let pg = ProcessGroup::new();
let opts = crate::sys::SpawnOptions::default();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("trap '' TERM; while :; do :; done");
cmd.kill_on_drop(true);
let mut child = pg.spawn(&mut cmd, &opts).unwrap();
let pid = child.id().unwrap() as i32;
tokio::time::sleep(Duration::from_millis(50)).await;
pg.graceful_shutdown(libc::SIGTERM, Duration::from_millis(100), false)
.await
.unwrap();
drop(pg);
let alive = unsafe { libc::kill(pid, 0) } == 0;
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(alive, "child must survive when escalate_to_kill=false");
}
#[cfg(feature = "pty")]
async fn spawn_survivor(pg: &ProcessGroup, tag: &str) -> (Child, i32) {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let marker = std::env::temp_dir().join(format!(
"processkit_pgroup_survivor_{tag}_{}_{nanos}.ready",
std::process::id()
));
let _ = std::fs::remove_file(&marker);
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg("trap '' TERM; echo ready > \"$PK_READY\"; while :; do sleep 60; done")
.env("PK_READY", &marker);
cmd.kill_on_drop(true);
let child = pg
.spawn(&mut cmd, &crate::sys::SpawnOptions::default())
.expect("spawn a group member");
let pid = child.id().expect("the member reports a pid") as i32;
for _ in 0..600 {
if marker.exists() {
let _ = std::fs::remove_file(&marker);
return (child, pid);
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("the survivor never reported that its SIGTERM trap was installed");
}
#[cfg(feature = "pty")]
fn is_live_child(pid: i32) -> bool {
let mut status = 0;
unsafe {
if libc::waitpid(pid, &raw mut status, libc::WNOHANG) == pid {
return false; }
libc::kill(pid, 0) == 0
}
}
#[cfg(feature = "pty")]
#[tokio::test]
#[ignore = "spawns real subprocesses"]
async fn a_rolled_back_pty_spawn_restores_the_spare_it_displaced() {
let pg = ProcessGroup::new();
let (mut survivor, survivor_pid) = spawn_survivor(&pg, "restore").await;
pg.graceful_shutdown(libc::SIGTERM, Duration::from_millis(100), false)
.await
.unwrap();
assert!(
pg.skip_drop_kill.is_set(),
"precondition: a non-escalating shutdown spares the survivors"
);
let mut pty_cmd = Command::new("sh");
pty_cmd.arg("-c").arg("sleep 60");
pty_cmd.kill_on_drop(true);
let (mut pty_child, displaced) = pg
.spawn_displacing_spare(&mut pty_cmd, &crate::sys::SpawnOptions::default())
.unwrap();
let pty_pid = pty_child.id().expect("the pty child reports a pid");
assert!(
!pg.skip_drop_kill.is_set(),
"precondition: the spawn re-arms the backstop for its new member"
);
pg.rollback_pty_spawn(pty_pid, displaced);
assert!(
pg.skip_drop_kill.is_set(),
"the rollback must restore the spare its own spawn displaced"
);
drop(pg);
tokio::time::sleep(Duration::from_millis(200)).await;
let spared = is_live_child(survivor_pid);
let _ = unsafe { libc::kill(survivor_pid, libc::SIGKILL) };
let _ = survivor.wait().await;
let _ = pty_child.wait().await;
assert!(
spared,
"the survivor a non-escalating shutdown spared must outlive a failed \
PTY launch and the group's Drop"
);
}
#[cfg(feature = "pty")]
#[tokio::test]
#[ignore = "spawns real subprocesses"]
async fn a_spawn_between_the_pty_spawn_and_its_rollback_keeps_the_backstop_armed() {
let pg = ProcessGroup::new();
let (mut survivor, survivor_pid) = spawn_survivor(&pg, "raced").await;
pg.graceful_shutdown(libc::SIGTERM, Duration::from_millis(100), false)
.await
.unwrap();
let mut pty_cmd = Command::new("sh");
pty_cmd.arg("-c").arg("sleep 60");
pty_cmd.kill_on_drop(true);
let (mut pty_child, displaced) = pg
.spawn_displacing_spare(&mut pty_cmd, &crate::sys::SpawnOptions::default())
.unwrap();
let pty_pid = pty_child.id().expect("the pty child reports a pid");
let (mut newcomer, newcomer_pid) = spawn_survivor(&pg, "newcomer").await;
pg.rollback_pty_spawn(pty_pid, displaced);
assert!(
!pg.skip_drop_kill.is_set(),
"a spawn after the rolled-back one must keep the backstop armed"
);
drop(pg);
let mut killed = false;
for _ in 0..100 {
if !is_live_child(newcomer_pid) {
killed = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let _ = unsafe { libc::kill(newcomer_pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(survivor_pid, libc::SIGKILL) };
let _ = newcomer.wait().await;
let _ = survivor.wait().await;
let _ = pty_child.wait().await;
assert!(
killed,
"a member that joined after the rolled-back spawn must keep its \
kill-on-drop backstop — the restore must not spare it"
);
}
#[tokio::test(start_paused = true)]
async fn shutdown_request_does_not_override_a_concurrent_rearm() {
use std::sync::atomic::{AtomicUsize, Ordering};
struct RacingRearm<'a> {
latch: &'a crate::sys::SkipDropKill,
polls: AtomicUsize,
}
impl crate::sys::graceful::GracefulTarget for RacingRearm<'_> {
fn signal_all(&self, _signal: i32) -> crate::sys::graceful::SoftDelivery {
crate::sys::graceful::SoftDelivery::Sent
}
fn is_drained(&self) -> bool {
if self.polls.fetch_add(1, Ordering::Relaxed) == 1 {
self.latch.clear();
}
false
}
fn alive_count(&self) -> Option<usize> {
None
}
fn hard_kill(&self) -> std::io::Result<()> {
Ok(())
}
}
let pg = ProcessGroup::new();
pg.skip_drop_kill.clear();
let target = RacingRearm {
latch: &pg.skip_drop_kill,
polls: AtomicUsize::new(0),
};
crate::sys::graceful::run(
&target,
&pg.skip_drop_kill,
libc::SIGTERM,
Duration::from_millis(100),
false,
)
.await
.expect("graceful run");
assert!(
!pg.skip_drop_kill.is_set(),
"a child spawned/adopted mid-shutdown must keep the group's Drop-kill \
backstop — the stale request must not re-spare it"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn esrch_on_group_probe_does_not_prune_a_live_pid() {
let tracked = Tracked::new(true);
let mut child = Command::new("sh")
.arg("-c")
.arg("sleep 60")
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
let group_ok = unsafe { libc::kill(-pid, 0) } == 0;
let pid_ok = unsafe { libc::kill(pid, 0) } == 0;
if group_ok {
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
return;
}
assert!(pid_ok, "spawned child must be alive");
let exists = tracked.probe_raw(pid, false).0;
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(
exists,
"a process that exists as a pid but not as a group leader \
must be considered alive (L6 fallback, pre-latch)"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn group_seen_latch_disables_l6_fallback() {
let tracked = Tracked::new(true);
let mut child = Command::new("sh")
.arg("-c")
.arg("sleep 60")
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
if unsafe { libc::kill(-pid, 0) } == 0 {
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
return;
}
assert!(
tracked.probe_raw(pid, false).0,
"pre-latch: L6 keeps a live pid"
);
assert!(
!tracked.probe_raw(pid, true).0,
"post-latch: L6 disabled — a not-a-group-leader pid is treated as gone (B5)"
);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
}
#[cfg(feature = "process-control")]
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn adopt_of_an_already_spawned_child_does_not_double_track() {
let pg = ProcessGroup::new();
let opts = crate::sys::SpawnOptions::default();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 60");
cmd.kill_on_drop(true);
let mut child = pg.spawn(&mut cmd, &opts).unwrap();
let pid = child.id().unwrap() as i32;
pg.adopt(&child).unwrap();
let members = pg.members();
assert_eq!(
members.iter().filter(|&&m| m == pid).count(),
1,
"an already-spawned child must be tracked once, not double-tracked"
);
drop(pg);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn adopt_child_reconciles_a_stale_unanchored_group_entry() {
let pg = ProcessGroup::new();
let marker = std::env::temp_dir().join(format!(
"processkit_pgroup_adopt_child_{}_{}.ready",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock after epoch")
.as_nanos()
));
let _ = std::fs::remove_file(&marker);
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg("echo ready > \"$PK_ADOPT_CHILD_READY\"; exec sleep 60")
.env("PK_ADOPT_CHILD_READY", &marker)
.kill_on_drop(true);
make_session_leader(&mut cmd);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
for _ in 0..200 {
if marker.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
marker.exists(),
"the session-leader child must report readiness"
);
let _ = std::fs::remove_file(&marker);
let anchor = read_identity(pid).expect("this target reports a start-time identity");
pg.groups.track_with(pid, false, None);
pg.adopt(&child)
.expect("adopt the session-leader child as a solo");
let group_count = {
let groups = pg.groups.ids.lock().unwrap_or_else(|e| e.into_inner());
groups.iter().filter(|entry| entry.id == pid).count()
};
let (solo_count, solo_identity) = {
let solos = pg.solos.ids.lock().unwrap_or_else(|e| e.into_inner());
(
solos.iter().filter(|entry| entry.id == pid).count(),
solos
.iter()
.find(|entry| entry.id == pid)
.map(|entry| entry.identity),
)
};
drop(pg);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert_eq!(group_count, 0, "the stale group entry must be removed");
assert_eq!(
solo_count, 1,
"the adopted child must be tracked once as a solo"
);
assert_eq!(
solo_identity,
Some(Some(anchor)),
"the solo entry must retain the child's identity anchor"
);
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn adopt_external_anchors_the_tracked_entry_on_an_identity() {
let pg = ProcessGroup::new();
let mut child = Command::new("sh")
.arg("-c")
.arg("sleep 60")
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
let real = read_identity(pid).expect("this target reports a start-time identity");
pg.adopt_external(pid as u32)
.expect("a live process is adoptable by pid on this target");
let anchored = {
let solos = pg.solos.ids.lock().unwrap_or_else(|e| e.into_inner());
let groups = pg.groups.ids.lock().unwrap_or_else(|e| e.into_inner());
solos
.iter()
.chain(groups.iter())
.find(|e| e.id == pid)
.map(|e| e.identity)
};
drop(pg);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert_eq!(
anchored,
Some(Some(real)),
"a bare-pid adoption must track the pid, anchored on the identity read \
during the call — never a number-only entry"
);
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[test]
fn solo_track_with_replaces_a_stale_unanchored_entry() {
let solos = Tracked::new(false);
let pid = std::process::id() as i32;
let anchor = read_identity(pid).expect("this target reports a start-time identity");
solos.track_with(pid, false, None);
solos.track_with(pid, false, Some(anchor));
solos.track_with(pid, false, Some(anchor));
let ids = solos.ids.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(
ids.len(),
1,
"the same anchored process must remain deduplicated"
);
assert_eq!(ids[0].id, pid);
assert_eq!(ids[0].identity, Some(anchor));
}
#[cfg(all(
feature = "process-control",
not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))
))]
#[test]
fn solo_track_with_keeps_number_only_dedup_without_identity_reader() {
let solos = Tracked::new(false);
let pid = std::process::id() as i32;
solos.track_with(pid, false, None);
solos.track_with(pid, false, None);
let ids = solos.ids.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(
ids.len(),
1,
"number-only targets must retain numeric de-dup"
);
assert_eq!(ids[0].id, pid);
assert_eq!(ids[0].identity, None);
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn a_recycled_number_is_not_killed_by_the_group_that_adopted_it() {
let pg = ProcessGroup::new();
let mut child = Command::new("sh")
.arg("-c")
.arg("sleep 60")
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
pg.adopt_external(pid as u32).expect("adopt by pid");
for set in [&pg.solos, &pg.groups] {
let mut ids = set.ids.lock().unwrap_or_else(|e| e.into_inner());
for entry in ids.iter_mut().filter(|e| e.id == pid) {
entry.identity = entry.identity.map(|token| token ^ 1);
}
}
pg.kill_all().expect("kill_all over a recycled entry");
let mut survived = true;
for _ in 0..50 {
if !is_live_non_zombie(pid) {
survived = false;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
drop(pg);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(
survived,
"kill_all must not signal a number whose identity no longer matches the \
one captured when it was adopted"
);
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns a real subprocess"]
async fn adopt_external_is_not_silenced_by_a_stale_entry_for_the_same_number() {
for (case, stale_carries_a_token) in [("anchored", true), ("bare", false)] {
let pg = ProcessGroup::new();
let ready = std::env::temp_dir().join(format!(
"processkit_pgroup_adopt_external_{case}_{}.ready",
std::process::id()
));
let _ = std::fs::remove_file(&ready);
let mut child = Command::new("sh")
.arg("-c")
.arg("echo ready > \"$PK_ADOPT_READY\"; exec sleep 60")
.env("PK_ADOPT_READY", &ready)
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
for _ in 0..200 {
if ready.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
ready.exists(),
"the child must reach the post-exec adoption point"
);
let _ = std::fs::remove_file(&ready);
let real = read_identity(pid).expect("this target reports a start-time identity");
pg.groups
.track_with(pid, false, stale_carries_a_token.then_some(real ^ 1));
pg.adopt_external(pid as u32)
.expect("a live process is adoptable by pid on this target");
let (group_count, solo_count, solo_identity) = {
let solos = pg.solos.ids.lock().unwrap_or_else(|e| e.into_inner());
let groups = pg.groups.ids.lock().unwrap_or_else(|e| e.into_inner());
(
groups.iter().filter(|e| e.id == pid).count(),
solos.iter().filter(|e| e.id == pid).count(),
solos.iter().find(|e| e.id == pid).map(|e| e.identity),
)
};
assert_eq!(group_count, 0, "the stale group entry must be removed");
assert_eq!(
solo_count, 1,
"the adopted pid must be tracked once as a solo"
);
assert_eq!(solo_identity, Some(Some(real)));
let faults = crate::sys::fault_injection::Faults::new()
.fail_every(
crate::sys::fault_injection::Site::PgroupSignalDelivery,
None,
libc::EINVAL,
)
.arm();
let outcome = pg.kill_all();
assert_eq!(
faults.fired(crate::sys::fault_injection::Site::PgroupSignalDelivery),
1
);
assert_eq!(
outcome
.expect_err("the injected delivery failure must reach kill_all")
.raw_os_error(),
Some(libc::EINVAL)
);
assert_eq!(
unsafe { libc::kill(pid, 0) },
0,
"the holder was not signalled"
);
drop(faults);
drop(pg);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
}
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns a real subprocess"]
async fn adoption_reconcile_serializes_with_concurrent_broadcast() {
let pause = Arc::new(AdoptionPause::new());
let mut process_group = ProcessGroup::new();
process_group.adoption_pause = Some(Arc::clone(&pause));
let pg = Arc::new(process_group);
let marker = std::env::temp_dir().join(format!(
"processkit_pgroup_adoption_race_{}_{}.ready",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock after epoch")
.as_nanos()
));
let _ = std::fs::remove_file(&marker);
let mut cmd = Command::new("sh");
cmd.arg("-c")
.arg("echo ready > \"$PK_ADOPTION_RACE_READY\"; exec sleep 60")
.env("PK_ADOPTION_RACE_READY", &marker)
.kill_on_drop(true);
make_session_leader(&mut cmd);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
for _ in 0..200 {
if marker.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(
marker.exists(),
"the session-leader child must report readiness"
);
let _ = std::fs::remove_file(&marker);
pg.groups.track_with(pid, false, None);
let adopter_pg = Arc::clone(&pg);
let adopter = std::thread::spawn(move || adopter_pg.adopt_external(pid as u32));
let entered = pause.wait_until_entered(Duration::from_secs(1));
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (finished_tx, finished_rx) = std::sync::mpsc::channel();
let broadcaster_pg = Arc::clone(&pg);
let broadcaster = std::thread::spawn(move || {
started_tx.send(()).expect("broadcast start receiver");
let result = broadcaster_pg.broadcast(0);
finished_tx.send(result).expect("broadcast result receiver");
});
started_rx
.recv_timeout(Duration::from_secs(1))
.expect("broadcast thread started");
let early_broadcast_result = finished_rx.recv_timeout(Duration::from_millis(100)).ok();
let completed_during_reconcile = early_broadcast_result.is_some();
pause.release();
let adoption_result = adopter.join().expect("adoption thread");
let broadcast_result = early_broadcast_result.unwrap_or_else(|| {
finished_rx
.recv_timeout(Duration::from_secs(1))
.expect("broadcast result receiver")
});
broadcaster.join().expect("broadcast thread");
drop(pg);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(entered, "adoption must reach the paused reconcile point");
assert!(
adoption_result.is_ok(),
"adoption should complete after publishing the solo entry: {adoption_result:?}"
);
assert!(
broadcast_result.is_ok(),
"signal-0 broadcast should complete: {broadcast_result:?}"
);
assert!(
!completed_during_reconcile,
"broadcast must wait for the group-to-solo ownership transition"
);
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[test]
fn adoption_reconcile_serializes_with_broadcast_without_a_subprocess() {
let pause = Arc::new(AdoptionPause::new());
let mut process_group = ProcessGroup::new();
process_group.adoption_pause = Some(Arc::clone(&pause));
let pg = Arc::new(process_group);
let pid = std::process::id() as i32;
let anchor = read_identity(pid).expect("this target reports a start-time identity");
pg.groups.track_with(pid, false, None);
let adopter_pg = Arc::clone(&pg);
let adopter = std::thread::spawn(move || {
adopter_pg.reconcile_solo_adoption(pid, Some(anchor));
});
assert!(
pause.wait_until_entered(Duration::from_secs(1)),
"reconciliation must pause after evicting the stale group entry"
);
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (finished_tx, finished_rx) = std::sync::mpsc::channel();
let broadcaster_pg = Arc::clone(&pg);
let broadcaster = std::thread::spawn(move || {
started_tx.send(()).expect("broadcast start receiver");
finished_tx
.send(broadcaster_pg.broadcast(0))
.expect("broadcast result");
});
started_rx
.recv_timeout(Duration::from_secs(1))
.expect("broadcast thread started");
assert!(
finished_rx
.recv_timeout(Duration::from_millis(100))
.is_err(),
"broadcast must remain blocked until solo publication completes"
);
pause.release();
adopter.join().expect("adoption thread");
let broadcast_result = finished_rx
.recv_timeout(Duration::from_secs(1))
.expect("broadcast result receiver");
broadcaster.join().expect("broadcast thread");
assert!(
broadcast_result.is_ok(),
"signal-0 broadcast should complete after reconciliation"
);
let groups = pg.groups.ids.lock().unwrap_or_else(|e| e.into_inner());
assert!(
groups.iter().all(|entry| entry.id != pid),
"stale group entry must not survive the ownership transition"
);
drop(groups);
let mut solos = pg.solos.ids.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(
solos.iter().filter(|entry| entry.id == pid).count(),
1,
"the reconciled process must be tracked once as a solo"
);
assert_eq!(
solos
.iter()
.find(|entry| entry.id == pid)
.map(|entry| entry.identity),
Some(Some(anchor)),
"the solo entry must retain the identity anchor"
);
solos.clear();
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns a real subprocess"]
async fn adopt_external_group_publication_serializes_with_failed_adoption() {
use std::io::{Read, Write};
let group_pause = Arc::new(AdoptionPause::new());
let mut process_group = ProcessGroup::new();
process_group.group_adoption_pause = Some(Arc::clone(&group_pause));
let pg = Arc::new(process_group);
let mut race_child = AdoptionRaceChild::spawn();
let _pause_release = AdoptionPauseRelease(Arc::clone(&group_pause));
let ready_read = race_child.take_ready();
let ready_wait = tokio::task::spawn_blocking(move || {
let mut file = ready_read;
let mut byte = [0u8];
file.read_exact(&mut byte)
});
tokio::time::timeout(Duration::from_secs(1), ready_wait)
.await
.expect("pre-exec readiness must not hang")
.expect("pre-exec readiness waiter must not panic")
.expect("child must reach the pre-exec barrier");
let pid = race_child.pid();
let anchor = read_identity(pid).expect("this target reports a start-time identity");
let adopter_pg = Arc::clone(&pg);
let successful = std::thread::spawn(move || adopter_pg.adopt_external(pid as u32));
assert!(
group_pause.wait_until_entered(Duration::from_secs(1)),
"successful adoption must pause after taking ownership"
);
race_child
.release()
.write_all(&[1])
.expect("release the pre-exec barrier");
let exec_ready_read = race_child.take_exec_ready();
let exec_ready_wait = tokio::task::spawn_blocking(move || {
let mut file = exec_ready_read;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
});
let exec_ready_bytes = tokio::time::timeout(Duration::from_secs(1), exec_ready_wait)
.await
.expect("post-exec readiness must not hang")
.expect("post-exec readiness waiter must not panic")
.expect("child must close the post-exec marker on exec");
assert_eq!(exec_ready_bytes, 0, "post-exec marker must be EOF-only");
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (finished_tx, finished_rx) = std::sync::mpsc::channel();
let failed_pg = Arc::clone(&pg);
let failed = std::thread::spawn(move || {
started_tx.send(()).expect("failed adoption start receiver");
finished_tx
.send(failed_pg.adopt_external(pid as u32))
.expect("failed adoption result receiver");
});
started_rx
.recv_timeout(Duration::from_secs(1))
.expect("failed adoption thread started");
assert!(
finished_rx
.recv_timeout(Duration::from_millis(100))
.is_err(),
"failed adoption must wait for successful group publication"
);
group_pause.release();
let successful_result = successful.join().expect("successful adoption thread");
let failed_result = finished_rx
.recv_timeout(Duration::from_secs(1))
.expect("failed adoption result receiver");
failed.join().expect("failed adoption thread");
assert!(
successful_result.is_ok(),
"successful adoption: {successful_result:?}"
);
assert!(failed_result.is_ok(), "failed adoption: {failed_result:?}");
let groups = pg.groups.ids.lock().unwrap_or_else(|e| e.into_inner());
let solos = pg.solos.ids.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(
groups.iter().filter(|entry| entry.id == pid).count(),
1,
"successful adoption must publish one group entry"
);
assert_eq!(
groups
.iter()
.find(|entry| entry.id == pid)
.map(|entry| entry.identity),
Some(Some(anchor)),
"the group entry must retain the adoption anchor"
);
assert_eq!(
solos.iter().filter(|entry| entry.id == pid).count(),
0,
"failed reconciliation must not split the adopted process into solos"
);
drop(solos);
drop(groups);
drop(pg);
race_child.kill_and_reap();
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns a real subprocess"]
async fn adopt_child_group_publication_serializes_with_external_adoption() {
use std::io::{Read, Write};
let group_pause = Arc::new(AdoptionPause::new());
let mut process_group = ProcessGroup::new();
process_group.group_adoption_pause = Some(Arc::clone(&group_pause));
let pg = Arc::new(process_group);
let mut race_child = AdoptionRaceChild::spawn();
let _pause_release = AdoptionPauseRelease(Arc::clone(&group_pause));
let ready_read = race_child.take_ready();
let ready_wait = tokio::task::spawn_blocking(move || {
let mut file = ready_read;
let mut byte = [0u8];
file.read_exact(&mut byte)
});
tokio::time::timeout(Duration::from_secs(1), ready_wait)
.await
.expect("pre-exec readiness must not hang")
.expect("pre-exec readiness waiter must not panic")
.expect("child must reach the pre-exec barrier");
let pid = race_child.pid();
let anchor = read_identity(pid).expect("this target reports a start-time identity");
let adopter_pg = Arc::clone(&pg);
let successful = std::thread::spawn(move || adopter_pg.adopt_pid(pid));
assert!(
group_pause.wait_until_entered(Duration::from_secs(1)),
"successful child adoption must pause after taking ownership"
);
race_child
.release()
.write_all(&[1])
.expect("release the pre-exec barrier");
let exec_ready_read = race_child.take_exec_ready();
let exec_ready_wait = tokio::task::spawn_blocking(move || {
let mut file = exec_ready_read;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
});
let exec_ready_bytes = tokio::time::timeout(Duration::from_secs(1), exec_ready_wait)
.await
.expect("post-exec readiness must not hang")
.expect("post-exec readiness waiter must not panic")
.expect("child must close the post-exec marker on exec");
assert_eq!(exec_ready_bytes, 0, "post-exec marker must be EOF-only");
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (finished_tx, finished_rx) = std::sync::mpsc::channel();
let failed_pg = Arc::clone(&pg);
let failed = std::thread::spawn(move || {
started_tx
.send(())
.expect("external adoption start receiver");
finished_tx
.send(failed_pg.adopt_external(pid as u32))
.expect("external adoption result receiver");
});
started_rx
.recv_timeout(Duration::from_secs(1))
.expect("external adoption thread started");
assert!(
finished_rx
.recv_timeout(Duration::from_millis(100))
.is_err(),
"external adoption must wait for successful child publication"
);
group_pause.release();
let successful_result = successful.join().expect("child adoption thread");
let failed_result = finished_rx
.recv_timeout(Duration::from_secs(1))
.expect("external adoption result receiver");
failed.join().expect("external adoption thread");
assert!(
successful_result.is_ok(),
"successful child adoption: {successful_result:?}"
);
assert!(
failed_result.is_ok(),
"external adoption: {failed_result:?}"
);
let groups = pg.groups.ids.lock().unwrap_or_else(|e| e.into_inner());
let solos = pg.solos.ids.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(
groups.iter().filter(|entry| entry.id == pid).count(),
1,
"successful child adoption must publish one group entry"
);
assert_eq!(
groups
.iter()
.find(|entry| entry.id == pid)
.map(|entry| entry.identity),
Some(Some(anchor)),
"the group entry must retain the adoption anchor"
);
assert_eq!(
solos.iter().filter(|entry| entry.id == pid).count(),
0,
"external adoption must not split the child into solos"
);
drop(solos);
drop(groups);
drop(pg);
race_child.kill_and_reap();
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test]
async fn adopt_external_of_a_pid_that_names_nothing_is_not_found() {
let pg = ProcessGroup::new();
let err = pg
.adopt_external(2_000_000_000)
.expect_err("a pid that names nothing is not adoptable");
assert_eq!(err.kind(), io::ErrorKind::NotFound, "{err:?}");
assert!(
pg.members().is_empty(),
"a refused adoption must track nothing"
);
}
#[cfg(all(
feature = "process-control",
not(any(target_os = "linux", target_os = "android", target_vendor = "apple"))
))]
#[tokio::test]
async fn adopt_external_is_unsupported_without_an_identity_reader() {
let pg = ProcessGroup::new();
let err = pg
.adopt_external(std::process::id())
.expect_err("no identity reader here, so nothing is adoptable by pid");
assert_eq!(err.kind(), io::ErrorKind::Unsupported, "{err:?}");
assert!(
pg.members().is_empty(),
"a refused adoption must track nothing"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn spawn_seeds_group_seen_false_on_both_paths() {
for setsid in [false, true] {
let pg = ProcessGroup::new();
let opts = crate::sys::SpawnOptions {
setsid,
..Default::default()
};
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 60");
cmd.kill_on_drop(true);
let mut child = pg.spawn(&mut cmd, &opts).unwrap();
let pid = child.id().unwrap() as i32;
let seeded_false = {
let ids = pg.groups.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.iter()
.find(|e| e.id == pid)
.map(|e| !e.group_seen)
.unwrap_or(false)
};
drop(pg);
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(
seeded_false,
"spawn (setsid={setsid}) must seed group_seen=false so the fallback \
window stays open until the first successful group probe",
);
}
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn signal_all_keeps_and_signals_a_not_yet_grouped_child() {
let tracked = Tracked::new(true);
let mut child = Command::new("sh")
.arg("-c")
.arg("trap '' TERM; while :; do :; done")
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
if unsafe { libc::kill(-pid, 0) } == 0 {
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
return;
}
tracked.track(pid, false);
let _ = tracked.signal_all(libc::SIGTERM);
let still_tracked = {
let ids = tracked.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.iter().any(|e| e.id == pid)
};
let alive = unsafe { libc::kill(pid, 0) } == 0;
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(
still_tracked,
"a not-yet-grouped live child must not be pruned by a teardown sweep"
);
assert!(
alive,
"the child must survive a trapped SIGTERM — it was signalled, not lost"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn untracked_guard_reaps_the_child_on_an_armed_drop() {
use std::os::unix::process::CommandExt as _;
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 60");
cmd.as_std_mut().process_group(0);
let child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"the child is alive right after spawn"
);
drop(UntrackedChildGuard::arm(child));
let mut dead = false;
for _ in 0..200 {
let r = unsafe { libc::waitpid(pid, std::ptr::null_mut(), libc::WNOHANG) };
if r == pid
|| (r == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ECHILD))
{
dead = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
assert!(
dead,
"an armed guard drop must terminate the untracked child"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn untracked_guard_disarm_hands_back_a_live_child() {
use std::os::unix::process::CommandExt as _;
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("sleep 60");
cmd.as_std_mut().process_group(0);
let child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
let mut kept = UntrackedChildGuard::arm(child).disarm();
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"disarm must leave the child running"
);
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = kept.wait().await;
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn group_pgid_reuse_without_esrch_is_not_signalled() {
use std::os::unix::process::CommandExt as _;
let tracked = Tracked::new(true);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("trap '' TERM; while :; do :; done");
cmd.kill_on_drop(true);
cmd.as_std_mut().process_group(0);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
assert!(
unsafe { libc::kill(-pid, 0) } == 0,
"the stand-in must lead its own group"
);
let Some(real) = read_identity(pid) else {
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
return;
};
{
let mut ids = tracked.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.push(Entry {
id: pid,
group_seen: true,
identity: Some(real ^ 1),
});
}
let _ = tracked.signal_all(libc::SIGTERM);
let still_tracked = {
let ids = tracked.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.iter().any(|e| e.id == pid)
};
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(
!still_tracked,
"a recycled-pgid entry (identity mismatch, no intervening ESRCH) must \
be pruned by the sweep, so the stranger group is never signalled"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn solo_pid_reuse_without_esrch_is_not_signalled() {
let tracked = Tracked::new(false);
let mut child = Command::new("sh")
.arg("-c")
.arg("trap '' TERM; while :; do :; done")
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"the stand-in solo pid must be alive"
);
let Some(real) = read_identity(pid) else {
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
return;
};
{
let mut ids = tracked.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.push(Entry {
id: pid,
group_seen: false,
identity: Some(real ^ 1),
});
}
let _ = tracked.signal_all(libc::SIGTERM);
let still_tracked = {
let ids = tracked.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.iter().any(|e| e.id == pid)
};
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(
!still_tracked,
"a recycled solo pid (identity mismatch) must be pruned by the sweep, \
so the stranger process is never signalled"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn matching_identity_group_is_kept_and_signalled() {
use std::os::unix::process::CommandExt as _;
let tracked = Tracked::new(true);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("trap '' TERM; while :; do :; done");
cmd.kill_on_drop(true);
cmd.as_std_mut().process_group(0);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
assert!(
unsafe { libc::kill(-pid, 0) } == 0,
"the child must lead its own group"
);
tokio::time::sleep(Duration::from_millis(50)).await;
tracked.track(pid, true);
let _ = tracked.signal_all(libc::SIGTERM);
let still_tracked = {
let ids = tracked.ids.lock().unwrap_or_else(|e| e.into_inner());
ids.iter().any(|e| e.id == pid)
};
let alive = unsafe { libc::kill(-pid, 0) } == 0;
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
assert!(
still_tracked,
"a live, identity-matching group must be kept by the sweep"
);
assert!(
alive,
"a matching-identity group must be signalled (trapped TERM) — the gate \
must not prune it"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn is_live_non_zombie_is_true_for_a_running_process() {
let mut child = Command::new("sh")
.arg("-c")
.arg("sleep 60")
.kill_on_drop(true)
.spawn()
.unwrap();
let pid = child.id().unwrap() as i32;
let verdict = is_live_non_zombie(pid);
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
assert!(
verdict,
"a running child must classify as live/non-zombie (the state that \
surfaces a genuine SIGKILL EPERM)"
);
#[cfg(not(any(target_os = "linux", target_os = "android", target_vendor = "apple")))]
assert!(
!verdict,
"targets without a state reader (the BSDs) always classify as not-live, \
so a delivery EPERM stays swallowed there"
);
}
#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
#[tokio::test]
#[ignore = "spawns a real subprocess and leaves it an unreaped zombie"]
async fn zombie_only_group_teardown_reports_success() {
use std::os::unix::process::CommandExt as _;
let tracked = Tracked::new(true);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("exit 0");
cmd.kill_on_drop(true);
cmd.as_std_mut().process_group(0);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
let mut became_zombie = false;
for _ in 0..500 {
if !is_live_non_zombie(pid) {
became_zombie = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(became_zombie, "the child never became an observable zombie");
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"the exited-but-unreaped child must still exist as a zombie"
);
tracked.track(pid, true);
let outcome = tracked.signal_all(libc::SIGKILL);
let _ = child.wait().await;
outcome.expect(
"a zombie-only group's killpg EPERM must be swallowed, not surfaced — \
surfacing it is the false positive that reverted the first attempt",
);
}
#[cfg(all(
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
#[tokio::test]
#[ignore = "spawns a real subprocess and leaves it an unreaped zombie"]
async fn suspend_resume_zombie_only_group_reports_success() {
use std::os::unix::process::CommandExt as _;
let pg = ProcessGroup::new();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("exit 0");
cmd.kill_on_drop(true);
cmd.as_std_mut().process_group(0);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
let mut became_zombie = false;
for _ in 0..500 {
if !is_live_non_zombie(pid) {
became_zombie = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(became_zombie, "the child never became an observable zombie");
assert!(
unsafe { libc::kill(pid, 0) } == 0,
"the exited-but-unreaped child must still exist as a zombie"
);
pg.groups.track(pid, true);
let suspend_outcome = pg.suspend();
let resume_outcome = pg.resume();
let _ = child.wait().await;
suspend_outcome.expect("suspending a zombie-only group must remain a no-op success");
resume_outcome.expect("resuming a zombie-only group must remain a no-op success");
}
#[cfg(feature = "process-control")]
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn suspend_resume_on_live_group_succeeds() {
let pg = ProcessGroup::new();
let opts = crate::sys::SpawnOptions::default();
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("while :; do :; done");
cmd.kill_on_drop(true);
let mut child = pg.spawn(&mut cmd, &opts).unwrap();
let pid = child.id().unwrap() as i32;
tokio::time::sleep(Duration::from_millis(50)).await;
let suspend_outcome = pg.suspend();
let mut observed_stop = false;
for _ in 0..500 {
let mut status = 0;
let waited = unsafe {
libc::waitpid(
pid,
std::ptr::addr_of_mut!(status),
libc::WNOHANG | libc::WUNTRACED,
)
};
if waited == pid && libc::WIFSTOPPED(status) {
observed_stop = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let resume_outcome = pg.resume();
let mut observed_continue = false;
for _ in 0..500 {
let mut status = 0;
let waited = unsafe {
libc::waitpid(
pid,
std::ptr::addr_of_mut!(status),
libc::WNOHANG | libc::WCONTINUED,
)
};
if waited == pid && libc::WIFCONTINUED(status) {
observed_continue = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
suspend_outcome.expect("SIGSTOP delivery to a live group must return Ok");
assert!(
observed_stop,
"the live group never entered a waitpid-observable stopped state"
);
resume_outcome.expect("SIGCONT delivery to a live group must return Ok");
assert!(
observed_continue,
"the live group never produced a waitpid-observable continued state"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn signal_all_surfaces_einval_for_a_live_group() {
use std::os::unix::process::CommandExt as _;
let tracked = Tracked::new(true);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("trap '' TERM; while :; do :; done");
cmd.kill_on_drop(true);
cmd.as_std_mut().process_group(0);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
assert!(
unsafe { libc::kill(-pid, 0) } == 0,
"the child must lead its own group"
);
tracked.track(pid, true);
let outcome = tracked.signal_all(BOGUS_SIGNAL);
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
let err = outcome.expect_err(
"an out-of-range signal number must surface EINVAL, not be swallowed as \
a false success",
);
assert_eq!(
err.raw_os_error(),
Some(libc::EINVAL),
"the surfaced error must be the EINVAL from the malformed send"
);
}
#[tokio::test]
#[ignore = "spawns a real subprocess"]
async fn other_zero_probes_without_delivering_and_returns_ok() {
use std::os::unix::process::CommandExt as _;
let tracked = Tracked::new(true);
let mut cmd = Command::new("sh");
cmd.arg("-c").arg("while :; do :; done");
cmd.kill_on_drop(true);
cmd.as_std_mut().process_group(0);
let mut child = cmd.spawn().unwrap();
let pid = child.id().unwrap() as i32;
assert!(
unsafe { libc::kill(-pid, 0) } == 0,
"the child must lead its own group"
);
tracked.track(pid, true);
let outcome = tracked.signal_all(0);
let alive = unsafe { libc::kill(-pid, 0) } == 0;
let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
let _ = unsafe { libc::kill(pid, libc::SIGKILL) };
let _ = child.wait().await;
outcome.expect(
"signal 0 is a probe over a live target — it returns Ok having delivered \
nothing, never an error",
);
assert!(
alive,
"signal 0 delivers nothing (POSIX existence probe), so the child must \
survive: an Ok here is not proof of delivery"
);
}
#[test]
fn signal_all_on_an_empty_set_is_ok_even_for_a_bogus_signal() {
Tracked::new(true)
.signal_all(BOGUS_SIGNAL)
.expect("an empty group signals nothing, so a bogus number is a no-op Ok");
Tracked::new(false)
.signal_all(BOGUS_SIGNAL)
.expect("an empty solo set signals nothing either — still a no-op Ok");
}
}
#[cfg(all(
test,
feature = "process-control",
any(target_os = "linux", target_os = "android", target_vendor = "apple")
))]
mod delivery_error_paths {
use super::ProcessGroup;
use crate::sys::fault_injection::{Faults, Site};
const SITE: Site = Site::PgroupSignalDelivery;
fn group_tracking_this_process(
faults: Faults,
) -> (super::super::fault_injection::Armed, ProcessGroup) {
let armed = faults.arm();
let group = ProcessGroup::new();
let epoch = group.skip_drop_kill.begin_shutdown();
group.skip_drop_kill.request(epoch);
group
.groups
.track(std::process::id() as i32, false);
(armed, group)
}
#[test]
fn a_live_member_rejecting_the_broadcast_makes_suspend_fail() {
let (faults, group) =
group_tracking_this_process(Faults::new().fail_every(SITE, None, libc::EPERM));
let err = group
.suspend()
.expect_err("a live member that rejects SIGSTOP is a gap, not a success");
assert!(faults.fired(SITE) >= 1, "the delivery really was refused");
assert_eq!(
err.raw_os_error(),
Some(libc::EPERM),
"the refusal reaches the caller as itself"
);
let public = crate::group::map_unsupported(err, "suspend");
assert_eq!(
public.kind(),
crate::ErrorKind::PermissionDenied,
"a refused signal is a permission problem — never `Unsupported`, which \
would claim the mechanism cannot suspend at all"
);
match public.reason() {
crate::ErrorReason::Io(source) => assert_eq!(
source.raw_os_error(),
Some(libc::EPERM),
"the errno survives the public mapping"
),
other => panic!("expected a plain Io failure, got {other:?}"),
}
}
#[test]
fn a_live_member_rejecting_the_broadcast_makes_resume_fail() {
let (faults, group) =
group_tracking_this_process(Faults::new().fail_every(SITE, None, libc::EPERM));
let err = group
.resume()
.expect_err("a live member that rejects SIGCONT is a gap, not a success");
assert!(faults.fired(SITE) >= 1);
assert_eq!(err.raw_os_error(), Some(libc::EPERM));
}
#[test]
fn an_already_exited_member_keeps_the_broadcast_successful() {
let (faults, group) =
group_tracking_this_process(Faults::new().fail_every(SITE, None, libc::ESRCH));
group
.suspend()
.expect("a target that is already gone is nothing to report");
assert!(
faults.fired(SITE) >= 1,
"the delivery was attempted and refused — the Ok is the classification"
);
}
#[test]
fn a_malformed_request_surfaces_regardless_of_the_targets_state() {
let (faults, group) =
group_tracking_this_process(Faults::new().fail_every(SITE, None, libc::EINVAL));
let err = group
.suspend()
.expect_err("a bad signal number is a malformed request, always reported");
assert!(faults.fired(SITE) >= 1);
assert_eq!(err.raw_os_error(), Some(libc::EINVAL));
}
}