1use std::fmt;
9
10#[derive(Clone, Debug, PartialEq, Eq, Hash)]
12pub struct ProcessStart(String);
13
14impl ProcessStart {
15 pub fn as_str(&self) -> &str {
17 &self.0
18 }
19
20 pub fn parse(s: &str) -> Option<Self> {
23 if let Some(ticks) = s.strip_prefix("linux-v1-") {
24 if !decimal_digits(ticks) || ticks.parse::<u64>().is_err() {
25 return None;
26 }
27 } else {
28 let numbers = s.strip_prefix("macos-v1-")?;
29 let unsigned = numbers.strip_prefix('-').unwrap_or(numbers);
31 let (seconds, micros) = unsigned.split_once('-')?;
32 let seconds_len = numbers.len() - unsigned.len() + seconds.len();
33 if !decimal_digits(seconds)
34 || !decimal_digits(micros.strip_prefix('-').unwrap_or(micros))
35 || numbers[..seconds_len].parse::<i64>().is_err()
36 || micros.parse::<i64>().is_err()
37 {
38 return None;
39 }
40 }
41 Some(Self(s.to_owned()))
42 }
43
44 #[cfg(unix)]
45 fn same_family(&self, other: &Self) -> bool {
46 self.0.starts_with("linux-v1-") == other.0.starts_with("linux-v1-")
47 }
48}
49
50fn decimal_digits(s: &str) -> bool {
51 !s.is_empty() && s.bytes().all(|byte| byte.is_ascii_digit())
52}
53
54impl fmt::Display for ProcessStart {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 f.write_str(self.as_str())
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Liveness {
63 Alive,
65 Dead,
67 Unknown,
70}
71
72pub fn process_start(pid: u32) -> Option<ProcessStart> {
75 let pid = valid_pid(pid)?;
76 platform_process_start(pid)
77}
78
79pub fn liveness(pid: u32, recorded: Option<&ProcessStart>) -> Liveness {
87 let Some(pid) = valid_pid(pid) else {
88 return Liveness::Unknown;
89 };
90 #[cfg(unix)]
91 {
92 #[allow(unsafe_code)]
95 let result = unsafe { libc::kill(pid, 0) };
96 if result != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
97 return Liveness::Dead;
98 }
99 #[cfg(target_os = "linux")]
100 if std::fs::read_to_string(format!("/proc/{pid}/stat"))
101 .ok()
102 .is_some_and(|stat| state_from_stat(&stat) == Some("Z"))
103 {
104 return Liveness::Dead;
105 }
106 let Some(recorded) = recorded else {
107 return Liveness::Alive;
108 };
109 let Some(current) = platform_process_start(pid) else {
110 return Liveness::Unknown;
111 };
112 if current == *recorded {
113 Liveness::Alive
114 } else if current.same_family(recorded) {
115 Liveness::Dead
116 } else {
117 Liveness::Unknown
119 }
120 }
121 #[cfg(not(unix))]
122 {
123 let _ = (pid, recorded);
124 Liveness::Unknown
125 }
126}
127
128fn valid_pid(pid: u32) -> Option<i32> {
129 i32::try_from(pid).ok().filter(|pid| *pid > 0)
130}
131
132#[cfg(target_os = "linux")]
133fn platform_process_start(pid: i32) -> Option<ProcessStart> {
134 let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
135 let ticks = start_time_from_stat(&stat)?;
136 Some(ProcessStart(format!("linux-v1-{ticks}")))
137}
138
139#[cfg(any(target_os = "linux", test))]
140fn stat_fields(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
141 Some(stat.rsplit_once(')')?.1.split_whitespace())
144}
145
146#[cfg(any(target_os = "linux", test))]
147fn start_time_from_stat(stat: &str) -> Option<u64> {
148 stat_fields(stat)?.nth(19)?.parse().ok()
151}
152
153#[cfg(any(target_os = "linux", test))]
154fn state_from_stat(stat: &str) -> Option<&str> {
155 stat_fields(stat)?.next()
156}
157
158#[cfg(target_os = "macos")]
159fn platform_process_start(pid: i32) -> Option<ProcessStart> {
160 let mut info = libc::proc_bsdinfo {
163 pbi_flags: 0,
164 pbi_status: 0,
165 pbi_xstatus: 0,
166 pbi_pid: 0,
167 pbi_ppid: 0,
168 pbi_uid: 0,
169 pbi_gid: 0,
170 pbi_ruid: 0,
171 pbi_rgid: 0,
172 pbi_svuid: 0,
173 pbi_svgid: 0,
174 rfu_1: 0,
175 pbi_comm: [0; 16],
176 pbi_name: [0; 32],
177 pbi_nfiles: 0,
178 pbi_pgid: 0,
179 pbi_pjobc: 0,
180 e_tdev: 0,
181 e_tpgid: 0,
182 pbi_nice: 0,
183 pbi_start_tvsec: 0,
184 pbi_start_tvusec: 0,
185 };
186 let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
187 #[allow(unsafe_code)]
190 let read = unsafe {
191 libc::proc_pidinfo(
192 pid,
193 libc::PROC_PIDTBSDINFO,
194 0,
195 std::ptr::from_mut(&mut info).cast(),
196 size,
197 )
198 };
199 if read != size {
200 return None;
201 }
202 Some(ProcessStart(format!(
203 "macos-v1-{}-{}",
204 info.pbi_start_tvsec, info.pbi_start_tvusec
205 )))
206}
207
208#[cfg(not(any(target_os = "linux", target_os = "macos")))]
209fn platform_process_start(_pid: i32) -> Option<ProcessStart> {
210 None
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn parse_accepts_both_versioned_encodings() {
219 for encoding in [
220 "linux-v1-0",
221 "linux-v1-18446744073709551615",
222 "macos-v1-123456789-987654",
223 "macos-v1--9223372036854775808--9223372036854775808",
224 "macos-v1-9223372036854775807-9223372036854775807",
225 ] {
226 let start = ProcessStart::parse(encoding).expect("valid identity");
227 assert_eq!(start.as_str(), encoding);
228 assert_eq!(start.to_string(), encoding);
229 }
230 }
231
232 #[test]
233 fn parse_rejects_malformed_encodings() {
234 for encoding in [
235 "",
236 "garbage",
237 "linux-v2-1",
238 "other-v1-1",
239 "macos-v2-1-2",
242 "Macos-v1-1-2",
243 "xxxxxxxxx1-2",
244 "linux-v2-5",
245 "linux-v1-",
246 "linux-v1--1",
247 "linux-v1-+1",
248 "linux-v1-1-2",
249 "linux-v1-18446744073709551616",
250 "macos-v1-",
251 "macos-v1-1",
252 "macos-v1-1-",
253 "macos-v1--1-",
254 "macos-v1-1-2-3",
255 "macos-v1-+1-2",
256 "macos-v1-1-+2",
257 "macos-v1-9223372036854775808-0",
258 "macos-v1-0--9223372036854775809",
259 "linux-v1- 1",
260 "macos-v1-1-2\n",
261 ] {
262 assert_eq!(ProcessStart::parse(encoding), None, "{encoding:?}");
263 }
264 }
265
266 #[test]
267 fn invalid_pids_are_unknown() {
268 let recorded = ProcessStart::parse("linux-v1-1").unwrap();
269 for pid in [0, u32::MAX] {
270 assert_eq!(process_start(pid), None);
271 assert_eq!(liveness(pid, None), Liveness::Unknown);
272 assert_eq!(liveness(pid, Some(&recorded)), Liveness::Unknown);
273 }
274 }
275
276 #[test]
277 fn stat_parser_uses_the_last_parenthesis_and_field_22() {
278 let stat = "123 ((a b) (c)) R 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 4242";
279 assert_eq!(start_time_from_stat(stat), Some(4242));
280 assert_eq!(state_from_stat(stat), Some("R"));
281 }
282
283 #[test]
284 fn stat_parser_rejects_truncated_or_invalid_start_times() {
285 for stat in [
286 "",
287 "123 (comm)",
288 "123 (comm) R 4 5",
289 "123 (comm) R 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21",
290 "123 (comm) R 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 invalid",
291 ] {
292 assert_eq!(start_time_from_stat(stat), None, "{stat:?}");
293 }
294 }
295
296 #[cfg(any(target_os = "linux", target_os = "macos"))]
297 #[test]
298 fn own_pid_with_matching_start_is_alive() {
299 let pid = std::process::id();
300 let start = process_start(pid).expect("own kernel start is readable");
301 assert_eq!(ProcessStart::parse(start.as_str()), Some(start.clone()));
302 assert_eq!(liveness(pid, Some(&start)), Liveness::Alive);
303 }
304
305 #[cfg(unix)]
306 #[test]
307 fn own_pid_without_recorded_start_is_alive() {
308 assert_eq!(liveness(std::process::id(), None), Liveness::Alive);
309 }
310
311 #[cfg(any(target_os = "linux", target_os = "macos"))]
312 #[test]
313 fn own_pid_with_different_same_family_start_is_dead() {
314 let pid = std::process::id();
315 let current = process_start(pid).unwrap();
316 let encoding = if cfg!(target_os = "linux") {
317 "linux-v1-0"
318 } else {
319 "macos-v1-0-0"
320 };
321 let mut recorded = ProcessStart::parse(encoding).unwrap();
322 if recorded == current {
323 recorded = ProcessStart::parse(&encoding.replace("-0", "-1")).unwrap();
324 }
325 assert_ne!(current, recorded);
326 assert_eq!(liveness(pid, Some(&recorded)), Liveness::Dead);
327 }
328
329 #[cfg(any(target_os = "linux", target_os = "macos"))]
330 #[test]
331 fn own_pid_with_cross_family_start_is_unknown() {
332 let encoding = if cfg!(target_os = "linux") {
333 "macos-v1-0-0"
334 } else {
335 "linux-v1-0"
336 };
337 let recorded = ProcessStart::parse(encoding).unwrap();
338 assert_eq!(
339 liveness(std::process::id(), Some(&recorded)),
340 Liveness::Unknown
341 );
342 }
343
344 #[cfg(unix)]
345 #[test]
346 fn exited_and_reaped_child_is_dead() {
347 let mut child = std::process::Command::new("true").spawn().unwrap();
348 let pid = child.id();
349 let recorded = process_start(pid);
350 child.wait().unwrap();
351 assert_eq!(liveness(pid, None), Liveness::Dead);
352 assert_eq!(liveness(pid, recorded.as_ref()), Liveness::Dead);
353 }
354
355 #[cfg(target_os = "linux")]
356 #[test]
357 fn exited_unreaped_zombie_child_is_dead() {
358 use std::time::{Duration, Instant};
359
360 let mut child = std::process::Command::new("true").spawn().unwrap();
361 let pid = child.id();
362 let deadline = Instant::now() + Duration::from_secs(5);
363 let zombie = loop {
364 let zombie = std::fs::read_to_string(format!("/proc/{pid}/stat"))
367 .ok()
368 .is_some_and(|stat| {
369 stat.rsplit_once(')')
370 .and_then(|(_, fields)| fields.split_whitespace().next())
371 == Some("Z")
372 });
373 if zombie {
374 break true;
375 }
376 if Instant::now() >= deadline {
377 break false;
378 }
379 std::thread::sleep(Duration::from_millis(10));
380 };
381 let recorded = process_start(pid);
382 let without_start = liveness(pid, None);
383 let with_start = liveness(pid, recorded.as_ref());
384 child.wait().unwrap();
386 assert!(zombie, "child did not reach state Z within five seconds");
387 assert!(
388 recorded.is_some(),
389 "unreaped zombie still has a kernel start"
390 );
391 assert_eq!(without_start, Liveness::Dead);
392 assert_eq!(with_start, Liveness::Dead);
393 }
394
395 #[cfg(not(unix))]
396 #[test]
397 fn unsupported_platform_is_unknown() {
398 let pid = std::process::id();
399 let recorded = ProcessStart::parse("linux-v1-1").unwrap();
400 assert_eq!(process_start(pid), None);
401 assert_eq!(liveness(pid, None), Liveness::Unknown);
402 assert_eq!(liveness(pid, Some(&recorded)), Liveness::Unknown);
403 }
404}