Skip to main content

a3s_box_runtime/
process.rs

1//! Host process identity helpers shared by runtime consumers.
2
3/// Check whether a host process exists.
4///
5/// On Unix, `EPERM` still means the process exists even though the caller is
6/// not allowed to signal it.
7#[cfg(unix)]
8pub fn is_process_alive(pid: u32) -> bool {
9    let Ok(pid) = i32::try_from(pid) else {
10        return false;
11    };
12    let result = unsafe { libc::kill(pid, 0) };
13    result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
14}
15
16#[cfg(windows)]
17pub fn is_process_alive(pid: u32) -> bool {
18    use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE};
19    use windows_sys::Win32::System::Threading::{
20        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_INFORMATION,
21    };
22
23    unsafe {
24        let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid);
25        if handle == 0 {
26            return false;
27        }
28        let mut exit_code = 0u32;
29        let ok = GetExitCodeProcess(handle, &mut exit_code);
30        CloseHandle(handle);
31        ok != 0 && exit_code == STILL_ACTIVE as u32
32    }
33}
34
35#[cfg(not(any(unix, windows)))]
36pub fn is_process_alive(_pid: u32) -> bool {
37    false
38}
39
40/// Read a process start time as a stable PID identity token.
41///
42/// Linux returns field 22 of `/proc/<pid>/stat`, measured in clock ticks since
43/// boot. macOS returns the `proc_bsdinfo` start timestamp in microseconds. Both
44/// distinguish a recorded process from a later process that reused the same PID.
45#[cfg(target_os = "linux")]
46pub fn pid_start_time(pid: u32) -> Option<u64> {
47    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
48    linux_process_identity_from_stat(&stat).map(|(_, start_time)| start_time)
49}
50
51#[cfg(target_os = "macos")]
52pub fn pid_start_time(pid: u32) -> Option<u64> {
53    macos_process_identity(pid).map(|identity| identity.start_time)
54}
55
56#[cfg(not(any(target_os = "linux", target_os = "macos")))]
57pub fn pid_start_time(_pid: u32) -> Option<u64> {
58    None
59}
60
61/// Check process liveness and, when recorded, its stable identity token.
62///
63/// Records created before PID identity tokens were introduced contain no
64/// expected start time and retain their legacy liveness behavior.
65pub fn is_process_alive_with_identity(pid: u32, expected_start_time: Option<u64>) -> bool {
66    if !is_process_alive(pid) {
67        return false;
68    }
69
70    match expected_start_time {
71        Some(expected) => pid_start_time(pid) == Some(expected),
72        None => true,
73    }
74}
75
76/// Check whether a process identity is actively running rather than a zombie.
77///
78/// A completed child remains addressable by `kill(pid, 0)` until its parent
79/// reaps it. Lifecycle ownership still uses [`is_process_alive_with_identity`]
80/// when that distinction matters; completion waiters use this helper so a
81/// fully drained worker zombie is treated as finished immediately.
82#[cfg(target_os = "linux")]
83pub fn is_process_running_with_identity(pid: u32, expected_start_time: Option<u64>) -> bool {
84    let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
85        return false;
86    };
87    linux_process_identity_from_stat(&stat).is_some_and(|(state, start_time)| {
88        is_linux_process_state_running(state)
89            && expected_start_time
90                .map(|expected| expected == start_time)
91                .unwrap_or(true)
92    })
93}
94
95#[cfg(target_os = "macos")]
96pub fn is_process_running_with_identity(pid: u32, expected_start_time: Option<u64>) -> bool {
97    match macos_process_identity(pid) {
98        Some(identity) => {
99            identity.running
100                && expected_start_time
101                    .map(|expected| expected == identity.start_time)
102                    .unwrap_or(true)
103        }
104        // A legacy record without an identity token can retain the portable
105        // existence fallback if libproc denies inspection. New records fail
106        // closed because signalling a PID with an unverified identity is unsafe.
107        None => expected_start_time.is_none() && is_process_alive(pid),
108    }
109}
110
111#[cfg(not(any(target_os = "linux", target_os = "macos")))]
112pub fn is_process_running_with_identity(pid: u32, expected_start_time: Option<u64>) -> bool {
113    is_process_alive_with_identity(pid, expected_start_time)
114}
115
116#[cfg(target_os = "macos")]
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118struct MacosProcessIdentity {
119    start_time: u64,
120    running: bool,
121}
122
123#[cfg(target_os = "macos")]
124fn macos_process_identity(pid: u32) -> Option<MacosProcessIdentity> {
125    let raw_pid = i32::try_from(pid).ok().filter(|pid| *pid > 0)?;
126    let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
127    let expected_size = std::mem::size_of::<libc::proc_bsdinfo>();
128    // SAFETY: `info` points to writable storage of exactly the size passed to
129    // libproc. A full-size return is required before the value is initialized.
130    let read = unsafe {
131        libc::proc_pidinfo(
132            raw_pid,
133            libc::PROC_PIDTBSDINFO,
134            0,
135            info.as_mut_ptr().cast(),
136            i32::try_from(expected_size).ok()?,
137        )
138    };
139    if read != i32::try_from(expected_size).ok()? {
140        return None;
141    }
142    // SAFETY: proc_pidinfo returned the complete proc_bsdinfo payload above.
143    let info = unsafe { info.assume_init() };
144    Some(MacosProcessIdentity {
145        start_time: info
146            .pbi_start_tvsec
147            .saturating_mul(1_000_000)
148            .saturating_add(info.pbi_start_tvusec),
149        running: info.pbi_status != libc::SZOMB,
150    })
151}
152
153/// Wait until a process identity is no longer actively executing.
154///
155/// Unlike [`wait_for_process_exit_with_identity`], this accepts an unreaped
156/// zombie as stopped. That distinction is required when a detached runtime
157/// owner was spawned by a short-lived client process: a later recovery process
158/// cannot reap the former client's child, but it can safely remove runtime
159/// state once that child has finished executing.
160#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
161pub(crate) fn wait_for_process_stop_with_identity(
162    pid: u32,
163    expected_start_time: u64,
164    timeout: std::time::Duration,
165) -> bool {
166    let deadline = std::time::Instant::now() + timeout;
167    loop {
168        if !is_process_running_with_identity(pid, Some(expected_start_time)) {
169            // Recovery can discard the original `Child` handle while the
170            // runtime owner remains our child. Reap that completed child when
171            // possible, while preserving the stopped semantics for processes
172            // owned by another parent.
173            let _ = try_reap_exited_child_with_identity(pid, expected_start_time);
174            return true;
175        }
176        if std::time::Instant::now() >= deadline {
177            return false;
178        }
179        std::thread::sleep(std::time::Duration::from_millis(10));
180    }
181}
182
183/// Wait for a Linux process identity to disappear, reaping it when it is an
184/// exited child of the current process.
185///
186/// Recovered runtime handles retain only a durable PID/start-time pair. When a
187/// worker was originally spawned by this process, dropping its `Child` handle
188/// does not transfer wait ownership: the completed worker remains a zombie
189/// until an explicit `waitpid`. Workers inherited by another process cannot be
190/// reaped here, so this helper waits for their owner to reap them instead.
191#[cfg(target_os = "linux")]
192pub(crate) fn wait_for_process_exit_with_identity(
193    pid: u32,
194    expected_start_time: u64,
195    timeout: std::time::Duration,
196) -> bool {
197    let deadline = std::time::Instant::now() + timeout;
198    loop {
199        if !is_process_alive_with_identity(pid, Some(expected_start_time)) {
200            return true;
201        }
202        if !is_process_running_with_identity(pid, Some(expected_start_time))
203            && try_reap_exited_child_with_identity(pid, expected_start_time)
204        {
205            return true;
206        }
207        if std::time::Instant::now() >= deadline {
208            return false;
209        }
210        std::thread::sleep(std::time::Duration::from_millis(10));
211    }
212}
213
214/// Try to reap an exited process when it is a child of the current process.
215///
216/// Returns `true` once the recorded identity has disappeared. `false` means
217/// the process is still present and must be reaped by its owning parent.
218#[cfg(unix)]
219fn try_reap_exited_child_with_identity(pid: u32, expected_start_time: u64) -> bool {
220    if !is_process_alive_with_identity(pid, Some(expected_start_time)) {
221        return true;
222    }
223
224    let Ok(raw_pid) = i32::try_from(pid) else {
225        return false;
226    };
227    let mut status = 0;
228    let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
229    waited == raw_pid || !is_process_alive_with_identity(pid, Some(expected_start_time))
230}
231
232#[cfg(not(unix))]
233#[allow(dead_code)]
234fn try_reap_exited_child_with_identity(pid: u32, expected_start_time: u64) -> bool {
235    !is_process_alive_with_identity(pid, Some(expected_start_time))
236}
237
238#[cfg(target_os = "linux")]
239fn linux_process_identity_from_stat(stat: &str) -> Option<(char, u64)> {
240    // `comm` may contain spaces and parentheses, so fields begin after the
241    // final `)`. Field 3 is then token zero and field 22 is token 19.
242    let fields: Vec<&str> = stat
243        .get(stat.rfind(')')? + 1..)?
244        .split_whitespace()
245        .collect();
246    let state = fields.first()?.chars().next()?;
247    let start_time = fields.get(19)?.parse().ok()?;
248    Some((state, start_time))
249}
250
251#[cfg(target_os = "linux")]
252const fn is_linux_process_state_running(state: char) -> bool {
253    !matches!(state, 'Z' | 'X' | 'x')
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn current_process_is_alive() {
262        assert!(is_process_alive(std::process::id()));
263    }
264
265    #[test]
266    fn missing_process_is_not_alive() {
267        assert!(!is_process_alive(0x7fff_fffe));
268    }
269
270    #[cfg(target_os = "macos")]
271    #[test]
272    fn macos_identity_distinguishes_a_reused_pid() {
273        let pid = std::process::id();
274        let start_time = pid_start_time(pid);
275        assert!(
276            start_time.is_some(),
277            "live process must have a start-time token"
278        );
279        assert!(is_process_alive_with_identity(pid, start_time));
280        assert!(!is_process_alive_with_identity(pid, Some(u64::MAX)));
281        assert!(is_process_running_with_identity(pid, start_time));
282    }
283
284    #[cfg(target_os = "macos")]
285    #[test]
286    #[allow(clippy::zombie_processes)] // Intentionally retain Child until state inspection.
287    fn macos_running_identity_rejects_a_zombie() {
288        let mut child = std::process::Command::new("/bin/sleep")
289            .arg("30")
290            .spawn()
291            .unwrap();
292        let pid = child.id();
293        let start_time = pid_start_time(pid).expect("capture child identity");
294        child.kill().expect("terminate child without reaping it");
295        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
296        while is_process_running_with_identity(pid, Some(start_time))
297            && std::time::Instant::now() < deadline
298        {
299            std::thread::sleep(std::time::Duration::from_millis(5));
300        }
301
302        assert!(!is_process_running_with_identity(pid, Some(start_time)));
303        let _ = child.wait();
304    }
305
306    #[cfg(target_os = "linux")]
307    #[test]
308    fn parses_start_time_after_complex_command_name() {
309        let stat =
310            "123 (command (with) spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242";
311        assert_eq!(linux_process_identity_from_stat(stat), Some(('S', 4242)));
312        assert_eq!(linux_process_identity_from_stat("malformed"), None);
313        assert_eq!(linux_process_identity_from_stat("123 (short) S 1"), None);
314    }
315
316    #[cfg(target_os = "linux")]
317    #[test]
318    fn identity_rejects_a_reused_pid() {
319        let pid = std::process::id();
320        let start_time = pid_start_time(pid);
321        assert!(start_time.is_some());
322        assert!(is_process_alive_with_identity(pid, start_time));
323        assert!(!is_process_alive_with_identity(pid, Some(u64::MAX)));
324        assert!(is_process_alive_with_identity(pid, None));
325        assert!(!is_process_alive_with_identity(0x7fff_fffe, None));
326        assert!(is_process_running_with_identity(pid, start_time));
327    }
328
329    #[cfg(target_os = "linux")]
330    #[test]
331    fn classifies_zombie_and_dead_states_as_completed() {
332        for state in ['Z', 'X', 'x'] {
333            let stat = format!(
334                "123 (completed worker) {state} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242"
335            );
336            assert_eq!(linux_process_identity_from_stat(&stat), Some((state, 4242)));
337            assert!(!is_linux_process_state_running(state));
338        }
339        for state in ['R', 'S', 'D', 'T', 't', 'I'] {
340            assert!(is_linux_process_state_running(state));
341        }
342    }
343
344    #[cfg(target_os = "linux")]
345    #[test]
346    #[allow(clippy::zombie_processes)] // Deliberately drop Child to exercise recovered waitpid.
347    fn recovered_identity_reaps_an_exited_child() {
348        let child = std::process::Command::new("true").spawn().unwrap();
349        let pid = child.id();
350        let start_time = pid_start_time(pid).unwrap();
351        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
352        while is_process_running_with_identity(pid, Some(start_time))
353            && std::time::Instant::now() < deadline
354        {
355            std::thread::sleep(std::time::Duration::from_millis(5));
356        }
357
358        assert!(is_process_alive_with_identity(pid, Some(start_time)));
359        assert!(!is_process_running_with_identity(pid, Some(start_time)));
360        assert!(wait_for_process_exit_with_identity(
361            pid,
362            start_time,
363            std::time::Duration::from_secs(1),
364        ));
365        assert!(!is_process_alive_with_identity(pid, Some(start_time)));
366    }
367
368    #[cfg(target_os = "linux")]
369    #[test]
370    #[allow(clippy::zombie_processes)] // Deliberately drop Child to exercise recovered waitpid.
371    fn stop_waiter_reaps_an_exited_child() {
372        let child = std::process::Command::new("true").spawn().unwrap();
373        let pid = child.id();
374        let start_time = pid_start_time(pid).unwrap();
375        drop(child);
376
377        assert!(wait_for_process_stop_with_identity(
378            pid,
379            start_time,
380            std::time::Duration::from_secs(1),
381        ));
382        assert!(!is_process_alive_with_identity(pid, Some(start_time)));
383    }
384}