Skip to main content

subc_os/
lib.rs

1//! Operating-system primitives the subc daemon needs and cannot reach without
2//! unsafe code, each behind a small safe API.
3//!
4//! The daemon crates forbid unsafe code. This crate is the one deliberate
5//! exception (like `subc-uptime` and `subc-cgroup`): every `unsafe` block here
6//! is a single foreign call with its preconditions stated beside it, and nothing
7//! unsafe is exported.
8//!
9//! Today it answers one question: is the process now holding pid N the same
10//! process the daemon spawned earlier? A pid alone cannot say, because the
11//! kernel reuses pids once a process has been reaped. [`Process`] reads the two
12//! facts that tell processes apart, the kernel's start time for the pid and the
13//! file identity (device and inode) of the executable image it runs, and sends
14//! signals to it.
15//!
16//! Sources, per platform:
17//!
18//! - Linux: the start time is field 22 of `/proc/<pid>/stat` (clock ticks since
19//!   boot), and the executable is `stat` through `/proc/<pid>/exe`, which
20//!   resolves to the running image even if its file has since been replaced or
21//!   deleted. A pidfd is opened before either is read and signals go through it
22//!   (`pidfd_send_signal`), so the process that was checked is the process that
23//!   is signalled. No unsafe code is needed: rustix wraps both calls.
24//! - macOS: the start time is `kp_proc.p_starttime` from `sysctl`
25//!   `KERN_PROC_PID` (microseconds since the epoch), and the executable is the
26//!   path `proc_pidpath` reports, then `stat` on that path. These two calls are
27//!   the crate's only unsafe code. macOS has no pidfd, so a signal is a plain
28//!   `kill` sent right after the checks; see [`Process::signal`].
29//! - Anywhere else: [`Process::open`] reports [`std::io::ErrorKind::Unsupported`].
30//!
31//! It also reads how much memory and CPU time one process is using, for
32//! reporting only; see [`resource_usage`]. On Linux that is procfs again; on
33//! macOS it is `proc_pid_rusage`, plus `mach_timebase_info` to convert its CPU
34//! times to nanoseconds, the other two unsafe calls in the crate.
35
36#![deny(unsafe_code)]
37
38#[cfg(target_os = "linux")]
39mod linux;
40#[cfg(target_os = "macos")]
41mod macos;
42
43#[cfg(target_os = "linux")]
44use linux as platform;
45#[cfg(target_os = "macos")]
46use macos as platform;
47
48use std::{io, path::Path};
49
50/// True where [`Process`] can identify and signal a process by pid.
51pub const PROCESS_IDENTITY_SUPPORTED: bool = cfg!(any(target_os = "linux", target_os = "macos"));
52
53/// Device and inode of a file: which file, independent of the name used to
54/// reach it.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
56pub struct FileIdentity {
57    pub device: u64,
58    pub inode: u64,
59}
60
61/// The device and inode of the file at `path`, following symlinks. `None` if it
62/// cannot be read or the platform has no inode numbers.
63pub fn file_identity(path: &Path) -> Option<FileIdentity> {
64    #[cfg(unix)]
65    {
66        use std::os::unix::fs::MetadataExt;
67
68        std::fs::metadata(path).ok().map(|metadata| FileIdentity {
69            device: metadata.dev(),
70            inode: metadata.ino(),
71        })
72    }
73    #[cfg(not(unix))]
74    {
75        let _ = path;
76        None
77    }
78}
79
80/// What a live process looks like right now.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct Observation {
83    /// The kernel's start time for the process. Opaque: compare it only with a
84    /// value read on the same host by this crate. Linux counts clock ticks since
85    /// boot; macOS counts microseconds since the epoch.
86    pub start_time: u64,
87    /// The file the process is executing, or `None` if it could not be read
88    /// (for example, a process owned by another user).
89    pub executable: Option<FileIdentity>,
90}
91
92/// A signal [`Process::signal`] can send.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Signal {
95    /// SIGTERM: a request to exit, which the process may handle or ignore.
96    Terminate,
97    /// SIGKILL: ends the process; it cannot be handled or ignored.
98    Kill,
99}
100
101/// The kernel start time of the process holding `pid`, or `None` if there is
102/// none, it has already exited (a zombie waiting to be reaped counts as exited),
103/// or the platform has no source.
104pub fn start_time(pid: u32) -> Option<u64> {
105    #[cfg(any(target_os = "linux", target_os = "macos"))]
106    {
107        platform::start_time(pid)
108    }
109    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
110    {
111        let _ = pid;
112        None
113    }
114}
115
116/// True where [`resource_usage`] can read a live process. Elsewhere it always
117/// answers `None`, and a caller can use this to say "not supported here"
118/// rather than "could not read".
119pub const RESOURCE_USAGE_SUPPORTED: bool = cfg!(any(target_os = "linux", target_os = "macos"));
120
121/// What [`ResourceUsage::memory_bytes`] measures. The platforms offer
122/// different figures, and they are not interchangeable.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum MemoryKind {
125    /// macOS `phys_footprint`: the memory the kernel charges to the process
126    /// (dirty and compressed pages, among others), which is also what jetsam
127    /// acts on. Pages an allocator has released with `MADV_FREE` do not count.
128    PhysFootprint,
129    /// Linux `VmRSS`: pages of the process resident in RAM, including shared
130    /// file-backed pages. Swapped-out pages are not included; see
131    /// [`ResourceUsage::swap_bytes`].
132    ResidentSet,
133}
134
135/// One reading of a process's memory and cumulative CPU time.
136///
137/// It covers the process named by the pid alone: its threads are included,
138/// processes it has started are not.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140pub struct ResourceUsage {
141    /// Memory in bytes, measured as [`Self::memory_kind`] says.
142    pub memory_bytes: u64,
143    pub memory_kind: MemoryKind,
144    /// Bytes swapped out (Linux `VmSwap`). `None` where the platform does not
145    /// report it for a single process, which is not the same as zero.
146    pub swap_bytes: Option<u64>,
147    /// CPU time spent in user mode since the process started.
148    pub cpu_user: std::time::Duration,
149    /// CPU time spent in the kernel on the process's behalf since it started.
150    pub cpu_system: std::time::Duration,
151}
152
153/// Memory and cumulative CPU time of the process holding `pid`, read now.
154///
155/// `None` when there is no such process, it has exited (a zombie awaiting its
156/// reap counts as exited), it cannot be read (for example, another user's
157/// process on macOS), or the platform has no source
158/// (see [`RESOURCE_USAGE_SUPPORTED`]). Never a reading of zeros in place of
159/// one of those.
160///
161/// Like any pid-based read, this describes whatever process holds `pid` now;
162/// a caller that needs it to be a particular process should confirm that
163/// process's [`start_time`] around the call.
164pub fn resource_usage(pid: u32) -> Option<ResourceUsage> {
165    #[cfg(any(target_os = "linux", target_os = "macos"))]
166    {
167        platform::resource_usage(pid)
168    }
169    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
170    {
171        let _ = pid;
172        None
173    }
174}
175
176/// A handle on the process holding one pid at the moment it was opened.
177#[derive(Debug)]
178pub struct Process {
179    pid: u32,
180    #[cfg(target_os = "linux")]
181    pidfd: Option<std::os::fd::OwnedFd>,
182}
183
184impl Process {
185    /// Open a handle on the process now holding `pid`.
186    ///
187    /// `Ok(None)` means no process holds that pid (on macOS, also a zombie
188    /// awaiting its reap; on Linux a zombie opens, and [`Self::observe`] then
189    /// reports it as exited). On Linux this opens a pidfd,
190    /// which from then on refers to this exact process even if it exits and the
191    /// pid is reused; when the kernel cannot open one (older than 5.3, or a
192    /// seccomp policy refusing the call) the handle falls back to the pid, as
193    /// on macOS.
194    pub fn open(pid: u32) -> io::Result<Option<Self>> {
195        #[cfg(target_os = "linux")]
196        {
197            linux::open(pid).map(|opened| opened.map(|pidfd| Self { pid, pidfd }))
198        }
199        #[cfg(target_os = "macos")]
200        {
201            Ok(platform::exists(pid).then_some(Self { pid }))
202        }
203        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
204        {
205            let _ = pid;
206            Err(io::Error::new(
207                io::ErrorKind::Unsupported,
208                "process identity is not available on this platform",
209            ))
210        }
211    }
212
213    pub fn pid(&self) -> u32 {
214        self.pid
215    }
216
217    /// True when signals go through a pidfd, so they cannot reach a different
218    /// process that has since reused this pid.
219    pub fn signals_through_pidfd(&self) -> bool {
220        #[cfg(target_os = "linux")]
221        {
222            self.pidfd.is_some()
223        }
224        #[cfg(not(target_os = "linux"))]
225        {
226            false
227        }
228    }
229
230    /// The process's start time and executable, or `None` once it has exited
231    /// (including as a zombie not yet reaped by its parent).
232    pub fn observe(&self) -> Option<Observation> {
233        #[cfg(any(target_os = "linux", target_os = "macos"))]
234        {
235            #[cfg(target_os = "linux")]
236            if !linux::pidfd_alive(self.pidfd.as_ref()) {
237                return None;
238            }
239            let start_time = platform::start_time(self.pid)?;
240            Some(Observation {
241                start_time,
242                executable: platform::executable_identity(self.pid),
243            })
244        }
245        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
246        {
247            None
248        }
249    }
250
251    /// Send `signal` to the process.
252    ///
253    /// With a pidfd the signal can only reach the process this handle was
254    /// opened on: if that process has exited, the call fails with `ESRCH` even
255    /// if the pid has been reused. Without one (macOS, or a Linux kernel with no
256    /// pidfd) the signal goes to whatever holds the pid now, so callers should
257    /// [`Self::observe`] immediately before signalling. What remains is the
258    /// time between that check and this call; for a different process to be
259    /// hit, the checked one must exit, be reaped, and have its pid handed to a
260    /// new process inside that window, and both kernels hand out pids in
261    /// increasing order, so a reuse needs the whole pid space to wrap first.
262    ///
263    /// `Ok(false)` means the process had already exited (`ESRCH`).
264    pub fn signal(&self, signal: Signal) -> io::Result<bool> {
265        #[cfg(any(target_os = "linux", target_os = "macos"))]
266        {
267            #[cfg(target_os = "linux")]
268            let result = linux::signal(self.pid, self.pidfd.as_ref(), signal);
269            #[cfg(target_os = "macos")]
270            let result = macos::signal(self.pid, signal);
271            match result {
272                Ok(()) => Ok(true),
273                Err(rustix::io::Errno::SRCH) => Ok(false),
274                Err(error) => Err(error.into()),
275            }
276        }
277        #[cfg(not(any(target_os = "linux", target_os = "macos")))]
278        {
279            let _ = signal;
280            Err(io::Error::new(
281                io::ErrorKind::Unsupported,
282                "process signalling is not available on this platform",
283            ))
284        }
285    }
286}
287
288#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
289mod tests {
290    use std::{
291        process::{Child, Command},
292        time::{Duration, Instant},
293    };
294
295    use super::*;
296
297    fn spawn_sleep() -> Child {
298        Command::new("sleep")
299            .arg("60")
300            .spawn()
301            .expect("spawn sleep")
302    }
303
304    /// The executable a spawned `sleep` runs, resolved the way `Command` found it.
305    fn sleep_identity() -> FileIdentity {
306        let path = ["/bin/sleep", "/usr/bin/sleep"]
307            .into_iter()
308            .find(|path| Path::new(path).exists())
309            .expect("sleep is installed");
310        file_identity(Path::new(path)).expect("stat sleep")
311    }
312
313    /// Right after `spawn` returns the child may not have finished exec yet,
314    /// and until then it still runs the test binary's image.
315    fn wait_for_executable(process: &Process, expected: FileIdentity) -> Observation {
316        let deadline = Instant::now() + Duration::from_secs(5);
317        loop {
318            let observation = process.observe().expect("child is alive");
319            if observation.executable == Some(expected) || Instant::now() > deadline {
320                return observation;
321            }
322            std::thread::sleep(Duration::from_millis(10));
323        }
324    }
325
326    #[test]
327    fn own_process_is_observable_with_its_own_image() {
328        let process = Process::open(std::process::id())
329            .expect("open own process")
330            .expect("own process exists");
331        let observation = process.observe().expect("own process is alive");
332        let own_image = file_identity(&std::env::current_exe().unwrap()).unwrap();
333        assert_eq!(observation.executable, Some(own_image));
334        assert_eq!(start_time(std::process::id()), Some(observation.start_time));
335    }
336
337    #[test]
338    fn child_start_time_is_stable_and_differs_from_ours() {
339        let mut child = spawn_sleep();
340        let pid = child.id();
341        let process = Process::open(pid).unwrap().unwrap();
342        let observation = wait_for_executable(&process, sleep_identity());
343        assert_eq!(observation.executable, Some(sleep_identity()));
344        assert_eq!(start_time(pid), Some(observation.start_time));
345        child.kill().unwrap();
346        child.wait().unwrap();
347    }
348
349    #[test]
350    fn a_signalled_and_unreaped_child_reads_as_exited() {
351        let mut child = spawn_sleep();
352        let process = Process::open(child.id()).unwrap().unwrap();
353        assert!(process.signal(Signal::Terminate).unwrap());
354        let deadline = Instant::now() + Duration::from_secs(5);
355        while process.observe().is_some() {
356            assert!(Instant::now() < deadline, "child still observed as alive");
357            std::thread::sleep(Duration::from_millis(10));
358        }
359        // Not yet reaped: the pid is still a zombie here, and still reads as exited.
360        assert_eq!(start_time(child.id()), None);
361        child.wait().unwrap();
362    }
363
364    #[test]
365    fn a_reaped_child_cannot_be_opened_or_observed() {
366        let mut child = spawn_sleep();
367        let pid = child.id();
368        child.kill().unwrap();
369        child.wait().unwrap();
370        // The pid could in principle be reused by now; either way it is not the child.
371        if let Some(process) = Process::open(pid).unwrap() {
372            if let Some(observation) = process.observe() {
373                assert_ne!(observation.executable, Some(sleep_identity()));
374            }
375        }
376    }
377
378    /// The macOS fields are read at fixed offsets, so check the value is a
379    /// plausible start time and not some other field: our own process started
380    /// in the past, and not long ago.
381    #[cfg(target_os = "macos")]
382    #[test]
383    fn macos_start_time_is_microseconds_since_the_epoch() {
384        let now = std::time::SystemTime::now()
385            .duration_since(std::time::UNIX_EPOCH)
386            .unwrap()
387            .as_micros() as u64;
388        let started = start_time(std::process::id()).unwrap();
389        assert!(started <= now, "start time {started} is after now {now}");
390        assert!(
391            now - started < 3_600 * 1_000_000,
392            "start time {started} is more than an hour before now {now}"
393        );
394    }
395
396    /// Keeps one core busy for at least `wall` of wall-clock time.
397    /// This thread's CPU time, from the thread CPU clock rather than the
398    /// process-usage API under test.
399    fn thread_cpu_time() -> Duration {
400        let now = rustix::time::clock_gettime(rustix::time::ClockId::ThreadCPUTime);
401        Duration::new(now.tv_sec as u64, now.tv_nsec as u32)
402    }
403
404    /// Spend `cpu` of this thread's CPU time. Measured on CPU time, not wall
405    /// time: on a loaded machine the thread is descheduled for part of any
406    /// wall interval, so a wall-timed loop can do far less work than its
407    /// duration suggests. A generous wall cap keeps a stalled clock from
408    /// hanging the test.
409    fn burn_cpu(cpu: Duration) {
410        let start = thread_cpu_time();
411        let give_up = Instant::now() + Duration::from_secs(60);
412        let mut value = 0u64;
413        while thread_cpu_time().saturating_sub(start) < cpu {
414            assert!(
415                Instant::now() < give_up,
416                "thread CPU clock stopped advancing"
417            );
418            for step in 0..10_000u64 {
419                value = std::hint::black_box(value.wrapping_mul(31).wrapping_add(step));
420            }
421        }
422        std::hint::black_box(value);
423    }
424
425    #[test]
426    fn own_resource_usage_is_present_and_plausible() {
427        let usage = resource_usage(std::process::id()).expect("own process is readable");
428        // A running test binary maps well over a megabyte; zero or a handful
429        // of bytes would mean the wrong field or unit was read.
430        assert!(
431            usage.memory_bytes > 1024 * 1024,
432            "memory {} bytes is implausibly small",
433            usage.memory_bytes
434        );
435        assert!(
436            usage.memory_bytes < 64 * 1024 * 1024 * 1024,
437            "memory {} bytes is implausibly large",
438            usage.memory_bytes
439        );
440        #[cfg(target_os = "macos")]
441        assert_eq!(usage.memory_kind, MemoryKind::PhysFootprint);
442        #[cfg(target_os = "linux")]
443        {
444            assert_eq!(usage.memory_kind, MemoryKind::ResidentSet);
445            assert!(usage.swap_bytes.is_some(), "Linux reports VmSwap");
446        }
447    }
448
449    /// CPU time must grow with busy work, and by roughly the amount of work
450    /// done: a reading in the wrong unit (for example Mach ticks taken as
451    /// nanoseconds on Apple silicon, about 24 times too small) grows too, but
452    /// not by enough.
453    #[test]
454    fn own_cpu_time_grows_by_about_the_busy_work_done() {
455        let pid = std::process::id();
456        let total = |usage: ResourceUsage| usage.cpu_user + usage.cpu_system;
457        let before = total(resource_usage(pid).unwrap());
458        let busy = Duration::from_millis(400);
459        burn_cpu(busy);
460        let after = total(resource_usage(pid).unwrap());
461        let grown = after.saturating_sub(before);
462        // This thread alone spent `busy` of CPU time, so the process total
463        // grew by at least that much; other tests' threads only add to it.
464        // The 10% allowance covers tick rounding in the reading, and is far
465        // tighter than the ~24x a unit error would cause.
466        assert!(
467            grown >= busy * 9 / 10,
468            "cpu time grew by {grown:?} over {busy:?} of busy work"
469        );
470    }
471
472    #[test]
473    fn a_child_reads_its_own_usage_not_ours() {
474        let mut child = spawn_sleep();
475        let process = Process::open(child.id()).unwrap().unwrap();
476        wait_for_executable(&process, sleep_identity());
477        let ours = resource_usage(std::process::id()).unwrap();
478        let usage = resource_usage(child.id()).expect("live child is readable");
479        assert!(usage.memory_bytes > 0);
480        assert!(
481            usage.memory_bytes < ours.memory_bytes,
482            "a sleeping child ({} bytes) should be smaller than the test binary ({} bytes)",
483            usage.memory_bytes,
484            ours.memory_bytes
485        );
486        child.kill().unwrap();
487        child.wait().unwrap();
488    }
489
490    #[test]
491    fn an_exited_child_reads_as_unavailable_not_zero() {
492        let mut child = spawn_sleep();
493        let pid = child.id();
494        child.kill().unwrap();
495        // Killed but not reaped: a zombie, which still has a pid.
496        let deadline = Instant::now() + Duration::from_secs(5);
497        while start_time(pid).is_some() {
498            assert!(Instant::now() < deadline, "child still observed as alive");
499            std::thread::sleep(Duration::from_millis(10));
500        }
501        assert_eq!(resource_usage(pid), None, "a zombie reads as unavailable");
502        child.wait().unwrap();
503        // Reaped: the pid names nothing (barring reuse, which would be some
504        // other live process and so still not a reading of zeros).
505        if let Some(usage) = resource_usage(pid) {
506            assert!(usage.memory_bytes > 0, "a reused pid is some live process");
507        }
508    }
509
510    #[test]
511    fn a_pid_with_no_process_reads_as_unavailable() {
512        // Above both kernels' pid limits (Linux caps pid_max at 2^22, macOS at
513        // 99998), so nothing can hold it.
514        assert_eq!(resource_usage(i32::MAX as u32), None);
515        // Not a representable pid at all.
516        assert_eq!(resource_usage(u32::MAX), None);
517    }
518
519    #[cfg(target_os = "linux")]
520    #[test]
521    fn linux_signals_go_through_a_pidfd() {
522        let mut child = spawn_sleep();
523        let process = Process::open(child.id()).unwrap().unwrap();
524        assert!(process.signals_through_pidfd());
525        child.kill().unwrap();
526        child.wait().unwrap();
527        // The pidfd still names the reaped child, so a signal cannot reach anything else.
528        assert!(!process.signal(Signal::Kill).unwrap());
529    }
530}