Skip to main content

git_policy_cli/
process_start.rs

1//! What:
2//!  The wall-clock start time of a process,
3//!  with its resolution,
4//!  for comparing a Git lock
5//!       PID file's owner with the lock's change time.
6//! Why:
7//!  A PID file names a live owner only when that process started no later than the lock
8//!      changed;
9//!  a younger process reused the PID.
10//!  Linux derives the start from
11//!      `/proc/<pid>/stat` field 22,
12//!  `/proc/uptime` and the current time;
13//!  macOS reads
14//!      `ps -o lstart=`;
15//!  Windows reads `Get-Process` `StartTime`
16//!      (`src/index-lock/process-start-time.ts`).
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! const start = await resolveProcessStart({ pid });
21//! ```
22
23/// Process existence probes.
24#[cfg(unix)]
25use super::process_identity::signal_probe;
26/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
27use std::path::{Path, PathBuf};
28
29/// Clock ticks per second in `/proc/<pid>/stat`,
30///  the kernel's fixed `USER_HZ`.
31pub const LINUX_USER_HZ: i64 = 100;
32
33/// Resolution of a Linux start time:
34///  one tick plus the centisecond resolution of `/proc/uptime`.
35pub const LINUX_RESOLUTION_MS: i64 = 2 * (1000 / LINUX_USER_HZ);
36
37/// Resolution of `ps -o lstart=`,
38///  which prints whole seconds.
39pub const DARWIN_RESOLUTION_MS: i64 = 1000;
40
41/// Resolution granted to Windows start times.
42pub const WINDOWS_RESOLUTION_MS: i64 = 1;
43
44/// Index of the start-tick field among the fields after the command name (field 22 overall).
45pub const START_TICKS_FIELD_INDEX: usize = 19;
46
47/// What:
48///  When a process started,
49///  or why that is not known.
50/// Why:
51///   `ProcessStart`.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// type ProcessStart = { kind: 'running'; startedAtMs; resolutionMs } | { kind: 'missing' } | { kind: 'unknown'; reason };
56/// ```
57#[derive(Clone, Debug, PartialEq)]
58pub enum ProcessStart {
59    /// The process runs.
60    Running {
61        /// Start time in milliseconds since the epoch.
62        started_at_ms: f64,
63        /// Clock granularity to allow for when comparing.
64        resolution_ms: i64,
65    },
66    /// No such process runs,
67    ///  or it is a zombie.
68    Missing,
69    /// The process may run but its start time is unreadable.
70    Unknown(
71        /// Why the start time is not known.
72        String,
73    ),
74}
75
76/// What:
77///  The state letter and start ticks of a `/proc/<pid>/stat` line.
78/// Why:
79///   `parseLinuxStat`:
80///  the command name ends at the last `)`;
81///  field 22 must be a safe
82///       integer as `Number` reads it.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// function parseLinuxStat(stat: string): { state: string; startTicks: number }; // throws TypeError
87/// ```
88pub fn parse_linux_stat(stat: &str) -> Option<(String, i64)> {
89    let command_end: usize = stat.rfind(')')?;
90    let after: &str = stat.get(command_end + 2..).unwrap_or("");
91    let fields: Vec<&str> = super::js_text::trim_javascript(after).split(' ').collect();
92    let state: &str = fields.first().copied().unwrap_or("");
93    let ticks_text: &str = fields.get(START_TICKS_FIELD_INDEX).copied()?;
94    if state.is_empty() || ticks_text.is_empty() || !ticks_text.bytes().all(is_digit) {
95        return None;
96    }
97    let ticks: i64 = ticks_text.parse::<i64>().ok()?;
98    if ticks > super::json_record::MAX_SAFE_INTEGER {
99        return None;
100    }
101    return Some((String::from(state), ticks));
102}
103
104/// What:
105///  Whether a byte is an ASCII digit.
106/// Why:
107///   A named predicate keeps the scan free of closures.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// (byte: number) => byte >= 0x30 && byte <= 0x39
112/// ```
113fn is_digit(byte: u8) -> bool {
114    return byte.is_ascii_digit();
115}
116
117/// What:
118///  The seconds since boot in `/proc/uptime` text,
119///  its first field.
120/// Why:
121///   The start is `now - uptime + ticks / USER_HZ`.
122///
123/// In TS you'd write (pseudocode):
124/// ```ts
125/// Number(text.split(' ')[0])
126/// ```
127pub fn parse_uptime(text: &str) -> Option<f64> {
128    let first: &str = text.split(' ').next()?;
129    let trimmed: &str = super::js_text::trim_javascript(first);
130    // `Number('')` is zero, so an empty first field reads as no time since boot.
131    let seconds: f64 = if trimmed.is_empty() { 0.0 } else { trimmed.parse::<f64>().ok()? };
132    if !seconds.is_finite() {
133        return None;
134    }
135    return Some(seconds);
136}
137
138/// What:
139///  The current time in milliseconds since the epoch.
140/// Why:
141///   `Date.now()`.
142///
143/// In TS you'd write (pseudocode):
144/// ```ts
145/// Date.now()
146/// ```
147pub fn now_ms() -> f64 {
148    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
149        Ok(elapsed) => return elapsed.as_secs_f64() * 1000.0,
150        Err(_) => return 0.0,
151    }
152}
153
154/// What:
155///  A Linux process's start from a proc root.
156/// Why:
157///   `linuxStart`;
158///  a vanished stat file or a zombie is a missing process.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// async function linuxStart({ pid, procRoot, now }): Promise<ProcessStart>;
163/// ```
164pub fn linux_start(pid: i64, proc_root: &Path, now: f64) -> ProcessStart {
165    let stat_path: PathBuf = proc_root.join(pid.to_string()).join("stat");
166    let stat: String = match std::fs::read(&stat_path) {
167        Ok(bytes) => String::from_utf8_lossy(bytes.as_slice()).into_owned(),
168        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return ProcessStart::Missing,
169        #[cfg(unix)]
170        Err(error) if error.raw_os_error() == Some(libc::ESRCH) => return ProcessStart::Missing,
171        Err(error) => return ProcessStart::Unknown(format!("reading {} failed: {error}", stat_path.display())),
172    };
173    let Some((state, ticks)) = parse_linux_stat(stat.as_str()) else {
174        return ProcessStart::Unknown(String::from("Malformed Linux process stat."));
175    };
176    if state == "Z" || state == "X" {
177        return ProcessStart::Missing;
178    }
179    let uptime_path: PathBuf = proc_root.join("uptime");
180    let uptime: f64 = match std::fs::read_to_string(&uptime_path).ok().as_deref().and_then(parse_uptime) {
181        Some(seconds) => seconds,
182        None => return ProcessStart::Unknown(String::from("Malformed /proc/uptime.")),
183    };
184    // Ticks are at most 2^53 - 1, so the conversion to a double is exact.
185    let ticks_ms: f64 = (ticks as f64) * 1000.0 / (LINUX_USER_HZ as f64);
186    return ProcessStart::Running {
187        started_at_ms: (now - uptime * 1000.0) + ticks_ms,
188        resolution_ms: LINUX_RESOLUTION_MS,
189    };
190}
191
192/// What:
193///  Month numbers of the C locale's abbreviated month names.
194/// Why:
195///   `ps -o lstart=` prints `Fri Sep 26 10:00:00 2026`.
196const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
197
198/// What:
199///  The fields of a `ps -o lstart=` line:
200///  year,
201///  month (0 to 11),
202///  day,
203///  hour,
204///  minute,
205///  second.
206/// Why:
207///   `Date.parse` reads this local-time form;
208///  the conversion to an instant happens apart.
209///
210/// In TS you'd write (pseudocode):
211/// ```ts
212/// Date.parse('Fri Sep 26 10:00:00 2026')
213/// ```
214pub fn parse_lstart(text: &str) -> Option<(i32, i32, i32, i32, i32, i32)> {
215    let words: Vec<&str> = text.split_whitespace().collect();
216    if words.len() != 5 {
217        return None;
218    }
219    let month: i32 = month_number(words[1])?;
220    let day: i32 = words[2].parse::<i32>().ok()?;
221    let clock: Vec<&str> = words[3].split(':').collect();
222    if clock.len() != 3 {
223        return None;
224    }
225    let hour: i32 = clock[0].parse::<i32>().ok()?;
226    let minute: i32 = clock[1].parse::<i32>().ok()?;
227    let second: i32 = clock[2].parse::<i32>().ok()?;
228    let year: i32 = words[4].parse::<i32>().ok()?;
229    return Some((year, month, day, hour, minute, second));
230}
231
232/// What:
233///  The month number (0 to 11) of a C-locale abbreviated month name.
234/// Why:
235///   A named search keeps the parser free of closures.
236///
237/// In TS you'd write (pseudocode):
238/// ```ts
239/// MONTHS.indexOf(name)
240/// ```
241fn month_number(name: &str) -> Option<i32> {
242    for (index, month) in MONTHS.iter().enumerate() {
243        if *month == name {
244            return i32::try_from(index).ok();
245        }
246    }
247    return None;
248}
249
250/// What:
251///  Whether a process with this PID exists;
252///  a permission denial means it does.
253/// Why:
254///   `processExists`.
255///
256/// In TS you'd write (pseudocode):
257/// ```ts
258/// function processExists(pid: number): boolean;
259/// ```
260pub fn process_exists(pid: i64) -> bool {
261    #[cfg(unix)]
262    {
263        match signal_probe(pid) {
264            Ok(alive) => return alive,
265            Err(error) => return error.kind() == std::io::ErrorKind::PermissionDenied,
266        }
267    }
268    #[cfg(not(unix))]
269    {
270        let _ = pid;
271        return true;
272    }
273}
274
275/// What:
276///  Whether a PID names a running process and when it started.
277/// Why:
278///   `resolveProcessStart` with the host's mechanism.
279///
280/// In TS you'd write (pseudocode):
281/// ```ts
282/// async function resolveProcessStart({ pid }): Promise<ProcessStart>;
283/// ```
284pub fn resolve_process_start(pid: i64) -> ProcessStart {
285    if !process_exists(pid) {
286        return ProcessStart::Missing;
287    }
288    #[cfg(target_os = "linux")]
289    {
290        return linux_start(pid, Path::new("/proc"), now_ms());
291    }
292    #[cfg(target_os = "macos")]
293    {
294        return darwin_start(pid);
295    }
296    #[cfg(windows)]
297    {
298        return windows_start(pid);
299    }
300    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
301    {
302        return ProcessStart::Unknown(String::from("no start-time source on this platform"));
303    }
304}
305
306/// What:
307///  A macOS process's start through `ps -o lstart=`,
308///  read as local time.
309/// Why:
310///   `darwinStart`;
311///  `mktime` applies the local time zone as `Date.parse` does.
312///
313/// In TS you'd write (pseudocode):
314/// ```ts
315/// async function darwinStart(pid: number): Promise<ProcessStart>;
316/// ```
317#[cfg(target_os = "macos")]
318fn darwin_start(pid: i64) -> ProcessStart {
319    let output: std::process::Output = match std::process::Command::new("ps")
320        .args(["-o", "lstart=", "-p", pid.to_string().as_str()])
321        .env("LC_ALL", "C")
322        .stdin(std::process::Stdio::null())
323        .output()
324    {
325        Ok(finished) => finished,
326        Err(error) => return ProcessStart::Unknown(format!("ps could not start: {error}")),
327    };
328    let text: String = String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string();
329    let Some((year, month, day, hour, minute, second)) = parse_lstart(text.as_str()) else {
330        return ProcessStart::Unknown(format!("unparsable ps start time {text:?}"));
331    };
332    // SAFETY: `tm` is plain data, fully initialized here; `mktime` only reads and normalizes it.
333    let mut broken: libc::tm = unsafe { std::mem::zeroed() };
334    broken.tm_year = year - 1900;
335    broken.tm_mon = month;
336    broken.tm_mday = day;
337    broken.tm_hour = hour;
338    broken.tm_min = minute;
339    broken.tm_sec = second;
340    broken.tm_isdst = -1;
341    // SAFETY: `broken` is a valid, exclusively borrowed `tm`.
342    let seconds: libc::time_t = unsafe { libc::mktime(&mut broken) };
343    if seconds == -1 {
344        return ProcessStart::Unknown(format!("unparsable ps start time {text:?}"));
345    }
346    return ProcessStart::Running {
347        started_at_ms: (seconds as f64) * 1000.0,
348        resolution_ms: DARWIN_RESOLUTION_MS,
349    };
350}
351
352/// What:
353///  A Windows process's start through PowerShell,
354///  in .NET ticks.
355/// Why:
356///   `win32Start`.
357///
358/// In TS you'd write (pseudocode):
359/// ```ts
360/// async function win32Start(pid: number): Promise<ProcessStart>;
361/// ```
362#[cfg(windows)]
363fn windows_start(pid: i64) -> ProcessStart {
364    let command: String = format!("(Get-Process -Id {pid}).StartTime.ToUniversalTime().Ticks");
365    let output: std::process::Output = match std::process::Command::new("powershell.exe")
366        .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command.as_str()])
367        .stdin(std::process::Stdio::null())
368        .output()
369    {
370        Ok(finished) => finished,
371        Err(error) => return ProcessStart::Unknown(format!("powershell could not start: {error}")),
372    };
373    let text: String = String::from_utf8_lossy(output.stdout.as_slice()).trim().to_string();
374    let Ok(ticks) = text.parse::<i128>() else {
375        return ProcessStart::Unknown(format!("unparsable start ticks {text:?}"));
376    };
377    let milliseconds: i128 = (ticks - 621_355_968_000_000_000) / 10_000;
378    return ProcessStart::Running {
379        started_at_ms: milliseconds as f64,
380        resolution_ms: WINDOWS_RESOLUTION_MS,
381    };
382}
383
384/// Start-time controls stay out of the release executable.
385#[cfg(test)]
386#[path = "process_start_tests.rs"]
387mod tests;