From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 4DCF71FF13F for ; Thu, 12 Mar 2026 14:53:48 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 0E42E17DB2; Thu, 12 Mar 2026 14:53:43 +0100 (CET) From: Lukas Wagner To: pdm-devel@lists.proxmox.com Subject: [PATCH proxmox 11/26] procfs: add helpers for querying pressure stall information Date: Thu, 12 Mar 2026 14:52:12 +0100 Message-ID: <20260312135229.420729-12-l.wagner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260312135229.420729-1-l.wagner@proxmox.com> References: <20260312135229.420729-1-l.wagner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1773323523179 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.046 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_SHORT 0.001 Use of a URL Shortener for very short URL RCVD_IN_MSPIKE_H2 0.001 Average reputation (+2) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: RDKCH7HC5CB6UCNE2GNC273U5GEHOOOX X-Message-ID-Hash: RDKCH7HC5CB6UCNE2GNC273U5GEHOOOX X-MailFrom: l.wagner@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This is put into a new crate, proxmox-procfs, since proxmox-sys is already quite large and should be split in the future. The general idea is that the contents of proxmox_sys::linux::procfs should be moved into this new crate (potentially after some API cleanup) at some point. Signed-off-by: Lukas Wagner --- Cargo.toml | 2 + proxmox-procfs/Cargo.toml | 18 ++ proxmox-procfs/debian/changelog | 5 + proxmox-procfs/debian/control | 50 +++++ proxmox-procfs/debian/copyright | 18 ++ proxmox-procfs/debian/debcargo.toml | 7 + proxmox-procfs/src/lib.rs | 1 + proxmox-procfs/src/pressure.rs | 334 ++++++++++++++++++++++++++++ 8 files changed, 435 insertions(+) create mode 100644 proxmox-procfs/Cargo.toml create mode 100644 proxmox-procfs/debian/changelog create mode 100644 proxmox-procfs/debian/control create mode 100644 proxmox-procfs/debian/copyright create mode 100644 proxmox-procfs/debian/debcargo.toml create mode 100644 proxmox-procfs/src/lib.rs create mode 100644 proxmox-procfs/src/pressure.rs diff --git a/Cargo.toml b/Cargo.toml index 8f3886bd..47847048 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ members = [ "proxmox-openid", "proxmox-parallel-handler", "proxmox-pgp", + "proxmox-procfs", "proxmox-product-config", "proxmox-rate-limiter", "proxmox-resource-scheduling", @@ -171,6 +172,7 @@ proxmox-login = { version = "1.0.0", path = "proxmox-login" } proxmox-network-types = { version = "1.0.0", path = "proxmox-network-types" } proxmox-parallel-handler = { version = "1.0.0", path = "proxmox-parallel-handler" } proxmox-pgp = { version = "1.0.0", path = "proxmox-pgp" } +proxmox-procfs = { version = "0.1.0", path = "proxmox-procfs" } proxmox-product-config = { version = "1.0.0", path = "proxmox-product-config" } proxmox-config-digest = { version = "1.0.0", path = "proxmox-config-digest" } proxmox-rate-limiter = { version = "1.0.0", path = "proxmox-rate-limiter" } diff --git a/proxmox-procfs/Cargo.toml b/proxmox-procfs/Cargo.toml new file mode 100644 index 00000000..3c0fe1dd --- /dev/null +++ b/proxmox-procfs/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "proxmox-procfs" +description = "helpers for reading system information from /proc" +version = "0.1.0" + +authors.workspace = true +edition.workspace = true +exclude.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde = { workspace = true, optional = true, features = ["derive"] } +thiserror.workspace = true + +[features] +serde = ["dep:serde"] diff --git a/proxmox-procfs/debian/changelog b/proxmox-procfs/debian/changelog new file mode 100644 index 00000000..9eee8f0b --- /dev/null +++ b/proxmox-procfs/debian/changelog @@ -0,0 +1,5 @@ +rust-proxmox-procfs (0.1.0-1) unstable; urgency=medium + + * initial version + + -- Proxmox Support Team Thu, 26 Feb 2026 15:54:07 +0100 diff --git a/proxmox-procfs/debian/control b/proxmox-procfs/debian/control new file mode 100644 index 00000000..6c4b4798 --- /dev/null +++ b/proxmox-procfs/debian/control @@ -0,0 +1,50 @@ +Source: rust-proxmox-procfs +Section: rust +Priority: optional +Build-Depends: debhelper-compat (= 13), + dh-sequence-cargo +Build-Depends-Arch: cargo:native , + rustc:native , + libstd-rust-dev , + librust-thiserror-2+default-dev +Maintainer: Proxmox Support Team +Standards-Version: 4.7.2 +Vcs-Git: git://git.proxmox.com/git/proxmox.git +Vcs-Browser: https://git.proxmox.com/?p=proxmox.git +Homepage: https://proxmox.com +X-Cargo-Crate: proxmox-procfs + +Package: librust-proxmox-procfs-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-thiserror-2+default-dev +Suggests: + librust-proxmox-procfs+serde-dev (= ${binary:Version}) +Provides: + librust-proxmox-procfs+default-dev (= ${binary:Version}), + librust-proxmox-procfs-0-dev (= ${binary:Version}), + librust-proxmox-procfs-0+default-dev (= ${binary:Version}), + librust-proxmox-procfs-0.1-dev (= ${binary:Version}), + librust-proxmox-procfs-0.1+default-dev (= ${binary:Version}), + librust-proxmox-procfs-0.1.0-dev (= ${binary:Version}), + librust-proxmox-procfs-0.1.0+default-dev (= ${binary:Version}) +Description: Helpers for reading system information from /proc - Rust source code + Source code for Debianized Rust crate "proxmox-procfs" + +Package: librust-proxmox-procfs+serde-dev +Architecture: any +Multi-Arch: same +Depends: + ${misc:Depends}, + librust-proxmox-procfs-dev (= ${binary:Version}), + librust-serde-1+default-dev, + librust-serde-1+derive-dev +Provides: + librust-proxmox-procfs-0+serde-dev (= ${binary:Version}), + librust-proxmox-procfs-0.1+serde-dev (= ${binary:Version}), + librust-proxmox-procfs-0.1.0+serde-dev (= ${binary:Version}) +Description: Helpers for reading system information from /proc - feature "serde" + This metapackage enables feature "serde" for the Rust proxmox-procfs crate, by + pulling in any additional dependencies needed by that feature. diff --git a/proxmox-procfs/debian/copyright b/proxmox-procfs/debian/copyright new file mode 100644 index 00000000..01138fa0 --- /dev/null +++ b/proxmox-procfs/debian/copyright @@ -0,0 +1,18 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: + * +Copyright: 2026 Proxmox Server Solutions GmbH +License: AGPL-3.0-or-later + This program is free software: you can redistribute it and/or modify it under + the terms of the GNU Affero General Public License as published by the Free + Software Foundation, either version 3 of the License, or (at your option) any + later version. + . + This program is distributed in the hope that it will be useful, but WITHOUT + ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + details. + . + You should have received a copy of the GNU Affero General Public License along + with this program. If not, see . diff --git a/proxmox-procfs/debian/debcargo.toml b/proxmox-procfs/debian/debcargo.toml new file mode 100644 index 00000000..b7864cdb --- /dev/null +++ b/proxmox-procfs/debian/debcargo.toml @@ -0,0 +1,7 @@ +overlay = "." +crate_src_path = ".." +maintainer = "Proxmox Support Team " + +[source] +vcs_git = "git://git.proxmox.com/git/proxmox.git" +vcs_browser = "https://git.proxmox.com/?p=proxmox.git" diff --git a/proxmox-procfs/src/lib.rs b/proxmox-procfs/src/lib.rs new file mode 100644 index 00000000..a5670f34 --- /dev/null +++ b/proxmox-procfs/src/lib.rs @@ -0,0 +1 @@ +pub mod pressure; diff --git a/proxmox-procfs/src/pressure.rs b/proxmox-procfs/src/pressure.rs new file mode 100644 index 00000000..452b3892 --- /dev/null +++ b/proxmox-procfs/src/pressure.rs @@ -0,0 +1,334 @@ +//! Utilities for reading [Pressure Stall Information][psi] for the system or cgroups. +//! +//! To read pressure data, refer to [`PressureData::read_system`] and [`PressureData::read_cgroup`]. +//! [`PressureData::read_file`] can be use for lower-level access, proving the path to the +//! pressure file directly. +//! +//! # Examples +//! +//! Read system-wide CPU pressure: +//! +//! ```no_run +//! use proxmox_procfs::pressure::{PressureData, Resource}; +//! +//! let cpu = PressureData::read_system(Resource::Cpu).unwrap(); +//! println!("CPU some avg10: {:.2}%", cpu.some.average_10); +//! ``` +//! +//! Read cgroup-level memory pressure: +//! +//! ```no_run +//! use proxmox_procfs::pressure::{PressureData, Resource}; +//! +//! let mem = PressureData::read_cgroup("system.slice", Resource::Memory).unwrap(); +//! println!("mem some avg10: {:.2}%", mem.some.average_10); +//! ``` +//! +//! [psi]: https://docs.kernel.org/accounting/psi.html +//! + +use std::ffi::OsStr; +use std::fs::File; +use std::io::{BufRead, BufReader, ErrorKind}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +#[derive(thiserror::Error, Debug)] +/// Error type for pressure-related errors. +pub enum Error { + /// General IO error when reading the pressure stall information file. + #[error("could not read pressure stall info file: {0}")] + Io(#[from] std::io::Error), + + /// Pressure stall info file does not exist. + /// This is a distinct error variant so that the caller can differentiate between a + /// disappeared cgroup (e.g. if the guest was stopped) and other kinds of IO errors + #[error("pressure stall info file does not exist: {0}")] + NotFound(PathBuf), + + /// The contents of the pressure stall file are unexpected. Should not really happen, + /// hopefully. + #[error("unexpected pressure stall file format: {0}")] + InvalidFormat(String), +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +/// Pressure stall information data. +pub struct PressureData { + /// At least some tasks were stalled on a given resource. + pub some: PressureRecord, + /// All non-idle tasks were stalled on a given resource. + /// + /// Note: When querying CPU pressure stall information on a system level, + /// all members in `full` contain 0 (see [here]). + /// + /// [here]: https://docs.kernel.org/accounting/psi.html#pressure-interface + pub full: PressureRecord, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))] +#[derive(Clone, Debug)] +/// Individual record corresponding to one line from a pressure stall information file. +pub struct PressureRecord { + /// Average pressure stall ratio over the last 10 seconds. + pub average_10: f64, + /// Average pressure stall ratio over the last 60 seconds. + pub average_60: f64, + /// Average pressure stall ratio over the last 300 seconds. + pub average_300: f64, + /// Total stall time in microseconds. + pub total: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PressureRecordKind { + Full, + Some, +} + +impl FromStr for PressureRecordKind { + type Err = Error; + + fn from_str(s: &str) -> Result { + match s { + "some" => Ok(Self::Some), + "full" => Ok(Self::Full), + _ => Err(Error::InvalidFormat(format!("invalid pressure kind '{s}'"))), + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Copy, Debug, PartialEq)] +/// Which pressure stall information to query. +pub enum Resource { + /// Query CPU pressure stall information. + Cpu, + /// Query memory pressure stall information. + Memory, + /// Query IO pressure stall information. + Io, +} + +impl Resource { + fn into_proc_path(self) -> &'static Path { + match self { + Resource::Cpu => Path::new("/proc/pressure/cpu"), + Resource::Memory => Path::new("/proc/pressure/memory"), + Resource::Io => Path::new("/proc/pressure/io"), + } + } + + fn into_cgroup_path_component(self) -> &'static OsStr { + match self { + Resource::Cpu => OsStr::new("cpu.pressure"), + Resource::Memory => OsStr::new("memory.pressure"), + Resource::Io => OsStr::new("io.pressure"), + } + } +} + +impl PressureData { + /// Read pressure stall information for the entire host from `/proc/pressure/*`. + /// + /// ```no_run + /// use proxmox_procfs::pressure::*; + /// + /// let pressure = PressureData::read_system(Resource::Cpu).unwrap(); + /// println!("{}", pressure.some.average_10); + /// + ///``` + pub fn read_system(what: Resource) -> Result { + Self::read_file(what.into_proc_path()) + } + + /// Read pressure stall information for a cgroup. + /// + /// The `cgroup` parameter will be directly used to assemble the path for the PSI file. For + /// instance, if set to `lxc/101`, then `/sys/fs/cgroup/lxc/101/cpu.pressure` will be read. + /// + /// Note: This functions will return [`Error::NotFound`] in case the pressure file does not exist, + /// usually meaning that the cgroup does not exist (any more). This distinct error variant allows + /// the caller to differentiate this case from other kinds of IO errors. + /// + /// ```no_run + /// use proxmox_procfs::pressure::{PressureData, Resource}; + /// + /// let pressure = PressureData::read_cgroup("qemu.slice/100.scope", Resource::Cpu).unwrap(); + /// println!("{}", pressure.some.average_10); + /// + /// let pressure = PressureData::read_cgroup("lxc/101", Resource::Io).unwrap(); + /// println!("{}", pressure.some.average_10); + /// + /// ``` + pub fn read_cgroup(cgroup: &str, resource: Resource) -> Result { + let path = Path::new("/sys/fs/cgroup/") + .join(cgroup) + .join(resource.into_cgroup_path_component()); + + Self::read_file(&path) + } + + /// Read pressure stall information from a provided path. + /// + /// ```no_run + /// use proxmox_procfs::pressure::{PressureData, Resource}; + /// + /// let pressure = PressureData::read_file("/proc/pressure/io").unwrap(); + /// println!("{}", pressure.some.average_10); + /// + /// ``` + pub fn read_file>(path: P) -> Result { + let file = match File::open(path.as_ref()) { + Ok(file) => file, + Err(err) if err.kind() == ErrorKind::NotFound => { + return Err(Error::NotFound(path.as_ref().into())) + } + Err(err) => return Err(Error::Io(err)), + }; + + let reader = BufReader::new(file); + + PressureData::read(reader) + } + + fn read(mut reader: R) -> Result { + // Depending on the length of the 'total' field, one line in the pressure output is around + // 60 characters long. Pre-alloc roughly double the size to pretty much eliminate the need + // for ever having to resize the vec. + let mut buf = Vec::with_capacity(128); + let (some_kind, some) = Self::read_pressure_line(&mut reader, &mut buf)?; + buf.clear(); + + let (full_kind, full) = Self::read_pressure_line(&mut reader, &mut buf)?; + + if some_kind != PressureRecordKind::Some || full_kind != PressureRecordKind::Full { + return Err(Error::InvalidFormat( + "unexpected pressure record structure".into(), + )); + } + + Ok(PressureData { some, full }) + } + + fn read_pressure_line( + reader: &mut R, + buf: &mut Vec, + ) -> Result<(PressureRecordKind, PressureRecord), Error> { + // The buffer should be empty. It is only passed by the caller as a performance + // optimization + debug_assert!(buf.is_empty()); + + reader.read_until(b'\n', buf)?; + // SAFETY: In production, `reader` is expected to read from + // procfs/sysfs pressure files, which only ever should return ASCII strings. + let line = unsafe { std::str::from_utf8_unchecked(buf) }; + + Self::read_record(line) + } + + fn read_record(line: &str) -> Result<(PressureRecordKind, PressureRecord), Error> { + let mut iter = line.split_ascii_whitespace(); + + let kind = iter + .next() + .ok_or_else(|| Error::InvalidFormat("missing pressure kind field".into())) + .and_then(PressureRecordKind::from_str)?; + + let average_10 = Self::parse_field(iter.next(), "avg10=")?; + let average_60 = Self::parse_field(iter.next(), "avg60=")?; + let average_300 = Self::parse_field(iter.next(), "avg300=")?; + let total = Self::parse_field(iter.next(), "total=")?; + + Ok(( + kind, + PressureRecord { + average_10, + average_60, + average_300, + total, + }, + )) + } + + fn parse_field(s: Option<&str>, prefix: &str) -> Result + where + ::Err: std::fmt::Display, + { + s.and_then(|s| s.strip_prefix(prefix)) + .ok_or_else(|| { + Error::InvalidFormat(format!("expected '{prefix}' prefix for next field")) + })? + .parse() + .map_err(|err| Error::InvalidFormat(format!("failed to parse '{prefix}': {err}"))) + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_read_psi() { + let s = "some avg10=1.42 avg60=2.09 avg300=1.42 total=40979658 +full avg10=0.08 avg60=0.18 avg300=0.13 total=22865313 +"; + + let mut reader = std::io::Cursor::new(s); + let stats = PressureData::read(&mut reader).unwrap(); + + assert_eq!(stats.some.total, 40979658); + assert!((stats.some.average_10 - 1.82).abs() < f64::EPSILON); + assert!((stats.some.average_60 - 2.09).abs() < f64::EPSILON); + assert!((stats.some.average_300 - 1.42).abs() < f64::EPSILON); + + assert_eq!(stats.full.total, 22865313); + assert!((stats.full.average_10 - 0.08).abs() < f64::EPSILON); + assert!((stats.full.average_60 - 0.18).abs() < f64::EPSILON); + assert!((stats.full.average_300 - 0.13).abs() < f64::EPSILON); + } + + #[test] + fn test_read_error() { + let s = "invalid avg10=1.42 avg60=2.09 avg300=1.42 total=40979658 +full avg10=0.08 avg60=0.18 avg300=0.13 total=22865313 +"; + + let mut reader = std::io::Cursor::new(s); + assert!(PressureData::read(&mut reader).is_err()); + } + + #[test] + fn test_invalid_field() { + let s = "some foo=1.42 avg60=2.09 avg300=1.42 total=40979658 +full avg10=0.08 avg60=0.18 avg300=0.13 total=22865313 +"; + + let mut reader = std::io::Cursor::new(s); + assert!(PressureData::read(&mut reader).is_err()); + } + + #[test] + fn test_read_system_pressure() { + for resource in [Resource::Io, Resource::Memory, Resource::Cpu] { + PressureData::read_system(resource).unwrap(); + } + } + + #[test] + fn test_read_cgroup_pressure() { + for resource in [Resource::Io, Resource::Memory, Resource::Cpu] { + PressureData::read_cgroup("system.slice", resource).unwrap(); + } + } + + #[test] + fn test_read_file_notfound() { + assert!(matches!( + PressureData::read_file("/invalid"), + Err(Error::NotFound(_)) + )) + } +} -- 2.47.3