#[cfg(unix)]
use std::sync::atomic::{AtomicI32, Ordering};
#[cfg(unix)]
const UNUSED_SLOT: i32 = 0;
#[cfg(unix)]
const MAX_TRACKED_PROCESS_GROUPS: usize = 512;
#[cfg(unix)]
static TRACKED_PROCESS_GROUPS: [AtomicI32; MAX_TRACKED_PROCESS_GROUPS] =
[const { AtomicI32::new(UNUSED_SLOT) }; MAX_TRACKED_PROCESS_GROUPS];
#[cfg(unix)]
const FORWARDED_SIGNALS: &[libc::c_int] = &[libc::SIGINT, libc::SIGTERM, libc::SIGHUP];
pub(crate) struct TrackedProcessGroup {
#[cfg(unix)]
slot: Option<usize>,
#[cfg(windows)]
job: Option<crate::process::job::JobObject>,
}
#[cfg(windows)]
impl TrackedProcessGroup {
pub(crate) fn terminate_tree(&self) -> bool {
self.job.as_ref().is_some_and(crate::process::job::JobObject::terminate)
}
}
#[cfg(unix)]
impl Drop for TrackedProcessGroup {
fn drop(&mut self) {
if let Some(slot) = self.slot {
TRACKED_PROCESS_GROUPS[slot].store(UNUSED_SLOT, Ordering::Release);
}
}
}
#[cfg(unix)]
pub(crate) fn track(child: &std::process::Child) -> TrackedProcessGroup {
install_termination_forwarding();
TrackedProcessGroup {
slot: claim_slot(&TRACKED_PROCESS_GROUPS, child.id().cast_signed()),
}
}
#[cfg(windows)]
pub(crate) fn track(child: &std::process::Child) -> TrackedProcessGroup {
TrackedProcessGroup {
job: crate::process::job::JobObject::holding(child),
}
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn track(_child: &std::process::Child) -> TrackedProcessGroup {
TrackedProcessGroup {}
}
#[cfg(unix)]
fn claim_slot(slots: &[AtomicI32], process_group: i32) -> Option<usize> {
slots.iter().position(|slot| {
slot.compare_exchange(UNUSED_SLOT, process_group, Ordering::AcqRel, Ordering::Relaxed)
.is_ok()
})
}
#[cfg(unix)]
fn kill_tracked_process_groups(slots: &[AtomicI32]) {
for slot in slots {
let process_group = slot.load(Ordering::Acquire);
if process_group != UNUSED_SLOT {
unsafe {
libc::kill(-process_group, libc::SIGKILL);
}
}
}
}
#[cfg(unix)]
extern "C" fn forward_termination(signal: libc::c_int) {
kill_tracked_process_groups(&TRACKED_PROCESS_GROUPS);
unsafe {
libc::signal(signal, libc::SIG_DFL);
libc::raise(signal);
}
}
#[cfg(unix)]
static SIGNALS_LEFT_IGNORED: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
#[cfg(unix)]
fn install_termination_forwarding() {
static INSTALL: std::sync::Once = std::sync::Once::new();
const _: () = assert!(
FORWARDED_SIGNALS.len() <= u8::BITS as usize,
"SIGNALS_LEFT_IGNORED is a u8 bitmask; widen it before forwarding more signals"
);
INSTALL.call_once(|| {
for (index, signal) in FORWARDED_SIGNALS.iter().enumerate() {
unsafe {
let previous = libc::signal(*signal, forward_termination as *const () as libc::sighandler_t);
if previous == libc::SIG_IGN {
libc::signal(*signal, libc::SIG_IGN);
SIGNALS_LEFT_IGNORED.fetch_or(1 << index, std::sync::atomic::Ordering::Release);
}
}
}
});
}
#[cfg(all(test, unix))]
mod tests {
use super::{TRACKED_PROCESS_GROUPS, UNUSED_SLOT, claim_slot, kill_tracked_process_groups};
use std::sync::atomic::{AtomicI32, Ordering};
const SLOT_COUNT: usize = 4;
const PROCESS_SETTLE_POLL: std::time::Duration = std::time::Duration::from_millis(20);
const PROCESS_SETTLE_LIMIT: std::time::Duration = std::time::Duration::from_secs(5);
const ORPHAN_PROBE_MARKER: &str = "ALEF_TERMINATION_ORPHAN_PROBE";
const ORPHAN_PROBE_NAME: &str = "process::termination::tests::orphan_probe_child";
fn slots() -> Vec<AtomicI32> {
(0..SLOT_COUNT).map(|_| AtomicI32::new(UNUSED_SLOT)).collect()
}
fn is_alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
fn wait_until_gone(pid: i32) -> bool {
let deadline = std::time::Instant::now() + PROCESS_SETTLE_LIMIT;
while std::time::Instant::now() < deadline {
if !is_alive(pid) {
return true;
}
std::thread::sleep(PROCESS_SETTLE_POLL);
}
!is_alive(pid)
}
fn spawn_group_with_grandchild(directory: &std::path::Path) -> (std::process::Child, i32, i32) {
use std::os::unix::process::CommandExt;
let marker = directory.join("grandchild.pid");
let mut command = std::process::Command::new("sh");
command
.args(["-c", &format!("sleep 60 & echo $! > {}; sleep 60", marker.display())])
.process_group(0);
let child = command.spawn().expect("spawn the process group");
let parent = child.id().cast_signed();
let deadline = std::time::Instant::now() + PROCESS_SETTLE_LIMIT;
let grandchild = loop {
assert!(
std::time::Instant::now() < deadline,
"grandchild never announced itself"
);
if let Ok(contents) = std::fs::read_to_string(&marker)
&& let Ok(pid) = contents.trim().parse::<i32>()
&& is_alive(pid)
{
break pid;
}
std::thread::sleep(PROCESS_SETTLE_POLL);
};
assert!(is_alive(parent), "the tracked shell must be running before the sweep");
(child, parent, grandchild)
}
#[test]
fn sweeping_a_tracked_group_kills_its_grandchildren_too() {
let directory = tempfile::tempdir().expect("scratch directory");
let (mut child, parent, grandchild) = spawn_group_with_grandchild(directory.path());
let table = slots();
claim_slot(&table, parent).expect("a free slot");
kill_tracked_process_groups(&table);
assert!(
wait_until_gone(grandchild),
"grandchild {grandchild} survived the sweep"
);
let _ = child.wait();
assert!(wait_until_gone(parent), "tracked shell {parent} survived the sweep");
}
#[test]
fn sweeping_leaves_an_untracked_group_running() {
let directory = tempfile::tempdir().expect("scratch directory");
let (mut child, parent, grandchild) = spawn_group_with_grandchild(directory.path());
let table = slots();
kill_tracked_process_groups(&table);
std::thread::sleep(PROCESS_SETTLE_POLL);
assert!(is_alive(parent), "an untracked shell must not be swept");
assert!(is_alive(grandchild), "an untracked grandchild must not be swept");
unsafe {
libc::kill(-parent, libc::SIGKILL);
}
let _ = child.wait();
}
#[test]
fn a_released_slot_is_reused_and_no_longer_swept() {
let table = slots();
let slot = claim_slot(&table, 4242).expect("a free slot");
table[slot].store(UNUSED_SLOT, Ordering::Release);
assert_eq!(claim_slot(&table, 5353), Some(slot));
assert_eq!(table[slot].load(Ordering::Acquire), 5353);
}
#[test]
fn a_full_table_refuses_further_slots() {
let table = slots();
for index in 0..SLOT_COUNT {
let pid = i32::try_from(index).expect("slot index fits an i32");
assert_eq!(claim_slot(&table, 100 + pid), Some(index));
}
assert_eq!(claim_slot(&table, 999), None);
}
#[test]
fn termination_forwarding_owns_every_interactive_signal() {
super::install_termination_forwarding();
let expected = super::forward_termination as *const () as libc::sighandler_t;
let left_ignored = super::SIGNALS_LEFT_IGNORED.load(Ordering::Acquire);
for (index, signal) in super::FORWARDED_SIGNALS.iter().enumerate() {
let mut current: libc::sigaction = unsafe { std::mem::zeroed() };
let read = unsafe { libc::sigaction(*signal, std::ptr::null(), &raw mut current) };
assert_eq!(read, 0, "reading the disposition of signal {signal}");
if left_ignored & (1 << index) == 0 {
assert_eq!(
current.sa_sigaction, expected,
"signal {signal} must be forwarded to the tracked child groups"
);
} else {
assert_eq!(
current.sa_sigaction,
libc::SIG_IGN,
"signal {signal} arrived ignored, so it must stay ignored rather than be armed"
);
}
}
}
#[test]
#[ignore = "spawned as a subprocess by a_signalled_alef_does_not_orphan_its_child_tree"]
fn orphan_probe_child() {
let Ok(marker) = std::env::var(ORPHAN_PROBE_MARKER) else {
return;
};
let mut command = std::process::Command::new("sh");
command.args(["-c", &format!("echo $$ > {marker}; sleep 30")]);
let _ = crate::snippets::validators::run_command(&mut command, 30);
}
#[test]
fn a_signalled_alef_does_not_orphan_its_child_tree() {
let directory = tempfile::tempdir().expect("scratch directory");
let marker = directory.path().join("group.pid");
let mut probe = std::process::Command::new(std::env::current_exe().expect("the test binary"))
.args(["--exact", ORPHAN_PROBE_NAME, "--ignored", "--test-threads=1"])
.env(ORPHAN_PROBE_MARKER, &marker)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn the probe");
let deadline = std::time::Instant::now() + PROCESS_SETTLE_LIMIT;
let orphan = loop {
assert!(
std::time::Instant::now() < deadline,
"the probe never announced a child"
);
if let Ok(contents) = std::fs::read_to_string(&marker)
&& let Ok(pid) = contents.trim().parse::<i32>()
&& is_alive(pid)
{
break pid;
}
std::thread::sleep(PROCESS_SETTLE_POLL);
};
let signalled = unsafe { libc::kill(probe.id().cast_signed(), libc::SIGINT) };
assert_eq!(signalled, 0, "signalling the probe");
probe.wait().expect("the probe exits");
let survived = !wait_until_gone(orphan);
if survived {
unsafe {
libc::kill(-orphan, libc::SIGKILL);
}
}
assert!(
!survived,
"child group {orphan} outlived the alef process that spawned it -- the tree was orphaned"
);
}
#[test]
fn run_command_registers_its_child_in_the_process_wide_table() {
let directory = tempfile::tempdir().expect("scratch directory");
let marker = directory.path().join("child.pid");
let script = format!("echo $$ > {}; sleep 5", marker.display());
let worker = std::thread::spawn(move || {
let mut command = std::process::Command::new("sh");
command.args(["-c", &script]);
let _ = crate::snippets::validators::run_command(&mut command, 2);
});
let deadline = std::time::Instant::now() + PROCESS_SETTLE_LIMIT;
let mut tracked = None;
while std::time::Instant::now() < deadline && tracked.is_none() {
if let Ok(contents) = std::fs::read_to_string(&marker)
&& let Ok(pid) = contents.trim().parse::<i32>()
{
tracked = TRACKED_PROCESS_GROUPS
.iter()
.any(|slot| slot.load(Ordering::Acquire) == pid)
.then_some(pid);
}
std::thread::sleep(PROCESS_SETTLE_POLL);
}
worker.join().expect("the run_command worker");
assert!(
tracked.is_some(),
"run_command must register its own child's process group while it runs"
);
}
}