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's Linux start time as a stable PID identity token.
41///
42/// The value is field 22 of `/proc/<pid>/stat`, measured in clock ticks since
43/// boot. It distinguishes a recorded process from a later process that reused
44/// the same PID. Other platforms return `None` until they provide an equivalent
45/// stable token.
46#[cfg(target_os = "linux")]
47pub fn pid_start_time(pid: u32) -> Option<u64> {
48    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
49    linux_process_identity_from_stat(&stat).map(|(_, start_time)| start_time)
50}
51
52#[cfg(not(target_os = "linux"))]
53pub fn pid_start_time(_pid: u32) -> Option<u64> {
54    None
55}
56
57/// Check process liveness and, when recorded, its stable identity token.
58///
59/// Records created before PID identity tokens were introduced contain no
60/// expected start time and retain their legacy liveness behavior.
61pub fn is_process_alive_with_identity(pid: u32, expected_start_time: Option<u64>) -> bool {
62    if !is_process_alive(pid) {
63        return false;
64    }
65
66    match expected_start_time {
67        Some(expected) => pid_start_time(pid) == Some(expected),
68        None => true,
69    }
70}
71
72/// Check whether a process identity is actively running rather than a zombie.
73///
74/// A completed child remains addressable by `kill(pid, 0)` until its parent
75/// reaps it. Lifecycle ownership still uses [`is_process_alive_with_identity`]
76/// when that distinction matters; completion waiters use this helper so a
77/// fully drained worker zombie is treated as finished immediately.
78#[cfg(target_os = "linux")]
79pub fn is_process_running_with_identity(pid: u32, expected_start_time: Option<u64>) -> bool {
80    let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
81        return false;
82    };
83    linux_process_identity_from_stat(&stat).is_some_and(|(state, start_time)| {
84        is_linux_process_state_running(state)
85            && expected_start_time
86                .map(|expected| expected == start_time)
87                .unwrap_or(true)
88    })
89}
90
91#[cfg(not(target_os = "linux"))]
92pub fn is_process_running_with_identity(pid: u32, expected_start_time: Option<u64>) -> bool {
93    is_process_alive_with_identity(pid, expected_start_time)
94}
95
96/// Wait until a process identity is no longer actively executing.
97///
98/// Unlike [`wait_for_process_exit_with_identity`], this accepts an unreaped
99/// zombie as stopped. That distinction is required when a detached runtime
100/// owner was spawned by a short-lived client process: a later recovery process
101/// cannot reap the former client's child, but it can safely remove runtime
102/// state once that child has finished executing.
103#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
104pub(crate) fn wait_for_process_stop_with_identity(
105    pid: u32,
106    expected_start_time: u64,
107    timeout: std::time::Duration,
108) -> bool {
109    let deadline = std::time::Instant::now() + timeout;
110    loop {
111        if !is_process_running_with_identity(pid, Some(expected_start_time)) {
112            // Recovery can discard the original `Child` handle while the
113            // runtime owner remains our child. Reap that completed child when
114            // possible, while preserving the stopped semantics for processes
115            // owned by another parent.
116            let _ = try_reap_exited_child_with_identity(pid, expected_start_time);
117            return true;
118        }
119        if std::time::Instant::now() >= deadline {
120            return false;
121        }
122        std::thread::sleep(std::time::Duration::from_millis(10));
123    }
124}
125
126/// Wait for a Linux process identity to disappear, reaping it when it is an
127/// exited child of the current process.
128///
129/// Recovered runtime handles retain only a durable PID/start-time pair. When a
130/// worker was originally spawned by this process, dropping its `Child` handle
131/// does not transfer wait ownership: the completed worker remains a zombie
132/// until an explicit `waitpid`. Workers inherited by another process cannot be
133/// reaped here, so this helper waits for their owner to reap them instead.
134#[cfg(target_os = "linux")]
135pub(crate) fn wait_for_process_exit_with_identity(
136    pid: u32,
137    expected_start_time: u64,
138    timeout: std::time::Duration,
139) -> bool {
140    let deadline = std::time::Instant::now() + timeout;
141    loop {
142        if !is_process_alive_with_identity(pid, Some(expected_start_time)) {
143            return true;
144        }
145        if !is_process_running_with_identity(pid, Some(expected_start_time))
146            && try_reap_exited_child_with_identity(pid, expected_start_time)
147        {
148            return true;
149        }
150        if std::time::Instant::now() >= deadline {
151            return false;
152        }
153        std::thread::sleep(std::time::Duration::from_millis(10));
154    }
155}
156
157/// Try to reap an exited process when it is a child of the current process.
158///
159/// Returns `true` once the recorded identity has disappeared. `false` means
160/// the process is still present and must be reaped by its owning parent.
161#[cfg(target_os = "linux")]
162fn try_reap_exited_child_with_identity(pid: u32, expected_start_time: u64) -> bool {
163    if !is_process_alive_with_identity(pid, Some(expected_start_time)) {
164        return true;
165    }
166
167    let Ok(raw_pid) = i32::try_from(pid) else {
168        return false;
169    };
170    let mut status = 0;
171    let waited = unsafe { libc::waitpid(raw_pid, &mut status, libc::WNOHANG) };
172    waited == raw_pid || !is_process_alive_with_identity(pid, Some(expected_start_time))
173}
174
175#[cfg(not(target_os = "linux"))]
176#[allow(dead_code)]
177fn try_reap_exited_child_with_identity(pid: u32, expected_start_time: u64) -> bool {
178    !is_process_alive_with_identity(pid, Some(expected_start_time))
179}
180
181#[cfg(target_os = "linux")]
182fn linux_process_identity_from_stat(stat: &str) -> Option<(char, u64)> {
183    // `comm` may contain spaces and parentheses, so fields begin after the
184    // final `)`. Field 3 is then token zero and field 22 is token 19.
185    let fields: Vec<&str> = stat
186        .get(stat.rfind(')')? + 1..)?
187        .split_whitespace()
188        .collect();
189    let state = fields.first()?.chars().next()?;
190    let start_time = fields.get(19)?.parse().ok()?;
191    Some((state, start_time))
192}
193
194#[cfg(target_os = "linux")]
195const fn is_linux_process_state_running(state: char) -> bool {
196    !matches!(state, 'Z' | 'X' | 'x')
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn current_process_is_alive() {
205        assert!(is_process_alive(std::process::id()));
206    }
207
208    #[test]
209    fn missing_process_is_not_alive() {
210        assert!(!is_process_alive(0x7fff_fffe));
211    }
212
213    #[cfg(target_os = "linux")]
214    #[test]
215    fn parses_start_time_after_complex_command_name() {
216        let stat =
217            "123 (command (with) spaces) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242";
218        assert_eq!(linux_process_identity_from_stat(stat), Some(('S', 4242)));
219        assert_eq!(linux_process_identity_from_stat("malformed"), None);
220        assert_eq!(linux_process_identity_from_stat("123 (short) S 1"), None);
221    }
222
223    #[cfg(target_os = "linux")]
224    #[test]
225    fn identity_rejects_a_reused_pid() {
226        let pid = std::process::id();
227        let start_time = pid_start_time(pid);
228        assert!(start_time.is_some());
229        assert!(is_process_alive_with_identity(pid, start_time));
230        assert!(!is_process_alive_with_identity(pid, Some(u64::MAX)));
231        assert!(is_process_alive_with_identity(pid, None));
232        assert!(!is_process_alive_with_identity(0x7fff_fffe, None));
233        assert!(is_process_running_with_identity(pid, start_time));
234    }
235
236    #[cfg(target_os = "linux")]
237    #[test]
238    fn classifies_zombie_and_dead_states_as_completed() {
239        for state in ['Z', 'X', 'x'] {
240            let stat = format!(
241                "123 (completed worker) {state} 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 4242"
242            );
243            assert_eq!(linux_process_identity_from_stat(&stat), Some((state, 4242)));
244            assert!(!is_linux_process_state_running(state));
245        }
246        for state in ['R', 'S', 'D', 'T', 't', 'I'] {
247            assert!(is_linux_process_state_running(state));
248        }
249    }
250
251    #[cfg(target_os = "linux")]
252    #[test]
253    #[allow(clippy::zombie_processes)] // Deliberately drop Child to exercise recovered waitpid.
254    fn recovered_identity_reaps_an_exited_child() {
255        let child = std::process::Command::new("true").spawn().unwrap();
256        let pid = child.id();
257        let start_time = pid_start_time(pid).unwrap();
258        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
259        while is_process_running_with_identity(pid, Some(start_time))
260            && std::time::Instant::now() < deadline
261        {
262            std::thread::sleep(std::time::Duration::from_millis(5));
263        }
264
265        assert!(is_process_alive_with_identity(pid, Some(start_time)));
266        assert!(!is_process_running_with_identity(pid, Some(start_time)));
267        assert!(wait_for_process_exit_with_identity(
268            pid,
269            start_time,
270            std::time::Duration::from_secs(1),
271        ));
272        assert!(!is_process_alive_with_identity(pid, Some(start_time)));
273    }
274
275    #[cfg(target_os = "linux")]
276    #[test]
277    #[allow(clippy::zombie_processes)] // Deliberately drop Child to exercise recovered waitpid.
278    fn stop_waiter_reaps_an_exited_child() {
279        let child = std::process::Command::new("true").spawn().unwrap();
280        let pid = child.id();
281        let start_time = pid_start_time(pid).unwrap();
282        drop(child);
283
284        assert!(wait_for_process_stop_with_identity(
285            pid,
286            start_time,
287            std::time::Duration::from_secs(1),
288        ));
289        assert!(!is_process_alive_with_identity(pid, Some(start_time)));
290    }
291}