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:
1 file changed
+144
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user