[PATCH proxmox v2 10/25] procfs: add helpers for querying pressure stall information

Lukas Wagner
2026-03-19T09:46:52Z

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 <l.wagner@proxmox.com>
Reviewed-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Tested-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
---

Notes:
Changes since v1:
- fix failing unit test - thanks @Arthur!
- just use `read_line(...)` instead of `read_until('\n', ...)`
followed by an unsafe `std::str::from_utf8_unchecked`-
a criterion benchmark showed that the performance differences are
absolutely negligible (0.3% faster)

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 | 332 ++++++++++++++++++++++++++++
8 files changed, 433 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 62a4af2d..dbd45e2e 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 <support@proxmox.com> 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 <!nocheck>,
+ rustc:native <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-thiserror-2+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+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 <support@proxmox.com>
+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 <https://www.gnu.org/licenses/>.
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 <support@proxmox.com>"
+
+[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..a31ffef4
--- /dev/null
+++ b/proxmox-procfs/src/pressure.rs
@@ -0,0 +1,332 @@
+//! 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<Self, Self::Err> {
+ 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<PressureData, Error> {
+ 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<PressureData, Error> {
+ 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<P: AsRef<Path>>(path: P) -> Result<PressureData, Error> {
+ 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<R: BufRead>(mut reader: R) -> Result<Self, Error> {
+ // 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 = String::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<R: BufRead>(
+ reader: &mut R,
+ buf: &mut String,
+ ) -> 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_line(buf)?;
+
+ Self::read_record(buf)
+ }
+
+ 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<T: FromStr>(s: Option<&str>, prefix: &str) -> Result<T, Error>
+ where
+ <T as FromStr>::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.42).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