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;