Skip to main content

signal_engine/
impact.rs

1// Pre-flight impact: TCP count, children, file locks.
2//
3// Logic moved from `peek-core::proc::signal` so it can be reused by multiple
4// frontends (CLI, daemon, future UI) without depending on peek-core.
5
6use serde::{Deserialize, Serialize};
7
8use crate::systemd::detect_systemd_unit;
9use network_inspector::tcp;
10
11/// Structured description of the impact of sending a signal to a process.
12#[derive(Debug, Serialize, Deserialize, Clone)]
13pub struct SignalImpact {
14    /// Number of active TCP connections this process has.
15    pub active_tcp_connections: usize,
16    /// Number of direct child processes.
17    pub child_process_count: usize,
18    /// Whether the process holds any exclusive file locks.
19    pub has_file_locks: bool,
20    /// Detected systemd unit name (e.g. "nginx.service"), if any.
21    pub systemd_unit: Option<String>,
22    /// Human-readable recommendation.
23    pub recommendation: String,
24    /// Whether a graceful stop is preferred over a hard kill.
25    pub prefer_graceful: bool,
26}
27
28/// Analyse the potential impact of sending a signal to `pid`.
29pub fn analyze_impact(pid: i32) -> anyhow::Result<SignalImpact> {
30    let active_tcp_connections = count_tcp_connections(pid);
31    let child_process_count = count_children(pid);
32    let has_file_locks = check_file_locks(pid);
33    let systemd_unit = detect_systemd_unit(pid);
34
35    let (recommendation, prefer_graceful) = build_recommendation(
36        active_tcp_connections,
37        child_process_count,
38        has_file_locks,
39        &systemd_unit,
40    );
41
42    Ok(SignalImpact {
43        active_tcp_connections,
44        child_process_count,
45        has_file_locks,
46        systemd_unit,
47        recommendation,
48        prefer_graceful,
49    })
50}
51
52// ─── TCP connection counting ─────────────────────────────────────────────────
53
54fn count_tcp_connections(pid: i32) -> usize {
55    let inodes = tcp::process_socket_inodes(pid);
56    if inodes.is_empty() {
57        return 0;
58    }
59    let mut count = 0usize;
60    for path in &["/proc/net/tcp", "/proc/net/tcp6"] {
61        if let Ok(raw) = std::fs::read_to_string(path) {
62            for line in raw.lines().skip(1) {
63                let fields: Vec<&str> = line.split_whitespace().collect();
64                if fields.len() < 10 {
65                    continue;
66                }
67                let inode: u64 = fields[9].parse().unwrap_or(0);
68                if inodes.contains(&inode) {
69                    // State 01 = ESTABLISHED, 0A = LISTEN (skip listen)
70                    let state = u8::from_str_radix(fields[3], 16).unwrap_or(0);
71                    if state == 0x01 {
72                        count += 1;
73                    }
74                }
75            }
76        }
77    }
78    count
79}
80
81// ─── Child counting ──────────────────────────────────────────────────────────
82
83fn count_children(pid: i32) -> usize {
84    let raw = match std::fs::read_to_string(format!("/proc/{}/task/{}/children", pid, pid)) {
85        Ok(r) => r,
86        Err(_) => {
87            // Fallback: scan all /proc/*/stat
88            return count_children_fallback(pid);
89        }
90    };
91    raw.split_whitespace().count()
92}
93
94fn count_children_fallback(parent_pid: i32) -> usize {
95    let mut count = 0usize;
96    if let Ok(entries) = std::fs::read_dir("/proc") {
97        for entry in entries.flatten() {
98            let name = entry.file_name();
99            let s = name.to_string_lossy();
100            if let Ok(p) = s.parse::<i32>() {
101                if let Ok(stat) = std::fs::read_to_string(format!("/proc/{}/stat", p)) {
102                    let after = stat.rfind(')').map(|i| &stat[i + 2..]).unwrap_or("");
103                    let fields: Vec<&str> = after.split_whitespace().collect();
104                    let ppid: i32 = fields.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
105                    if ppid == parent_pid {
106                        count += 1;
107                    }
108                }
109            }
110        }
111    }
112    count
113}
114
115// ─── File lock detection ─────────────────────────────────────────────────────
116
117fn check_file_locks(pid: i32) -> bool {
118    // /proc/locks lists all kernel file locks; check if pid appears
119    if let Ok(raw) = std::fs::read_to_string("/proc/locks") {
120        let pid_str = format!("{}", pid);
121        for line in raw.lines() {
122            // Format: "N: TYPE MAND PERM PID ..."
123            let fields: Vec<&str> = line.split_whitespace().collect();
124            // Field index 4 is the PID for POSIX locks; 5 for OFD locks
125            for &idx in &[4usize, 5usize] {
126                if fields.get(idx).copied() == Some(pid_str.as_str()) {
127                    // Only flag EXCLUSIVE (WRITE) locks
128                    if fields.get(3).map(|s| s.contains("WRITE")).unwrap_or(false) {
129                        return true;
130                    }
131                }
132            }
133        }
134    }
135    false
136}
137
138// ─── Recommendation builder ───────────────────────────────────────────────────
139
140fn build_recommendation(
141    tcp: usize,
142    children: usize,
143    locks: bool,
144    unit: &Option<String>,
145) -> (String, bool) {
146    let mut points = Vec::new();
147    let mut prefer_graceful = false;
148
149    if tcp > 0 {
150        points.push(format!(
151            "{} active TCP connection(s) will be abruptly terminated by a hard kill",
152            tcp
153        ));
154        prefer_graceful = true;
155    }
156    if children > 0 {
157        points.push(format!(
158            "{} child process(es) will be orphaned or killed (depending on signal)",
159            children
160        ));
161    }
162    if locks {
163        points.push(
164            "process holds exclusive file lock(s) — hard kill may leave stale locks".to_string(),
165        );
166        prefer_graceful = true;
167    }
168    if let Some(unit) = unit {
169        points.push(format!(
170            "process is managed by systemd unit '{}' — consider 'systemctl stop/restart' instead",
171            unit
172        ));
173        prefer_graceful = true;
174    }
175
176    if points.is_empty() {
177        (
178            "Process appears safe to terminate with SIGKILL.".to_string(),
179            false,
180        )
181    } else {
182        let rec = format!(
183            "{}{}",
184            points.join(". "),
185            if prefer_graceful {
186                ". Graceful stop (SIGTERM) is recommended."
187            } else {
188                "."
189            }
190        );
191        (rec, prefer_graceful)
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn recommendation_no_risks() {
201        let (msg, graceful) = build_recommendation(0, 0, false, &None);
202        assert!(!graceful);
203        assert!(msg.contains("safe"));
204    }
205
206    #[test]
207    fn recommendation_with_tcp() {
208        let (msg, graceful) = build_recommendation(5, 0, false, &None);
209        assert!(graceful);
210        assert!(msg.contains("TCP"));
211    }
212
213    #[test]
214    fn recommendation_with_systemd() {
215        let (msg, graceful) = build_recommendation(0, 0, false, &Some("nginx.service".to_string()));
216        assert!(graceful);
217        assert!(msg.contains("systemd"));
218    }
219}