use core::time::Duration;
use std::time::SystemTime;
use crate::model::{MetricState, ProcessIdentity, UserIdentity};
use crate::units::{Percent, Rate};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum ProcessState {
Running,
Sleeping,
UninterruptibleSleep,
Zombie,
Stopped,
Traced,
Idle,
Dead,
#[default]
Unknown,
}
impl ProcessState {
#[must_use]
pub const fn code(self) -> char {
match self {
Self::Running => 'R',
Self::Sleeping => 'S',
Self::UninterruptibleSleep => 'D',
Self::Zombie => 'Z',
Self::Stopped => 'T',
Self::Traced => 't',
Self::Idle => 'I',
Self::Dead => 'X',
Self::Unknown => '?',
}
}
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Running => "running",
Self::Sleeping => "sleeping",
Self::UninterruptibleSleep => "uninterruptible sleep",
Self::Zombie => "zombie",
Self::Stopped => "stopped",
Self::Traced => "traced",
Self::Idle => "idle",
Self::Dead => "dead",
Self::Unknown => "unknown",
}
}
#[must_use]
pub const fn is_notable(self) -> bool {
matches!(self, Self::Zombie | Self::UninterruptibleSleep)
}
#[must_use]
pub const fn is_signalable(self) -> bool {
!matches!(self, Self::Zombie | Self::Dead)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ProcessMemory {
pub rss_bytes: MetricState<u64>,
pub virtual_bytes: MetricState<u64>,
pub share_of_total: MetricState<Percent>,
}
impl ProcessMemory {
pub const WARMING_UP: Self = Self {
rss_bytes: MetricState::WarmingUp,
virtual_bytes: MetricState::WarmingUp,
share_of_total: MetricState::WarmingUp,
};
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ProcessIo {
pub read: MetricState<Rate>,
pub write: MetricState<Rate>,
pub read_total_bytes: MetricState<u64>,
pub write_total_bytes: MetricState<u64>,
}
impl ProcessIo {
pub const UNSUPPORTED: Self = Self {
read: MetricState::Unsupported,
write: MetricState::Unsupported,
read_total_bytes: MetricState::Unsupported,
write_total_bytes: MetricState::Unsupported,
};
pub const WARMING_UP: Self = Self {
read: MetricState::WarmingUp,
write: MetricState::WarmingUp,
read_total_bytes: MetricState::WarmingUp,
write_total_bytes: MetricState::WarmingUp,
};
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ProcessSnapshot {
pub identity: ProcessIdentity,
pub parent_pid: Option<u32>,
pub name: Box<str>,
pub command: Box<str>,
pub exe: Option<Box<str>>,
pub user: MetricState<UserIdentity>,
pub state: ProcessState,
pub cpu: MetricState<Percent>,
pub memory: ProcessMemory,
pub io: ProcessIo,
pub threads: MetricState<u32>,
pub age: MetricState<Duration>,
pub started_at: MetricState<SystemTime>,
pub is_kernel_thread: bool,
}
impl ProcessSnapshot {
#[must_use]
pub fn command_or_name(&self) -> &str {
if self.command.is_empty() {
&self.name
} else {
&self.command
}
}
#[must_use]
pub fn redacted_command(&self) -> &str {
let command = self.command_or_name();
match command.split_once(' ') {
Some((program, _)) => program,
None => command,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum OpenFileKind {
File,
Socket,
Pipe,
EventQueue,
SharedMemory,
Semaphore,
#[default]
Unknown,
}
impl OpenFileKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::File => "file",
Self::Socket => "socket",
Self::Pipe => "pipe",
Self::EventQueue => "event queue",
Self::SharedMemory => "shared memory",
Self::Semaphore => "semaphore",
Self::Unknown => "unknown",
}
}
#[must_use]
pub const fn has_path(self) -> bool {
matches!(self, Self::File)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct OpenFileEntry {
pub descriptor: i32,
pub kind: OpenFileKind,
#[cfg_attr(feature = "serde", serde(serialize_with = "redact_descriptor_path"))]
pub path: MetricState<Box<str>>,
}
#[cfg(feature = "serde")]
fn redact_descriptor_path<S: serde::Serializer>(
path: &MetricState<Box<str>>,
serializer: S,
) -> Result<S::Ok, S::Error> {
use serde::Serialize as _;
path.as_ref().map(|_| "redacted").serialize(serializer)
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct OpenFileList {
entries: Vec<OpenFileEntry>,
not_listed: u32,
}
impl OpenFileList {
pub const MAX_LISTED: usize = 256;
#[must_use]
pub fn listed(mut entries: Vec<OpenFileEntry>, total: usize) -> Self {
entries.truncate(Self::MAX_LISTED);
let not_listed = total.saturating_sub(entries.len());
Self {
entries,
not_listed: u32::try_from(not_listed).unwrap_or(u32::MAX),
}
}
#[must_use]
pub fn entries(&self) -> &[OpenFileEntry] {
&self.entries
}
#[must_use]
pub fn count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub const fn not_listed(&self) -> u32 {
self.not_listed
}
#[must_use]
pub fn total(&self) -> u64 {
u64::try_from(self.entries.len()).unwrap_or(u64::MAX) + u64::from(self.not_listed)
}
#[must_use]
pub const fn is_complete(&self) -> bool {
self.not_listed == 0
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct AncestorEntry {
pub identity: ProcessIdentity,
pub name: Box<str>,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct ProcessDetail {
pub identity: ProcessIdentity,
pub working_directory: MetricState<Box<str>>,
pub root: MetricState<Box<str>>,
pub open_files: MetricState<u32>,
pub sockets: MetricState<u32>,
pub open_file_list: MetricState<OpenFileList>,
pub ancestry: MetricState<Vec<AncestorEntry>>,
pub children: MetricState<Vec<ProcessIdentity>>,
pub descendants: MetricState<u32>,
pub nice: MetricState<i32>,
pub cgroup: MetricState<Box<str>>,
pub container: MetricState<Box<str>>,
pub collected_at: SystemTime,
}
impl ProcessDetail {
#[must_use]
pub fn pending(identity: ProcessIdentity, collected_at: SystemTime) -> Self {
Self {
identity,
working_directory: MetricState::WarmingUp,
root: MetricState::WarmingUp,
open_files: MetricState::WarmingUp,
sockets: MetricState::WarmingUp,
open_file_list: MetricState::WarmingUp,
ancestry: MetricState::WarmingUp,
children: MetricState::WarmingUp,
descendants: MetricState::WarmingUp,
nice: MetricState::WarmingUp,
cgroup: MetricState::WarmingUp,
container: MetricState::WarmingUp,
collected_at,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ProcessDetailResult {
Loaded(Box<ProcessDetail>),
Vanished(ProcessIdentity),
Reused {
requested: ProcessIdentity,
found: ProcessIdentity,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(command: &str, name: &str) -> ProcessSnapshot {
ProcessSnapshot {
identity: ProcessIdentity::new(31_842, 900_100),
parent_pid: Some(1),
name: name.into(),
command: command.into(),
exe: None,
user: MetricState::Unsupported,
state: ProcessState::Running,
cpu: MetricState::WarmingUp,
memory: ProcessMemory::WARMING_UP,
io: ProcessIo::UNSUPPORTED,
threads: MetricState::Unsupported,
age: MetricState::Unsupported,
started_at: MetricState::Unsupported,
is_kernel_thread: false,
}
}
#[test]
fn notable_states_are_exactly_zombie_and_uninterruptible_sleep() {
assert!(ProcessState::Zombie.is_notable());
assert!(ProcessState::UninterruptibleSleep.is_notable());
for state in [
ProcessState::Running,
ProcessState::Sleeping,
ProcessState::Stopped,
ProcessState::Traced,
ProcessState::Idle,
ProcessState::Dead,
ProcessState::Unknown,
] {
assert!(!state.is_notable(), "{state:?}");
}
}
#[test]
fn state_codes_are_unique_so_the_column_is_unambiguous() {
let states = [
ProcessState::Running,
ProcessState::Sleeping,
ProcessState::UninterruptibleSleep,
ProcessState::Zombie,
ProcessState::Stopped,
ProcessState::Traced,
ProcessState::Idle,
ProcessState::Dead,
ProcessState::Unknown,
];
let mut codes: Vec<char> = states.iter().map(|s| s.code()).collect();
codes.sort_unstable();
codes.dedup();
assert_eq!(codes.len(), states.len());
}
#[test]
fn already_exited_processes_are_not_signalable() {
assert!(!ProcessState::Zombie.is_signalable());
assert!(!ProcessState::Dead.is_signalable());
assert!(ProcessState::Running.is_signalable());
assert!(ProcessState::UninterruptibleSleep.is_signalable());
}
#[test]
fn an_empty_command_falls_back_to_the_process_name() {
let kernel_thread = sample("", "kworker/2:1");
assert_eq!(kernel_thread.command_or_name(), "kworker/2:1");
let normal = sample("cargo build --release", "cargo");
assert_eq!(normal.command_or_name(), "cargo build --release");
}
#[test]
fn redaction_strips_arguments_which_may_contain_secrets() {
let process = sample("psql postgres://user:hunter2@db/prod", "psql");
assert_eq!(process.redacted_command(), "psql");
assert!(!process.redacted_command().contains("hunter2"));
}
#[test]
fn redaction_of_a_bare_program_keeps_the_program() {
assert_eq!(sample("rustc", "rustc").redacted_command(), "rustc");
assert_eq!(sample("", "kworker/2:1").redacted_command(), "kworker/2:1");
}
fn descriptor(
descriptor: i32,
kind: OpenFileKind,
path: MetricState<Box<str>>,
) -> OpenFileEntry {
OpenFileEntry {
descriptor,
kind,
path,
}
}
#[test]
fn only_a_file_descriptor_can_have_a_path_to_refuse() {
assert!(OpenFileKind::File.has_path());
for kind in [
OpenFileKind::Socket,
OpenFileKind::Pipe,
OpenFileKind::EventQueue,
OpenFileKind::SharedMemory,
OpenFileKind::Semaphore,
OpenFileKind::Unknown,
] {
assert!(!kind.has_path(), "{kind:?}");
}
}
#[test]
fn every_descriptor_kind_has_a_distinct_ascii_label() {
let kinds = [
OpenFileKind::File,
OpenFileKind::Socket,
OpenFileKind::Pipe,
OpenFileKind::EventQueue,
OpenFileKind::SharedMemory,
OpenFileKind::Semaphore,
OpenFileKind::Unknown,
];
let mut labels: Vec<&str> = kinds.iter().map(|kind| kind.label()).collect();
for label in &labels {
assert!(label.is_ascii(), "{label} is not strict ASCII");
}
labels.sort_unstable();
labels.dedup();
assert_eq!(labels.len(), kinds.len());
}
#[test]
fn the_listing_cap_is_enforced_by_the_constructor_rather_than_by_its_callers() {
let entries: Vec<OpenFileEntry> = (0..OpenFileList::MAX_LISTED + 50)
.map(|index| {
descriptor(
i32::try_from(index).unwrap_or(i32::MAX),
OpenFileKind::File,
MetricState::Available("/tmp/x".into()),
)
})
.collect();
let list = OpenFileList::listed(entries, OpenFileList::MAX_LISTED + 50);
assert_eq!(list.count(), OpenFileList::MAX_LISTED);
assert_eq!(list.not_listed(), 50);
assert!(!list.is_complete());
}
#[test]
fn a_complete_listing_says_nothing_was_left_out() {
let list = OpenFileList::listed(
vec![descriptor(
3,
OpenFileKind::Socket,
MetricState::Unsupported,
)],
1,
);
assert!(list.is_complete());
assert_eq!(list.not_listed(), 0);
assert_eq!(list.total(), 1);
}
#[test]
fn a_total_below_the_listed_count_cannot_produce_a_negative_remainder() {
let list = OpenFileList::listed(
vec![
descriptor(3, OpenFileKind::File, MetricState::PermissionDenied),
descriptor(4, OpenFileKind::Pipe, MetricState::Unsupported),
],
1,
);
assert_eq!(list.not_listed(), 0);
assert_eq!(list.total(), 2);
}
#[test]
fn an_unreadable_descriptor_path_is_a_state_and_never_an_empty_string() {
let list = OpenFileList::listed(
vec![
descriptor(3, OpenFileKind::File, MetricState::PermissionDenied),
descriptor(7, OpenFileKind::Socket, MetricState::Unsupported),
],
2,
);
for entry in list.entries() {
assert!(entry.path.fresh().is_none_or(|path| !path.is_empty()));
assert!(entry.path.placeholder().is_some() || entry.path.fresh().is_some());
}
}
#[test]
fn a_pending_detail_reports_nothing_as_measured() {
let identity = ProcessIdentity::new(1, 2);
let detail = ProcessDetail::pending(identity, SystemTime::UNIX_EPOCH);
assert_eq!(detail.identity, identity);
assert!(detail.working_directory.is_warming_up());
assert!(detail.open_files.fresh().is_none());
assert!(
detail.open_file_list.is_warming_up(),
"an unread descriptor list is not an empty one"
);
}
}