Skip to main content

git_policy_cli/
process_tree.rs

1//! What:
2//!  The processes below one process: found before it is stopped, and signalled afterwards, each only while it is still
3//!  the process that was found.
4//! Why:
5//!  Stopping a process does not stop what it started. A `git push` that is sent `SIGTERM` or `SIGKILL` leaves its
6//!  transport helper (`git-remote-http`, `ssh`) and the hooks it ran (`git-lfs`) alive, orphaned to the init process,
7//!  holding their connection until their own timeout ends them (measured: a helper stayed 60 seconds, a `git-lfs`
8//!  that never exits stayed for good). The tree has to be read before the root dies, because a child's link to its
9//!  parent is gone once the parent has exited. A process number can be reused, so each process is identified by the
10//!  start time the kernel recorded for it, and a number whose start time changed is not signalled.
11//!  The list comes from `/proc`, so only Linux finds any; elsewhere the tree is empty and only the root is stopped.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const tree = await descendantsOf(child.pid); child.kill('SIGTERM'); for (const process of tree) process.signalIfSame('SIGTERM');
16//! ```
17
18/// One process of a tree and the start time that proves it is still that process.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct TreeMember {
21    /// The process number.
22    pub pid: i32,
23    /// The start time the kernel recorded, in clock ticks since boot (`/proc/<pid>/stat`, field 22).
24    pub started: u64,
25}
26
27/// What:
28///  The pid, parent and start time of the process a `stat` file describes, and whether it is a zombie.
29/// Why:
30///   The command name sits in parentheses and may hold spaces and parentheses, so the fields are read after the last
31///  closing parenthesis.
32///
33/// In TS you'd write (pseudocode):
34/// ```ts
35/// const [state, ppid, ...rest] = text.slice(text.lastIndexOf(')') + 2).split(' ');
36/// ```
37#[cfg(target_os = "linux")]
38fn parse_stat(text: &str) -> Option<(i32, u64, bool)> {
39    let after_name: &str = text.get(text.rfind(')')? + 2..)?;
40    let fields: Vec<&str> = after_name.split(' ').collect();
41    // After the name: state (index 0), parent (1), ..., start time (19).
42    let state: &str = fields.first()?;
43    let parent: i32 = fields.get(1)?.parse().ok()?;
44    let started: u64 = fields.get(19)?.parse().ok()?;
45    return Some((parent, started, state == "Z"));
46}
47
48/// What:
49///  Every living process below `root`, children before grandchildren, with their start times.
50/// Why:
51///   Read once, before the root is signalled, from one pass over `/proc`.
52///
53/// In TS you'd write (pseudocode):
54/// ```ts
55/// async function descendantsOf(root: number): Promise<TreeMember[]>;
56/// ```
57#[cfg(target_os = "linux")]
58pub fn descendants_of(root: i32) -> Vec<TreeMember> {
59    use std::collections::HashMap;
60    let Ok(listing) = std::fs::read_dir("/proc") else {
61        return Vec::new();
62    };
63    // Parent number to the children found under it.
64    let mut children: HashMap<i32, Vec<TreeMember>> = HashMap::new();
65    for entry in listing.flatten() {
66        let Some(pid) = entry.file_name().to_str().and_then(|name| { return name.parse::<i32>().ok(); }) else {
67            continue;
68        };
69        let Ok(text) = std::fs::read_to_string(entry.path().join("stat")) else {
70            continue;
71        };
72        if let Some((parent, started, zombie)) = parse_stat(text.as_str())
73            && !zombie
74        {
75            children.entry(parent).or_default().push(TreeMember { pid, started });
76        }
77    }
78    let mut found: Vec<TreeMember> = Vec::new();
79    let mut frontier: Vec<i32> = vec![root];
80    while let Some(parent) = frontier.pop() {
81        for member in children.remove(&parent).unwrap_or_default() {
82            frontier.push(member.pid);
83            found.push(member);
84        }
85    }
86    return found;
87}
88
89/// Only Linux lists processes; elsewhere no descendant is found and only the root is stopped.
90// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
91#[cfg(not(target_os = "linux"))]
92pub fn descendants_of(_root: i32) -> Vec<TreeMember> {
93    return Vec::new();
94}
95
96/// Whether the process is still the one that was found: it exists, is not a zombie, and has the recorded start time.
97#[cfg(target_os = "linux")]
98pub fn still_running(member: &TreeMember) -> bool {
99    let Ok(text) = std::fs::read_to_string(format!("/proc/{}/stat", member.pid)) else {
100        return false;
101    };
102    return parse_stat(text.as_str()).is_some_and(|(_parent, started, zombie)| { return started == member.started && !zombie; });
103}
104
105/// Nothing is found elsewhere, so nothing is still running.
106// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
107#[cfg(not(target_os = "linux"))]
108pub fn still_running(_member: &TreeMember) -> bool {
109    return false;
110}
111
112/// What:
113///  Send `signal` to each member that is still the process that was found; the others are left alone.
114/// Why:
115///   A number that was reused by an unrelated process must never be signalled.
116///
117/// In TS you'd write (pseudocode):
118/// ```ts
119/// for (const member of tree) if (member.stillRunning()) process.kill(member.pid, signal);
120/// ```
121#[cfg(unix)]
122pub fn signal_members(members: &[TreeMember], signal: i32) {
123    for member in members {
124        if still_running(member) {
125            // SAFETY: `kill` takes no pointers; a failure is ignored because the process may have ended since the check.
126            unsafe { libc::kill(member.pid, signal) };
127        }
128    }
129}
130
131/// Platforms without signals stop only the root.
132// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
133#[cfg(not(unix))]
134pub fn signal_members(_members: &[TreeMember], _signal: i32) {}
135
136/// Ask each member that is still the process that was found to end, with `SIGTERM`.
137#[cfg(unix)]
138pub fn terminate_members(members: &[TreeMember]) {
139    signal_members(members, libc::SIGTERM);
140}
141
142/// Platforms without signals stop only the root.
143// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
144#[cfg(not(unix))]
145pub fn terminate_members(_members: &[TreeMember]) {}
146
147/// End each member that is still the process that was found, with `SIGKILL`.
148#[cfg(unix)]
149pub fn kill_members(members: &[TreeMember]) {
150    signal_members(members, libc::SIGKILL);
151}
152
153/// Platforms without signals stop only the root.
154// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
155#[cfg(not(unix))]
156pub fn kill_members(_members: &[TreeMember]) {}
157
158/// Process-tree controls stay out of the release executable.
159#[cfg(test)]
160#[path = "process_tree_tests.rs"]
161mod tests;
162
163/// Controls for platforms without `/proc`, whose branches the Linux build does not compile.
164#[cfg(all(test, not(target_os = "linux")))]
165#[path = "process_tree_other_tests.rs"]
166mod other_tests;