#![cfg(target_os = "macos")]
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::Sender;
use std::time::Duration;
use crate::observer::{EventCategory, ObserverEvent, ObserverEventKind};
const POLL_INTERVAL: Duration = Duration::from_millis(50);
const PROC_ALL_PIDS: u32 = 1;
const PROC_PIDTBSDINFO: libc::c_int = 3;
pub(crate) fn spawn_pump(root_pid: u32, sink: Sender<ObserverEvent>) {
let _ = std::thread::Builder::new()
.name("rp-macos-descpump".to_string())
.spawn(move || pump_loop(root_pid, sink));
}
fn pump_loop(root_pid: u32, sink: Sender<ObserverEvent>) {
let mut known: HashSet<u32> = HashSet::new();
loop {
let all = list_all_processes();
if !all.iter().any(|&(pid, _)| pid == root_pid) {
for &pid in &known {
let _ = sink.send(ObserverEvent::new_now(
EventCategory::Process,
ObserverEventKind::DescendantExited,
pid,
));
}
break;
}
let current = descendants_of(root_pid, &all);
emit_diff(&known, ¤t, &sink);
known = current;
std::thread::sleep(POLL_INTERVAL);
}
}
fn list_all_processes() -> Vec<(u32, u32)> {
let size = unsafe {
libc::proc_listpids(
PROC_ALL_PIDS,
0,
std::ptr::null_mut(),
0,
)
};
if size <= 0 {
return Vec::new();
}
let pid_count = (size as usize) / std::mem::size_of::<libc::pid_t>();
if pid_count == 0 {
return Vec::new();
}
let mut pids: Vec<libc::pid_t> = vec![0; pid_count];
let written_bytes = unsafe {
libc::proc_listpids(
PROC_ALL_PIDS,
0,
pids.as_mut_ptr() as *mut libc::c_void,
(pid_count * std::mem::size_of::<libc::pid_t>()) as libc::c_int,
)
};
if written_bytes <= 0 {
return Vec::new();
}
let written = (written_bytes as usize) / std::mem::size_of::<libc::pid_t>();
pids.truncate(written);
let mut result = Vec::with_capacity(written);
for &pid in &pids {
if pid <= 0 {
continue;
}
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let n = unsafe {
libc::proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
&mut info as *mut libc::proc_bsdinfo as *mut libc::c_void,
std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int,
)
};
if n <= 0 {
continue;
}
result.push((info.pbi_pid, info.pbi_ppid));
}
result
}
fn descendants_of(root_pid: u32, all: &[(u32, u32)]) -> HashSet<u32> {
let mut child_map: HashMap<u32, Vec<u32>> = HashMap::new();
for &(pid, ppid) in all {
child_map.entry(ppid).or_default().push(pid);
}
let mut result = HashSet::new();
let mut stack = vec![root_pid];
while let Some(pid) = stack.pop() {
if let Some(children) = child_map.get(&pid) {
for &c in children {
if result.insert(c) {
stack.push(c);
}
}
}
}
result
}
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 descendants_of_handles_branching_tree() {
let all = vec![(100, 0), (200, 100), (201, 200), (300, 100), (999, 1)];
let descendants = descendants_of(100, &all);
assert_eq!(
descendants,
[200, 201, 300].into_iter().collect::<HashSet<_>>()
);
}
#[test]
fn descendants_of_for_unknown_root_returns_empty() {
let all = vec![(100, 0), (200, 100)];
let descendants = descendants_of(0x7FFF_FFFE, &all);
assert!(descendants.is_empty());
}
#[test]
fn list_all_processes_returns_non_empty_on_real_macos() {
let all = list_all_processes();
assert!(
all.len() > 5,
"expected the macOS process table to have plenty of entries, got {}",
all.len()
);
let self_pid = std::process::id();
assert!(
all.iter().any(|&(pid, _)| pid == self_pid),
"expected current pid {self_pid} in process table"
);
}
#[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, got {started} (all: {events:?})"
);
assert!(
exited >= 3,
"expected ≥3 DescendantExited, got {exited} (all: {events:?})"
);
for ev in &events {
assert_eq!(
ev.category,
EventCategory::Process,
"Lifecycle leaked into Process-only subscriber: {ev:?}"
);
}
}
}