#![cfg(target_os = "linux")]
use std::collections::HashSet;
use std::sync::mpsc::Sender;
use std::time::Duration;
use crate::observer::{EventCategory, ObserverEvent, ObserverEventKind};
const POLL_INTERVAL: Duration = Duration::from_millis(50);
pub(crate) fn enable_subreaper() {
let _ = unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) };
}
pub(crate) fn spawn_pump(root_pid: u32, sink: Sender<ObserverEvent>) {
let _ = std::thread::Builder::new()
.name("rp-linux-descpump".to_string())
.spawn(move || pump_loop(root_pid, sink));
}
fn descendant_pids(root_pid: u32) -> Vec<u32> {
let mut result = Vec::new();
let mut stack: Vec<u32> = vec![root_pid];
while let Some(pid) = stack.pop() {
let path = format!("/proc/{pid}/task/{pid}/children");
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
for token in contents.split_ascii_whitespace() {
if let Ok(child) = token.parse::<u32>() {
result.push(child);
stack.push(child);
}
}
}
result
}
fn pump_loop(root_pid: u32, sink: Sender<ObserverEvent>) {
let mut known: HashSet<u32> = HashSet::new();
let root_path = format!("/proc/{root_pid}");
loop {
if !std::path::Path::new(&root_path).exists() {
break;
}
let current: HashSet<u32> = descendant_pids(root_pid).into_iter().collect();
emit_diff(&known, ¤t, &sink);
known = current;
std::thread::sleep(POLL_INTERVAL);
}
for &pid in &known {
let _ = sink.send(ObserverEvent::new_now(
EventCategory::Process,
ObserverEventKind::DescendantExited,
pid,
));
}
}
fn emit_diff(prev: &HashSet<u32>, current: &HashSet<u32>, sink: &Sender<ObserverEvent>) {
for &new_pid in current.difference(prev) {
let _ = sink.send(ObserverEvent::new_now(
EventCategory::Process,
ObserverEventKind::DescendantStarted,
new_pid,
));
}
for &gone_pid in prev.difference(current) {
let _ = sink.send(ObserverEvent::new_now(
EventCategory::Process,
ObserverEventKind::DescendantExited,
gone_pid,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn emit_diff_fires_one_started_per_new_pid() {
let (tx, rx) = mpsc::channel();
let prev: HashSet<u32> = [10, 20].into_iter().collect();
let current: HashSet<u32> = [10, 20, 30, 40].into_iter().collect();
emit_diff(&prev, ¤t, &tx);
drop(tx);
let evs: Vec<_> = rx.iter().collect();
assert_eq!(evs.len(), 2);
let started_pids: HashSet<u32> = evs
.iter()
.filter(|e| matches!(e.kind, ObserverEventKind::DescendantStarted))
.map(|e| e.pid)
.collect();
assert_eq!(started_pids, [30, 40].into_iter().collect::<HashSet<_>>());
}
#[test]
fn emit_diff_fires_one_exited_per_gone_pid() {
let (tx, rx) = mpsc::channel();
let prev: HashSet<u32> = [10, 20, 30].into_iter().collect();
let current: HashSet<u32> = [10].into_iter().collect();
emit_diff(&prev, ¤t, &tx);
drop(tx);
let evs: Vec<_> = rx.iter().collect();
assert_eq!(evs.len(), 2);
let exited_pids: HashSet<u32> = evs
.iter()
.filter(|e| matches!(e.kind, ObserverEventKind::DescendantExited))
.map(|e| e.pid)
.collect();
assert_eq!(exited_pids, [20, 30].into_iter().collect::<HashSet<_>>());
}
#[test]
fn emit_diff_no_events_when_steady_state() {
let (tx, rx) = mpsc::channel();
let prev: HashSet<u32> = [10, 20].into_iter().collect();
let current = prev.clone();
emit_diff(&prev, ¤t, &tx);
drop(tx);
assert_eq!(rx.iter().count(), 0);
}
#[test]
fn descendant_pids_for_nonexistent_root_returns_empty() {
let pids = descendant_pids(0x7FFF_FFFE);
assert!(pids.is_empty(), "expected no descendants, got {pids:?}");
}
#[test]
fn descendant_pids_for_self_includes_no_phantom_entries() {
let pids = descendant_pids(std::process::id());
for pid in pids {
assert!(pid > 1, "pid {pid} is suspiciously small");
}
}
#[test]
fn end_to_end_descendant_started_and_exited_for_spawned_chain() {
use crate::observer::ObserverConfig;
use crate::{CommandSpec, NativeProcess, ProcessConfig, StderrMode, StdinMode};
let cfg = ProcessConfig {
command: CommandSpec::Argv(vec![
"bash".into(),
"-c".into(),
"sleep 0.5 & sleep 0.5 & sleep 0.5 & wait".into(),
]),
cwd: None,
env: None,
capture: false,
stderr_mode: StderrMode::Stdout,
creationflags: None,
create_process_group: false,
stdin_mode: StdinMode::Inherit,
nice: None,
};
let (process, subscriber) = NativeProcess::with_observer(
cfg,
ObserverConfig::with_categories([EventCategory::Process]),
);
process.start().expect("spawn bash chain");
let _ = process
.wait(Some(Duration::from_secs(30)))
.expect("bash chain exits");
process.close().ok();
std::thread::sleep(Duration::from_millis(200));
let events = subscriber.drain();
let started = events
.iter()
.filter(|e| {
e.category == EventCategory::Process
&& matches!(e.kind, ObserverEventKind::DescendantStarted)
})
.count();
let exited = events
.iter()
.filter(|e| {
e.category == EventCategory::Process
&& matches!(e.kind, ObserverEventKind::DescendantExited)
})
.count();
assert!(
started >= 3,
"expected ≥3 DescendantStarted events, got {started} (all: {events:?})"
);
assert!(
exited >= 3,
"expected ≥3 DescendantExited events, got {exited} (all: {events:?})"
);
for ev in &events {
assert_eq!(
ev.category,
EventCategory::Process,
"Lifecycle leaked into Process-only subscriber: {ev:?}"
);
}
}
}