feat(core): add MemoryPressure snapshot for runtime watchdog

Extend qbz-core's system_capabilities with a parse_meminfo_available_kb
helper plus a small MemoryPressure value object that classifies the
host's current available memory against thresholds derived from
MemoryProfile. The watchdog (src-tauri side, next commit) polls this
to decide when to evict caches.

Thresholds intentionally simple for v1: is_low triggers under 15 %
available, is_critical under 5 %. Both are strict-inequality so the
exact-percent boundaries don't flap.

The pure pressure_from_figures(avail_kb, total_kb) factors out the
math from the /proc read so threshold behavior is tested without
touching the filesystem. Refactors the existing
parse_meminfo_total_kb to share a parse_meminfo_field_kb helper with
the new available-memory parser.

Adds 9 tests covering the parser, the threshold boundaries, the zero-
total divide-safety guard, and the codehd7 issue #331 scenario
(Pi 3B, ~25 MB available out of 938 MB → critical).

cc
This commit is contained in:
vicrodh committed 2026-04-24 19:41:12 -06:00
1 parent b38065a033
commit e8128b813a
1 file changed
+144 -2
+144 -2
View File
@@ -78,9 +78,24 @@ impl MemoryProfile {
/// Parse the `MemTotal:` line out of `/proc/meminfo` content.
/// Returns None if the field is missing or unparseable.
pub fn parse_meminfo_total_kb(content: &str) -> Option<u64> {
parse_meminfo_field_kb(content, "MemTotal:")
}
/// Parse the `MemAvailable:` line out of `/proc/meminfo` content.
/// Returns None if the field is missing or unparseable.
///
/// `MemAvailable` is the kernel's estimate of how much memory can be
/// allocated to a new workload without swapping — i.e. the right
/// metric for memory pressure (more accurate than `MemFree`, which
/// doesn't account for reclaimable page cache).
pub fn parse_meminfo_available_kb(content: &str) -> Option<u64> {
parse_meminfo_field_kb(content, "MemAvailable:")
}
/// Shared parser for `<Field>: <number> kB` style /proc/meminfo lines.
fn parse_meminfo_field_kb(content: &str, field_prefix: &str) -> Option<u64> {
for line in content.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
// Format: "MemTotal: 938196 kB"
if let Some(rest) = line.strip_prefix(field_prefix) {
let kb_str = rest.split_whitespace().next()?;
return kb_str.parse::<u64>().ok();
}
@@ -88,6 +103,47 @@ pub fn parse_meminfo_total_kb(content: &str) -> Option<u64> {
None
}
/// Snapshot of current memory pressure relative to the host's
/// MemoryProfile, used by the runtime memory watchdog to decide
/// whether to evict caches and abort prefetches.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MemoryPressure {
pub mem_available_kb: u64,
pub mem_total_kb: u64,
/// Available memory as a percentage of total (0.0–100.0).
pub available_pct: f64,
/// True when available memory falls below 15 % of total.
pub is_low: bool,
/// True when available memory falls below 5 % of total. The
/// watchdog should drop caches at this point.
pub is_critical: bool,
}
/// Build a pressure snapshot from raw figures.
///
/// Pure for testing; the real entry point is [`read_memory_pressure`].
pub fn pressure_from_figures(mem_available_kb: u64, mem_total_kb: u64) -> MemoryPressure {
let total = mem_total_kb.max(1);
let pct = (mem_available_kb as f64 / total as f64) * 100.0;
MemoryPressure {
mem_available_kb,
mem_total_kb,
available_pct: pct,
is_low: pct < 15.0,
is_critical: pct < 5.0,
}
}
/// Read /proc/meminfo and return a pressure snapshot relative to the
/// detected MemoryProfile. Returns None on platforms without
/// /proc/meminfo or when the file is unreadable.
pub fn read_memory_pressure() -> Option<MemoryPressure> {
let content = std::fs::read_to_string("/proc/meminfo").ok()?;
let mem_available_kb = parse_meminfo_available_kb(&content)?;
let profile = memory_profile();
Some(pressure_from_figures(mem_available_kb, profile.mem_total_kb))
}
/// Pure detection given `/proc/meminfo` content. Falls back to Normal
/// when MemTotal is missing or unparseable so we never accidentally
/// throttle a system whose meminfo we couldn't read.
@@ -221,4 +277,90 @@ MemFree: 250000 kB
assert_eq!(profile.class, MemoryClass::LowMemory);
assert_eq!(profile.mem_total_kb, 938196);
}
#[test]
fn parse_meminfo_available_kb_extracts_value() {
let sample = "\
MemTotal: 938196 kB
MemFree: 120000 kB
MemAvailable: 180000 kB
";
assert_eq!(parse_meminfo_available_kb(sample), Some(180000));
}
#[test]
fn parse_meminfo_available_kb_distinguishes_from_memtotal() {
let sample = "\
MemAvailable: 500000 kB
MemTotal: 4194304 kB
";
assert_eq!(parse_meminfo_available_kb(sample), Some(500000));
assert_eq!(parse_meminfo_total_kb(sample), Some(4194304));
}
#[test]
fn parse_meminfo_available_kb_handles_missing_field() {
let sample = "MemTotal: 938196 kB\nMemFree: 100000 kB\n";
assert_eq!(parse_meminfo_available_kb(sample), None);
}
#[test]
fn pressure_healthy_machine_is_neither_low_nor_critical() {
// 4 GB total, 2 GB available => 50%
let p = pressure_from_figures(2 * 1024 * 1024, 4 * 1024 * 1024);
assert!((p.available_pct - 50.0).abs() < 0.001);
assert!(!p.is_low);
assert!(!p.is_critical);
}
#[test]
fn pressure_at_14pct_is_low_not_critical() {
// Exactly 14 % available — under 15 % low threshold but well
// above the 5 % critical floor.
let total = 1_000_000;
let avail = 140_000;
let p = pressure_from_figures(avail, total);
assert!(p.is_low);
assert!(!p.is_critical);
}
#[test]
fn pressure_at_4pct_is_both_low_and_critical() {
let total = 1_000_000;
let avail = 40_000;
let p = pressure_from_figures(avail, total);
assert!(p.is_low);
assert!(p.is_critical);
}
#[test]
fn pressure_threshold_boundaries_inclusive_above() {
// 15.0 % should not register as low (strict inequality).
let p = pressure_from_figures(150_000, 1_000_000);
assert!(!p.is_low);
// Just under: 14.999 % -> low.
let p = pressure_from_figures(149_990, 1_000_000);
assert!(p.is_low);
// 5.0 % should not register as critical.
let p = pressure_from_figures(50_000, 1_000_000);
assert!(!p.is_critical);
}
#[test]
fn pressure_handles_zero_total_safely() {
// The .max(1) guard prevents divide-by-zero. The resulting pct
// is meaningless but we don't panic.
let p = pressure_from_figures(100, 0);
assert!(p.available_pct.is_finite());
}
#[test]
fn pi3b_critical_pressure_matches_codehd7_scenario() {
// codehd7's reported case (issue #331 comments): Pi 3B with
// ~938 MB total, swap thrash drives MemAvailable below 30 MB.
// The watchdog should treat this as critical.
let p = pressure_from_figures(25 * 1024, 938 * 1024);
assert!(p.is_critical);
}
}