Skip to main content

ralph_workflow/executor/
macos.rs

1//! macOS-specific child process detection using libproc.
2
3use super::ChildProcessInfo;
4use std::collections::{HashSet, VecDeque};
5use std::ffi::c_void;
6
7const PROC_PIDT_SHORTBSDINFO: libc::c_int = 13;
8const PROC_PIDTASKINFO: libc::c_int = 4;
9const MAXCOMLEN: usize = 16;
10const SIDL: u32 = 1;
11const SRUN: u32 = 2;
12const SSTOP: u32 = 4;
13const SZOMB: u32 = 5;
14
15#[repr(C)]
16struct ProcBsdShortInfo {
17    pid: u32,
18    parent_pid: u32,
19    process_group_id: u32,
20    status: u32,
21    command: [libc::c_char; MAXCOMLEN],
22    flags: u32,
23    uid: libc::uid_t,
24    gid: libc::gid_t,
25    real_uid: libc::uid_t,
26    real_gid: libc::gid_t,
27    saved_uid: libc::uid_t,
28    saved_gid: libc::gid_t,
29    reserved: u32,
30}
31
32#[repr(C)]
33struct ProcTaskInfo {
34    virtual_size: u64,
35    resident_size: u64,
36    total_user_time: u64,
37    total_system_time: u64,
38    threads_user_time: u64,
39    threads_system_time: u64,
40    policy: i32,
41    faults: i32,
42    pageins: i32,
43    cow_faults: i32,
44    messages_sent: i32,
45    messages_received: i32,
46    mach_syscalls: i32,
47    unix_syscalls: i32,
48    context_switches: i32,
49    thread_count: i32,
50    running_thread_count: i32,
51    priority: i32,
52}
53
54#[link(name = "proc")]
55unsafe extern "C" {
56    fn proc_listchildpids(pid: libc::pid_t, buffer: *mut c_void, buffersize: i32) -> i32;
57    fn proc_pidinfo(
58        pid: libc::pid_t,
59        flavor: libc::c_int,
60        arg: u64,
61        buffer: *mut c_void,
62        buffersize: libc::c_int,
63    ) -> libc::c_int;
64}
65
66pub fn child_pid_entry_count(bytes_written: i32) -> Option<usize> {
67    let bytes = usize::try_from(bytes_written).ok()?;
68    let pid_width = std::mem::size_of::<libc::pid_t>();
69    Some(bytes / pid_width)
70}
71
72fn descendant_pid_signature(descendants: &[u32]) -> u64 {
73    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
74    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
75
76    descendants.iter().fold(FNV_OFFSET, |signature, &pid| {
77        pid.to_le_bytes().iter().fold(signature, |sig, &byte| {
78            (sig ^ u64::from(byte)).wrapping_mul(FNV_PRIME)
79        })
80    })
81}
82
83const fn qualifies_libproc_status(status: u32) -> bool {
84    !matches!(status, SIDL | SSTOP | SZOMB)
85}
86
87const fn libproc_state_indicates_current_activity(
88    status: u32,
89    cpu_time_ms: u64,
90    num_running_threads: i32,
91) -> bool {
92    status == SRUN && cpu_time_ms > 0 && num_running_threads > 0
93}
94
95fn call_proc_listchildpids(pid: libc::pid_t, capacity: usize) -> Option<(i32, Vec<libc::pid_t>)> {
96    let byte_len = capacity.checked_mul(std::mem::size_of::<libc::pid_t>())?;
97    let buffer_size = i32::try_from(byte_len).ok()?;
98    let mut buffer = vec![libc::pid_t::default(); capacity];
99    let bytes_written =
100        unsafe { proc_listchildpids(pid, buffer.as_mut_ptr().cast::<c_void>(), buffer_size) };
101    Some((bytes_written, buffer))
102}
103
104fn collect_child_pids_from_buffer(buffer: Vec<libc::pid_t>, count: usize) -> Vec<u32> {
105    buffer
106        .into_iter()
107        .take(count)
108        .filter_map(|p| u32::try_from(p).ok())
109        .collect()
110}
111
112fn try_read_child_pids(pid: libc::pid_t, capacity: usize) -> Option<Result<Vec<u32>, usize>> {
113    let (bytes_written, buffer) = call_proc_listchildpids(pid, capacity)?;
114    if bytes_written < 0 {
115        return None;
116    }
117    if bytes_written == 0 {
118        return Some(Ok(Vec::new()));
119    }
120    let count = child_pid_entry_count(bytes_written)?;
121    if count < capacity {
122        return Some(Ok(collect_child_pids_from_buffer(buffer, count)));
123    }
124    Some(Err(capacity.checked_mul(2)?))
125}
126
127fn list_child_pids(parent_pid: u32) -> Option<Vec<u32>> {
128    let pid = libc::pid_t::try_from(parent_pid).ok()?;
129    let mut capacity: usize = 32;
130
131    loop {
132        match try_read_child_pids(pid, capacity)? {
133            Ok(pids) => return Some(pids),
134            Err(new_capacity) => capacity = new_capacity,
135        }
136    }
137}
138
139fn fetch_bsd_short_info(pid: u32) -> Option<ProcBsdShortInfo> {
140    let mut info = ProcBsdShortInfo {
141        pid: 0,
142        parent_pid: 0,
143        process_group_id: 0,
144        status: 0,
145        command: [0; MAXCOMLEN],
146        flags: 0,
147        uid: 0,
148        gid: 0,
149        real_uid: 0,
150        real_gid: 0,
151        saved_uid: 0,
152        saved_gid: 0,
153        reserved: 0,
154    };
155    let pid = libc::pid_t::try_from(pid).ok()?;
156    let expected = i32::try_from(std::mem::size_of::<ProcBsdShortInfo>()).ok()?;
157    let bytes = unsafe {
158        proc_pidinfo(
159            pid,
160            PROC_PIDT_SHORTBSDINFO,
161            0,
162            (&raw mut info).cast::<c_void>(),
163            expected,
164        )
165    };
166    (bytes == expected).then_some(info)
167}
168
169fn fetch_task_info(pid: u32) -> Option<ProcTaskInfo> {
170    let mut info = ProcTaskInfo {
171        virtual_size: 0,
172        resident_size: 0,
173        total_user_time: 0,
174        total_system_time: 0,
175        threads_user_time: 0,
176        threads_system_time: 0,
177        policy: 0,
178        faults: 0,
179        pageins: 0,
180        cow_faults: 0,
181        messages_sent: 0,
182        messages_received: 0,
183        mach_syscalls: 0,
184        unix_syscalls: 0,
185        context_switches: 0,
186        thread_count: 0,
187        running_thread_count: 0,
188        priority: 0,
189    };
190    let pid = libc::pid_t::try_from(pid).ok()?;
191    let expected = i32::try_from(std::mem::size_of::<ProcTaskInfo>()).ok()?;
192    let bytes = unsafe {
193        proc_pidinfo(
194            pid,
195            PROC_PIDTASKINFO,
196            0,
197            (&raw mut info).cast::<c_void>(),
198            expected,
199        )
200    };
201    (bytes == expected).then_some(info)
202}
203
204fn process_child_pids(
205    child_pids: Vec<u32>,
206    descendants: &mut Vec<u32>,
207    visited: &mut HashSet<u32>,
208    queue: &mut VecDeque<u32>,
209) {
210    child_pids
211        .into_iter()
212        .filter(|&p| visited.insert(p))
213        .for_each(|p| {
214            descendants.push(p);
215            queue.push_back(p);
216        });
217}
218
219fn drain_pid_queue(
220    queue: &mut VecDeque<u32>,
221    descendants: &mut Vec<u32>,
222    visited: &mut HashSet<u32>,
223) -> Option<()> {
224    while let Some(pid) = queue.pop_front() {
225        let child_pids = list_child_pids(pid)?;
226        process_child_pids(child_pids, descendants, visited, queue);
227    }
228    Some(())
229}
230
231fn collect_descendant_pids(current_pid: u32) -> Option<Vec<u32>> {
232    let mut descendants = Vec::new();
233    let mut visited = HashSet::new();
234    let mut queue = VecDeque::from([current_pid]);
235    drain_pid_queue(&mut queue, &mut descendants, &mut visited)?;
236    descendants.sort_unstable();
237    Some(descendants)
238}
239
240struct DescendantTally {
241    child_count: u32,
242    active_child_count: u32,
243    total_cpu_ms: u64,
244    qualifying_pids: Vec<u32>,
245}
246
247fn pid_qualifies_for_parent(bsd_info: &ProcBsdShortInfo, parent_pid: u32) -> bool {
248    bsd_info.process_group_id == parent_pid && qualifies_libproc_status(bsd_info.status)
249}
250
251fn cpu_time_ms_from_task(task_info: &Option<ProcTaskInfo>) -> u64 {
252    task_info.as_ref().map_or(0, |info| {
253        (info.total_user_time + info.total_system_time) / 1_000_000
254    })
255}
256
257fn running_threads_from_task(task_info: &Option<ProcTaskInfo>) -> i32 {
258    task_info
259        .as_ref()
260        .map_or(0, |info| info.running_thread_count)
261}
262
263fn tally_one_descendant(pid: u32, parent_pid: u32, tally: &mut DescendantTally) {
264    let Some(bsd_info) = fetch_bsd_short_info(pid) else {
265        return;
266    };
267    if !pid_qualifies_for_parent(&bsd_info, parent_pid) {
268        return;
269    }
270    let task_info = fetch_task_info(pid);
271    let cpu_time_ms = cpu_time_ms_from_task(&task_info);
272    let num_running_threads = running_threads_from_task(&task_info);
273    tally.child_count = tally.child_count.saturating_add(1);
274    tally.total_cpu_ms += cpu_time_ms;
275    if libproc_state_indicates_current_activity(bsd_info.status, cpu_time_ms, num_running_threads) {
276        tally.active_child_count = tally.active_child_count.saturating_add(1);
277    }
278    tally.qualifying_pids.push(pid);
279}
280
281fn build_descendant_tally(parent_pid: u32, descendants: &[u32]) -> DescendantTally {
282    let mut tally = DescendantTally {
283        child_count: 0,
284        active_child_count: 0,
285        total_cpu_ms: 0,
286        qualifying_pids: Vec::new(),
287    };
288    descendants
289        .iter()
290        .for_each(|&pid| tally_one_descendant(pid, parent_pid, &mut tally));
291    tally
292}
293
294fn tally_to_child_info(tally: DescendantTally) -> ChildProcessInfo {
295    ChildProcessInfo {
296        child_count: tally.child_count,
297        active_child_count: tally.active_child_count,
298        cpu_time_ms: tally.total_cpu_ms,
299        descendant_pid_signature: descendant_pid_signature(&tally.qualifying_pids),
300    }
301}
302
303fn compute_child_info_from_descendants(
304    parent_pid: u32,
305    descendants: &[u32],
306) -> Option<ChildProcessInfo> {
307    if descendants.is_empty() {
308        return None;
309    }
310    let tally = build_descendant_tally(parent_pid, descendants);
311    if tally.child_count == 0 {
312        return Some(ChildProcessInfo::NONE);
313    }
314    Some(tally_to_child_info(tally))
315}
316
317pub fn child_info_from_libproc(parent_pid: u32) -> Option<ChildProcessInfo> {
318    let descendants = collect_descendant_pids(parent_pid)?;
319    compute_child_info_from_descendants(parent_pid, &descendants)
320}