git_policy_cli/process/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/// What:
97/// The root of a tree as a member: its pid and the start time that proves later that it is still that process.
98/// Why:
99/// `descendants_of` lists only what is below a process, and a stop must wait for the process itself too.
100/// A process that has ended, or is a zombie, is no member.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// async function memberOf(pid: number): Promise<TreeMember | undefined>;
105/// ```
106#[cfg(target_os = "linux")]
107pub fn member_of(pid: i32) -> Option<TreeMember> {
108 let text: String = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
109 let (_parent, started, zombie) = parse_stat(text.as_str())?;
110 if zombie {
111 return None;
112 }
113 return Some(TreeMember { pid, started });
114}
115
116/// Only Linux reads a process's start time; elsewhere no member is found.
117// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
118#[cfg(not(target_os = "linux"))]
119pub fn member_of(_pid: i32) -> Option<TreeMember> {
120 return None;
121}
122
123/// Whether the process is still the one that was found: it exists, is not a zombie, and has the recorded start time.
124#[cfg(target_os = "linux")]
125pub fn still_running(member: &TreeMember) -> bool {
126 let Ok(text) = std::fs::read_to_string(format!("/proc/{}/stat", member.pid)) else {
127 return false;
128 };
129 return parse_stat(text.as_str()).is_some_and(|(_parent, started, zombie)| { return started == member.started && !zombie; });
130}
131
132/// Nothing is found elsewhere, so nothing is still running.
133// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
134#[cfg(not(target_os = "linux"))]
135pub fn still_running(_member: &TreeMember) -> bool {
136 return false;
137}
138
139/// What:
140/// Send `signal` to each member that is still the process that was found; the others are left alone.
141/// Why:
142/// A number that was reused by an unrelated process must never be signalled.
143///
144/// In TS you'd write (pseudocode):
145/// ```ts
146/// for (const member of tree) if (member.stillRunning()) process.kill(member.pid, signal);
147/// ```
148#[cfg(unix)]
149pub fn signal_members(members: &[TreeMember], signal: i32) {
150 for member in members {
151 if still_running(member) {
152 // SAFETY: `kill` takes no pointers; a failure is ignored because the process may have ended since the check.
153 unsafe { libc::kill(member.pid, signal) };
154 }
155 }
156}
157
158/// Platforms without signals stop only the root.
159// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
160#[cfg(not(unix))]
161pub fn signal_members(_members: &[TreeMember], _signal: i32) {}
162
163/// Ask each member that is still the process that was found to end, with `SIGTERM`.
164#[cfg(unix)]
165pub fn terminate_members(members: &[TreeMember]) {
166 signal_members(members, libc::SIGTERM);
167}
168
169/// Platforms without signals stop only the root.
170// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
171#[cfg(not(unix))]
172pub fn terminate_members(_members: &[TreeMember]) {}
173
174/// End each member that is still the process that was found, with `SIGKILL`.
175#[cfg(unix)]
176pub fn kill_members(members: &[TreeMember]) {
177 signal_members(members, libc::SIGKILL);
178}
179
180/// Platforms without signals stop only the root.
181// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
182#[cfg(not(unix))]
183pub fn kill_members(_members: &[TreeMember]) {}
184
185/// Process-tree controls stay out of the release executable.
186#[cfg(test)]
187#[path = "process_tree_tests.rs"]
188mod tests;
189
190/// Controls for platforms without `/proc`, whose branches the Linux build does not compile.
191#[cfg(all(test, not(target_os = "linux")))]
192#[path = "process_tree_other_tests.rs"]
193mod other_tests;