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