Skip to main content

gander_core/
action.rs

1//! Process actions.
2//!
3//! `core` never performs a side effect directly: it produces an `Action` that
4//! the front end executes, so the same logic can be unit-tested and dry-run.
5
6use crate::Pid;
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub enum Action {
10    Kill {
11        pid: Pid,
12        signal: i32,
13    },
14    Renice {
15        pid: Pid,
16        nice: i8,
17    },
18    /// Raw priority adjustment; `Delta` is applied to the current nice value.
19    Priority {
20        pid: Pid,
21        delta: i8,
22    },
23}
24
25/// Signals offered by the signal picker, in the order htop shows them.
26pub const SIGNALS: &[(i32, &str)] = &[
27    (1, "SIGHUP"),
28    (2, "SIGINT"),
29    (3, "SIGQUIT"),
30    (4, "SIGILL"),
31    (6, "SIGABRT"),
32    (9, "SIGKILL"),
33    (11, "SIGSEGV"),
34    (15, "SIGTERM"),
35    (17, "SIGCHLD"),
36    (18, "SIGCONT"),
37    (19, "SIGSTOP"),
38    (20, "SIGTSTP"),
39];
40
41/// Executes actions against the real system.
42///
43/// `kill(2)`, `getpriority(2)` and `setpriority(2)` are POSIX, so this works on
44/// every unix. Gating it on Linux meant `F9` silently failed on macOS with
45/// "process actions are only implemented for Linux".
46#[cfg(unix)]
47pub fn execute(action: &Action) -> Result<(), std::io::Error> {
48    use std::io;
49    match *action {
50        Action::Kill { pid, signal } => {
51            // SAFETY: `kill` is async-signal-safe and takes only integers.
52            let rc = unsafe { libc::kill(pid as libc::pid_t, signal as libc::c_int) };
53            if rc == 0 {
54                Ok(())
55            } else {
56                Err(io::Error::last_os_error())
57            }
58        }
59        Action::Renice { pid, nice } => set_nice(pid, nice as i32),
60        Action::Priority { pid, delta } => {
61            // Read the current nice value. `-1` is both a valid priority and the
62            // documented error return, so errno is cleared first to tell them
63            // apart (the classic getpriority trap).
64            // SAFETY: both calls take only integers.
65            unsafe { set_errno(0) };
66            // SAFETY: `getpriority` has no preconditions.
67            let cur = unsafe { libc::getpriority(libc::PRIO_PROCESS, who(pid)) };
68            // SAFETY: reading errno has no preconditions.
69            if cur == -1 && unsafe { errno() } != 0 {
70                return Err(io::Error::last_os_error());
71            }
72            let target = (cur + delta as i32).clamp(-20, 19);
73            set_nice(pid, target)
74        }
75    }
76}
77
78/// Read the C `errno`. `libc` exposes it differently per platform.
79///
80/// SAFETY: no preconditions; the pointer is thread-local and always valid.
81#[cfg(unix)]
82unsafe fn errno() -> i32 {
83    #[cfg(any(target_os = "linux", target_os = "android"))]
84    {
85        *libc::__errno_location()
86    }
87    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
88    {
89        *libc::__error()
90    }
91    #[cfg(not(any(
92        target_os = "linux",
93        target_os = "android",
94        target_os = "macos",
95        target_os = "ios",
96        target_os = "freebsd"
97    )))]
98    {
99        0
100    }
101}
102
103/// Set the C `errno`.
104///
105/// SAFETY: as above.
106#[cfg(unix)]
107unsafe fn set_errno(value: i32) {
108    #[cfg(any(target_os = "linux", target_os = "android"))]
109    {
110        *libc::__errno_location() = value;
111    }
112    #[cfg(any(target_os = "macos", target_os = "ios", target_os = "freebsd"))]
113    {
114        *libc::__error() = value;
115    }
116    #[cfg(not(any(
117        target_os = "linux",
118        target_os = "android",
119        target_os = "macos",
120        target_os = "ios",
121        target_os = "freebsd"
122    )))]
123    {
124        let _ = value;
125    }
126}
127
128/// The `who` argument for `getpriority`/`setpriority`.
129///
130/// POSIX types it `id_t`, and FreeBSD's `id_t` is genuinely 64-bit while its
131/// `getpriority` still takes an `int`; `libc` reproduces each platform's actual
132/// prototype, so the two spellings differ. Naming the type once keeps the call
133/// sites identical.
134#[cfg(any(target_os = "linux", target_os = "macos"))]
135fn who(pid: Pid) -> libc::id_t {
136    pid as libc::id_t
137}
138
139#[cfg(target_os = "freebsd")]
140fn who(pid: Pid) -> libc::c_int {
141    pid as libc::c_int
142}
143
144#[cfg(unix)]
145fn set_nice(pid: Pid, nice: i32) -> Result<(), std::io::Error> {
146    use std::io;
147    // SAFETY: integer arguments only.
148    let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, who(pid), nice) };
149    if rc == 0 {
150        Ok(())
151    } else {
152        Err(io::Error::last_os_error())
153    }
154}
155
156/// Non-unix fallback. There is no portable `kill(2)` here, and no supported
157/// target needs one yet.
158#[cfg(not(unix))]
159pub fn execute(_action: &Action) -> Result<(), std::io::Error> {
160    Err(std::io::Error::new(
161        std::io::ErrorKind::Unsupported,
162        "process actions require a unix target",
163    ))
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn actions_are_comparable() {
172        assert_eq!(
173            Action::Kill { pid: 1, signal: 9 },
174            Action::Kill { pid: 1, signal: 9 }
175        );
176        assert_ne!(
177            Action::Kill { pid: 1, signal: 9 },
178            Action::Kill { pid: 1, signal: 15 }
179        );
180    }
181
182    #[test]
183    fn signal_table_is_sorted_and_unique() {
184        let mut signals: Vec<i32> = SIGNALS.iter().map(|(n, _)| *n).collect();
185        let len = signals.len();
186        signals.dedup();
187        assert_eq!(signals.len(), len, "duplicate signal numbers");
188        signals.sort_unstable();
189        assert_eq!(signals.first(), Some(&1));
190        assert!(signals.contains(&9) && signals.contains(&15));
191    }
192}
193
194/// I/O scheduling class, as Linux's `ioprio_set` understands it.
195#[derive(Copy, Clone, PartialEq, Eq, Debug)]
196pub enum IoClass {
197    None,
198    Realtime,
199    BestEffort,
200    Idle,
201}
202
203impl IoClass {
204    /// The `IOPRIO_CLASS_*` value.
205    pub fn bits(self) -> u32 {
206        match self {
207            IoClass::None => 0,
208            IoClass::Realtime => 1,
209            IoClass::BestEffort => 2,
210            IoClass::Idle => 3,
211        }
212    }
213
214    pub fn from_bits(bits: u32) -> Option<Self> {
215        Some(match bits {
216            0 => IoClass::None,
217            1 => IoClass::Realtime,
218            2 => IoClass::BestEffort,
219            3 => IoClass::Idle,
220            _ => return None,
221        })
222    }
223
224    pub fn name(self) -> &'static str {
225        match self {
226            IoClass::None => "none",
227            IoClass::Realtime => "realtime",
228            IoClass::BestEffort => "best-effort",
229            IoClass::Idle => "idle",
230        }
231    }
232
233    /// The next class in the cycle the panel walks.
234    pub fn next(self) -> Self {
235        match self {
236            IoClass::None => IoClass::BestEffort,
237            IoClass::BestEffort => IoClass::Idle,
238            IoClass::Idle => IoClass::Realtime,
239            IoClass::Realtime => IoClass::None,
240        }
241    }
242}
243
244/// An I/O priority: a class plus a 0..=7 level within that class.
245#[derive(Copy, Clone, PartialEq, Eq, Debug)]
246pub struct IoPriority {
247    pub class: IoClass,
248    pub level: u8,
249}
250
251impl Default for IoPriority {
252    fn default() -> Self {
253        Self {
254            class: IoClass::None,
255            level: 0,
256        }
257    }
258}
259
260impl IoPriority {
261    pub fn new(class: IoClass, level: u8) -> Self {
262        Self {
263            class,
264            level: level.min(7),
265        }
266    }
267
268    /// The packed `ioprio` value the syscall takes.
269    pub fn to_bits(self) -> i32 {
270        (((self.class.bits() as i32) << 13) | self.level as i32) & 0xFFFF
271    }
272
273    pub fn from_bits(bits: i32) -> Self {
274        let class = IoClass::from_bits(((bits >> 13) & 0x7) as u32).unwrap_or(IoClass::None);
275        Self {
276            class,
277            level: (bits & 0x7) as u8,
278        }
279    }
280
281    /// `class/level`, e.g. `best-effort/4`.
282    pub fn label(self) -> String {
283        format!("{}/{}", self.class.name(), self.level)
284    }
285}
286
287/// Whether this build can read and set I/O priority.
288pub fn ioprio_supported() -> bool {
289    cfg!(target_os = "linux")
290}
291
292/// Read a process's I/O priority.
293///
294/// Linux only; elsewhere this returns `Err` with an explanation, because unlike
295/// CPU affinity there is no partial story to tell.
296#[cfg(target_os = "linux")]
297pub fn ioprio_get(pid: Pid) -> Result<IoPriority, std::io::Error> {
298    use std::io;
299    // SAFETY: `ioprio_get` is a plain syscall taking integers.
300    let rc = unsafe { libc::syscall(libc::SYS_ioprio_get, 1 /* IOPRIO_WHO_PROCESS */, pid) };
301    if rc < 0 {
302        return Err(io::Error::last_os_error());
303    }
304    Ok(IoPriority::from_bits(rc as i32))
305}
306
307#[cfg(not(target_os = "linux"))]
308pub fn ioprio_get(_pid: Pid) -> Result<IoPriority, std::io::Error> {
309    Err(std::io::Error::new(
310        std::io::ErrorKind::Unsupported,
311        "I/O priority is a Linux feature (ioprio_get)",
312    ))
313}
314
315#[cfg(target_os = "linux")]
316pub fn ioprio_set(pid: Pid, priority: IoPriority) -> Result<(), std::io::Error> {
317    use std::io;
318    // SAFETY: `ioprio_set` is a plain syscall taking integers.
319    let rc = unsafe {
320        libc::syscall(
321            libc::SYS_ioprio_set,
322            1, /* IOPRIO_WHO_PROCESS */
323            pid,
324            priority.to_bits(),
325        )
326    };
327    if rc < 0 {
328        Err(io::Error::last_os_error())
329    } else {
330        Ok(())
331    }
332}
333
334#[cfg(not(target_os = "linux"))]
335pub fn ioprio_set(_pid: Pid, _priority: IoPriority) -> Result<(), std::io::Error> {
336    Err(std::io::Error::new(
337        std::io::ErrorKind::Unsupported,
338        "I/O priority is a Linux feature (ioprio_set)",
339    ))
340}
341
342#[cfg(test)]
343mod ioprio_tests {
344    use super::*;
345
346    #[test]
347    fn bits_pack_and_unpack() {
348        for class in [
349            IoClass::None,
350            IoClass::Realtime,
351            IoClass::BestEffort,
352            IoClass::Idle,
353        ] {
354            for level in 0..=7u8 {
355                let p = IoPriority::new(class, level);
356                let back = IoPriority::from_bits(p.to_bits());
357                assert_eq!(back, p, "{:?}/{}", class, level);
358            }
359        }
360    }
361
362    #[test]
363    fn the_default_is_class_none() {
364        let p = IoPriority::default();
365        assert_eq!(p.class, IoClass::None);
366        assert_eq!(p.level, 0);
367        assert_eq!(p.to_bits(), 0);
368    }
369
370    #[test]
371    fn levels_are_clamped_to_seven() {
372        let p = IoPriority::new(IoClass::BestEffort, 200);
373        assert_eq!(p.level, 7);
374    }
375
376    #[test]
377    fn classes_cycle_through_every_variant() {
378        let mut c = IoClass::None;
379        let mut seen = vec![c];
380        for _ in 0..3 {
381            c = c.next();
382            seen.push(c);
383        }
384        assert_eq!(seen.len(), 4);
385        assert_eq!(c.next(), IoClass::None, "the cycle closes");
386    }
387
388    #[test]
389    fn labels_are_readable() {
390        let p = IoPriority::new(IoClass::BestEffort, 4);
391        assert_eq!(p.label(), "best-effort/4");
392    }
393
394    #[test]
395    fn an_out_of_range_class_degrades_to_none() {
396        let p = IoPriority::from_bits((7 << 13) | 3);
397        assert_eq!(p.class, IoClass::None);
398        assert_eq!(p.level, 3);
399    }
400
401    #[cfg(target_os = "linux")]
402    #[test]
403    fn reading_our_own_ioprio_works() {
404        let p = ioprio_get(std::process::id()).expect("ioprio_get on self");
405        // Any class is fine; the call must simply succeed.
406        assert!(matches!(
407            p.class,
408            IoClass::None | IoClass::Realtime | IoClass::BestEffort | IoClass::Idle
409        ));
410    }
411
412    #[cfg(target_os = "linux")]
413    #[test]
414    fn setting_our_own_ioprio_round_trips() {
415        let original = ioprio_get(std::process::id()).unwrap();
416        let target = IoPriority::new(IoClass::BestEffort, 6);
417        if ioprio_set(std::process::id(), target).is_ok() {
418            assert_eq!(ioprio_get(std::process::id()).unwrap(), target);
419        }
420        let _ = ioprio_set(std::process::id(), original);
421    }
422
423    #[cfg(not(target_os = "linux"))]
424    #[test]
425    fn unsupported_platforms_explain_themselves() {
426        assert!(!ioprio_supported());
427        let err = ioprio_get(std::process::id()).unwrap_err();
428        assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
429        assert!(err.to_string().contains("Linux"));
430    }
431}