release/1.2.0 #2

Merged
nvrl merged 13 commits from release/1.2.0 into main 2026-03-01 00:32:04 +01:00
21 changed files with 480 additions and 110 deletions
Showing only changes of commit 667d94af7a - Show all commits

20
Cargo.lock generated
View File

@@ -530,6 +530,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
"tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"toml", "toml",
"tracing", "tracing",
@@ -595,6 +596,12 @@ dependencies = [
"regex", "regex",
] ]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]] [[package]]
name = "filedescriptor" name = "filedescriptor"
version = "0.8.3" version = "0.8.3"
@@ -1705,6 +1712,19 @@ dependencies = [
"windows", "windows",
] ]
[[package]]
name = "tempfile"
version = "3.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "terminal_size" name = "terminal_size"
version = "0.4.3" version = "0.4.3"

View File

@@ -31,3 +31,6 @@ num_cpus = "1.17"
toml = "1.0.3" toml = "1.0.3"
regex = "1.12.3" regex = "1.12.3"
which = "8.0.0" which = "8.0.0"
[dev-dependencies]
tempfile = "3"

View File

@@ -141,6 +141,13 @@ ryzenadj = "ryzenadj"
# env health verification # env health verification
[benchmarking]
idle_duration_s = 10
stress_duration_min_s = 15
stress_duration_max_s = 45
cool_down_s = 5
power_steps_watts = [15.0, 20.0, 25.0, 30.0, 35.0]
[[preflight_checks]] [[preflight_checks]]
name = "MSR Write Access" name = "MSR Write Access"
check_cmd = "grep -q 'msr.allow_writes=on' /proc/cmdline" check_cmd = "grep -q 'msr.allow_writes=on' /proc/cmdline"

View File

@@ -4,41 +4,57 @@ use anyhow::Result;
pub struct I8kmonConfig { pub struct I8kmonConfig {
pub t_ambient: f32, pub t_ambient: f32,
pub t_max_fan: f32, pub t_max_fan: f32,
pub thermal_resistance_kw: f32,
} }
pub struct I8kmonTranslator; pub struct I8kmonTranslator;
impl I8kmonTranslator { impl I8kmonTranslator {
pub fn generate_conf(config: &I8kmonConfig) -> String { pub fn generate_conf(config: &I8kmonConfig) -> String {
// Higher resistance means we need to start fans sooner.
// If R_theta is 2.5 K/W, it's quite high for a laptop.
// We'll scale the 'low' threshold based on R_theta.
let aggression_factor = (config.thermal_resistance_kw / 1.5).clamp(0.8, 1.5);
let t_off = config.t_ambient + 5.0; let t_off = config.t_ambient + 5.0;
let t_low_on = config.t_ambient + 12.0; let t_low_on = config.t_ambient + (10.0 / aggression_factor);
let t_low_off = config.t_ambient + 10.0; let t_low_off = t_low_on - 2.0;
let t_high_on = config.t_max_fan; let t_high_on = config.t_max_fan;
let t_high_off = config.t_max_fan - 5.0; let t_high_off = t_high_on - 5.0;
let t_low_trigger = (config.t_max_fan - 15.0).max(t_low_on + 2.0);
let t_mid_on = (t_low_on + t_high_on) / 2.0;
let t_mid_off = t_mid_on - 3.0;
format!( format!(
r#"# Generated by ember-tune Optimizer r#"# Generated by ember-tune Optimizer
# Grounded in physical thermal resistance # Grounded in physical thermal resistance (Rθ = {r_theta:.3} K/W)
set config(gen_shadow) 1 set config(gen_shadow) 1
set config(i8k_ignore_dmi) 1 set config(i8k_ignore_dmi) 1
# Fan states: {{state_low state_high temp_on temp_off}} # Fan states: {{state_low state_high temp_on temp_off}}
# 0: Off
set config(0) {{0 0 {t_low_on:.0} {t_off:.0}}} set config(0) {{0 0 {t_low_on:.0} {t_off:.0}}}
set config(1) {{1 1 {t_low_trigger:.0} {t_low_off:.0}}} # 1: Low
set config(2) {{2 2 {t_high_on:.0} {t_high_off:.0}}} set config(1) {{1 1 {t_mid_on:.0} {t_low_off:.0}}}
# 2: High
set config(2) {{2 2 {t_high_on:.0} {t_mid_off:.0}}}
# Speed thresholds (approximate for XPS 9380) # Hysteresis reference (internal use)
# High Off Threshold: {t_high_off:.0}
# Speed thresholds
set config(speed_low) 2500 set config(speed_low) 2500
set config(speed_high) 4500 set config(speed_high) 4500
"#, "#,
r_theta = config.thermal_resistance_kw,
t_low_on = t_low_on, t_low_on = t_low_on,
t_off = t_off, t_off = t_off,
t_low_trigger = t_low_trigger, t_mid_on = t_mid_on,
t_low_off = t_low_off, t_low_off = t_low_off,
t_high_on = t_high_on, t_high_on = t_high_on,
t_high_off = t_high_off t_mid_off = t_mid_off
) )
} }

8
src/lib.rs Normal file
View File

@@ -0,0 +1,8 @@
pub mod mediator;
pub mod sal;
pub mod load;
pub mod orchestrator;
pub mod ui;
pub mod engine;
pub mod cli;
pub mod sys;

View File

@@ -1,12 +1,4 @@
mod mediator; use miette::{Result, IntoDiagnostic, Diagnostic, Report};
mod sal;
mod load;
mod orchestrator;
mod ui;
mod engine;
mod cli;
use miette::{Result, IntoDiagnostic, Diagnostic, Report, Context};
use thiserror::Error; use thiserror::Error;
use std::sync::mpsc; use std::sync::mpsc;
use std::thread; use std::thread;
@@ -25,16 +17,16 @@ use crossterm::{
}; };
use ratatui::{backend::CrosstermBackend, Terminal}; use ratatui::{backend::CrosstermBackend, Terminal};
use cli::Cli; use ember_tune_rs::cli::Cli;
use mediator::{TelemetryState, UiCommand, BenchmarkPhase}; use ember_tune_rs::mediator::{TelemetryState, UiCommand, BenchmarkPhase};
use sal::traits::{AuditError, PlatformSal}; use ember_tune_rs::sal::traits::{AuditError, PlatformSal};
use sal::mock::MockSal; use ember_tune_rs::sal::mock::MockSal;
use sal::heuristic::engine::HeuristicEngine; use ember_tune_rs::sal::heuristic::engine::HeuristicEngine;
use sal::heuristic::discovery::SystemFactSheet; use ember_tune_rs::sal::heuristic::discovery::SystemFactSheet;
use load::{StressNg}; use ember_tune_rs::load::{StressNg};
use orchestrator::BenchmarkOrchestrator; use ember_tune_rs::orchestrator::BenchmarkOrchestrator;
use ui::dashboard::{draw_dashboard, DashboardState}; use ember_tune_rs::ui::dashboard::{draw_dashboard, DashboardState};
use engine::OptimizationResult; use ember_tune_rs::engine::OptimizationResult;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
#[derive(Error, Diagnostic, Debug)] #[derive(Error, Diagnostic, Debug)]
@@ -109,11 +101,13 @@ fn main() -> Result<()> {
info!("ember-tune starting with args: {:?}", args); info!("ember-tune starting with args: {:?}", args);
let ctx = ember_tune_rs::sal::traits::EnvironmentCtx::production();
// 2. Platform Detection & Audit // 2. Platform Detection & Audit
let (sal_box, facts): (Box<dyn PlatformSal>, SystemFactSheet) = if args.mock { let (sal_box, facts): (Box<dyn PlatformSal>, SystemFactSheet) = if args.mock {
(Box::new(MockSal::new()), SystemFactSheet::default()) (Box::new(MockSal::new()), SystemFactSheet::default())
} else { } else {
HeuristicEngine::detect_and_build()? HeuristicEngine::detect_and_build(ctx)?
}; };
let sal: Arc<dyn PlatformSal> = sal_box.into(); let sal: Arc<dyn PlatformSal> = sal_box.into();

View File

@@ -8,7 +8,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use crate::sal::traits::{PlatformSal, AuditStep, SafetyStatus}; use crate::sal::traits::{PlatformSal, SafetyStatus};
use crate::sal::heuristic::discovery::SystemFactSheet; use crate::sal::heuristic::discovery::SystemFactSheet;
use crate::load::Workload; use crate::load::Workload;
use crate::mediator::{TelemetryState, UiCommand, BenchmarkPhase}; use crate::mediator::{TelemetryState, UiCommand, BenchmarkPhase};
@@ -94,6 +94,8 @@ impl BenchmarkOrchestrator {
} }
fn execute_benchmark(&mut self) -> Result<OptimizationResult> { fn execute_benchmark(&mut self) -> Result<OptimizationResult> {
let bench_cfg = self.facts.bench_config.clone().context("Benchmarking config missing in facts")?;
// Phase 1: Audit & Baseline // Phase 1: Audit & Baseline
self.phase = BenchmarkPhase::Auditing; self.phase = BenchmarkPhase::Auditing;
for step in self.sal.audit() { for step in self.sal.audit() {
@@ -107,13 +109,13 @@ impl BenchmarkOrchestrator {
// Baseline (Idle Calibration) // Baseline (Idle Calibration)
self.phase = BenchmarkPhase::IdleCalibration; self.phase = BenchmarkPhase::IdleCalibration;
self.log("Phase 1: Recording Idle Baseline (10s)...")?; self.log(&format!("Phase 1: Recording Idle Baseline ({}s)...", bench_cfg.idle_duration_s))?;
self.sal.set_fan_mode("auto")?; // Use auto for idle self.sal.set_fan_mode("auto")?; // Use auto for idle
let mut idle_temps = Vec::new(); let mut idle_temps = Vec::new();
let start = Instant::now(); let start = Instant::now();
let mut tick = 0; let mut tick = 0;
while start.elapsed() < Duration::from_secs(10) { while start.elapsed() < Duration::from_secs(bench_cfg.idle_duration_s) {
self.check_abort()?; self.check_abort()?;
self.send_telemetry(tick)?; self.send_telemetry(tick)?;
idle_temps.push(self.sal.get_temp().unwrap_or(0.0)); idle_temps.push(self.sal.get_temp().unwrap_or(0.0));
@@ -128,19 +130,19 @@ impl BenchmarkOrchestrator {
self.log("Phase 2: Starting Synthetic Stress Matrix.")?; self.log("Phase 2: Starting Synthetic Stress Matrix.")?;
self.sal.set_fan_mode("max")?; // Lock fans for consistent resistance self.sal.set_fan_mode("max")?; // Lock fans for consistent resistance
let power_steps = [15.0, 20.0, 25.0, 30.0, 35.0]; let steps = bench_cfg.power_steps_watts.clone();
for &pl in &power_steps { for &pl in &steps {
self.log(&format!("Testing PL1 = {:.0}W...", pl))?; self.log(&format!("Testing PL1 = {:.0}W...", pl))?;
self.sal.set_sustained_power_limit(pl)?; self.sal.set_sustained_power_limit(pl)?;
self.sal.set_burst_power_limit(pl + 5.0)?; self.sal.set_burst_power_limit(pl + 5.0)?;
self.workload.start(num_cpus::get(), 100)?; self.workload.start(num_cpus::get(), 100)?;
// Wait for equilibrium: Hybrid approach (15s min, 45s max) // Wait for equilibrium
let step_start = Instant::now(); let step_start = Instant::now();
let mut step_temps = VecDeque::with_capacity(30); // Last 15s @ 500ms let mut step_temps = VecDeque::with_capacity(30);
while step_start.elapsed() < Duration::from_secs(45) { while step_start.elapsed() < Duration::from_secs(bench_cfg.stress_duration_max_s) {
self.check_abort()?; self.check_abort()?;
let t = self.sal.get_temp().unwrap_or(0.0); let t = self.sal.get_temp().unwrap_or(0.0);
@@ -151,7 +153,7 @@ impl BenchmarkOrchestrator {
tick += 1; tick += 1;
// Check for stability: Range < 0.5C over last 5s (10 ticks) // Check for stability: Range < 0.5C over last 5s (10 ticks)
if step_start.elapsed() > Duration::from_secs(15) && step_temps.len() == 10 { if step_start.elapsed() > Duration::from_secs(bench_cfg.stress_duration_min_s) && step_temps.len() == 10 {
let min = step_temps.iter().fold(f32::MAX, |a, &b| a.min(b)); let min = step_temps.iter().fold(f32::MAX, |a, &b| a.min(b));
let max = step_temps.iter().fold(f32::MIN, |a, &b| a.max(b)); let max = step_temps.iter().fold(f32::MIN, |a, &b| a.max(b));
if (max - min) < 0.5 { if (max - min) < 0.5 {
@@ -179,8 +181,8 @@ impl BenchmarkOrchestrator {
}); });
self.workload.stop()?; self.workload.stop()?;
self.log(" Step complete. Cooling down for 5s...")?; self.log(&format!(" Step complete. Cooling down for {}s...", bench_cfg.cool_down_s))?;
thread::sleep(Duration::from_secs(5)); thread::sleep(Duration::from_secs(bench_cfg.cool_down_s));
} }
// Phase 4: Physical Modeling // Phase 4: Physical Modeling
@@ -216,6 +218,7 @@ impl BenchmarkOrchestrator {
let i8k_config = crate::engine::formatters::i8kmon::I8kmonConfig { let i8k_config = crate::engine::formatters::i8kmon::I8kmonConfig {
t_ambient: self.profile.ambient_temp, t_ambient: self.profile.ambient_temp,
t_max_fan: res.max_temp_c - 5.0, t_max_fan: res.max_temp_c - 5.0,
thermal_resistance_kw: res.thermal_resistance_kw,
}; };
crate::engine::formatters::i8kmon::I8kmonTranslator::save(i8k_path, &i8k_config)?; crate::engine::formatters::i8kmon::I8kmonTranslator::save(i8k_path, &i8k_config)?;
self.log(&format!("✓ Saved '{}'.", i8k_path.display()))?; self.log(&format!("✓ Saved '{}'.", i8k_path.display()))?;
@@ -229,6 +232,7 @@ impl BenchmarkOrchestrator {
let abort = self.emergency_abort.clone(); let abort = self.emergency_abort.clone();
let reason_store = self.emergency_reason.clone(); let reason_store = self.emergency_reason.clone();
let sal = self.sal.clone(); let sal = self.sal.clone();
let tx = self.telemetry_tx.clone();
thread::spawn(move || { thread::spawn(move || {
while !abort.load(Ordering::SeqCst) { while !abort.load(Ordering::SeqCst) {
@@ -239,7 +243,30 @@ impl BenchmarkOrchestrator {
abort.store(true, Ordering::SeqCst); abort.store(true, Ordering::SeqCst);
break; break;
} }
Ok(SafetyStatus::Warning(_msg)) | Ok(SafetyStatus::Critical(_msg)) => {} Ok(SafetyStatus::Warning(msg)) | Ok(SafetyStatus::Critical(msg)) => {
let state = TelemetryState {
cpu_model: String::new(),
total_ram_gb: 0,
tick: 0,
cpu_temp: 0.0,
power_w: 0.0,
current_freq: 0.0,
fans: Vec::new(),
governor: String::new(),
pl1_limit: 0.0,
pl2_limit: 0.0,
fan_tier: String::new(),
phase: BenchmarkPhase::StressTesting,
history_watts: Vec::new(),
history_temp: Vec::new(),
history_mhz: Vec::new(),
log_event: Some(format!("WATCHDOG: {}", msg)),
metadata: std::collections::HashMap::new(),
is_emergency: false,
emergency_reason: None,
};
let _ = tx.send(state);
}
Ok(SafetyStatus::Nominal) => {} Ok(SafetyStatus::Nominal) => {}
Err(e) => { Err(e) => {
*reason_store.lock().unwrap() = Some(format!("Watchdog Sensor Failure: {}", e)); *reason_store.lock().unwrap() = Some(format!("Watchdog Sensor Failure: {}", e));

View File

@@ -1,14 +1,14 @@
use super::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog, AuditError, AuditStep, SafetyStatus}; use super::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog, AuditError, AuditStep, SafetyStatus, EnvironmentCtx};
use anyhow::{Result, Context, anyhow}; use anyhow::{Result, Context, anyhow};
use std::fs; use std::fs;
use std::path::{PathBuf}; use std::path::{PathBuf};
use std::process::Command;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::sync::Mutex; use std::sync::Mutex;
use tracing::{debug}; use tracing::{debug};
use crate::sal::heuristic::discovery::SystemFactSheet; use crate::sal::heuristic::discovery::SystemFactSheet;
pub struct DellXps9380Sal { pub struct DellXps9380Sal {
ctx: EnvironmentCtx,
fact_sheet: SystemFactSheet, fact_sheet: SystemFactSheet,
temp_path: PathBuf, temp_path: PathBuf,
pwr_path: PathBuf, pwr_path: PathBuf,
@@ -25,15 +25,18 @@ pub struct DellXps9380Sal {
} }
impl DellXps9380Sal { impl DellXps9380Sal {
pub fn init(facts: SystemFactSheet) -> Result<Self> { pub fn init(ctx: EnvironmentCtx, facts: SystemFactSheet) -> Result<Self> {
let temp_path = facts.temp_path.clone().context("Dell SAL requires temperature sensor")?; let temp_path = facts.temp_path.clone().context("Dell SAL requires temperature sensor")?;
let pwr_base = facts.rapl_paths.first().cloned().context("Dell SAL requires RAPL interface")?; let pwr_base = facts.rapl_paths.first().cloned().context("Dell SAL requires RAPL interface")?;
let fan_paths = facts.fan_paths.clone(); let fan_paths = facts.fan_paths.clone();
let freq_path = PathBuf::from("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq"); let freq_path = ctx.sysfs_base.join("sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq");
let msr_path = ctx.sysfs_base.join("dev/cpu/0/msr");
let msr_file = fs::OpenOptions::new().read(true).write(true).open("/dev/cpu/0/msr") let msr_file = fs::OpenOptions::new().read(true).write(true).open(&msr_path)
.context("Failed to open /dev/cpu/0/msr. Is the 'msr' module loaded?")?; .with_context(|| format!("Failed to open {:?}. Is the 'msr' module loaded?", msr_path))?;
let initial_energy = fs::read_to_string(pwr_base.join("energy_uj")).unwrap_or_default().trim().parse().unwrap_or(0);
Ok(Self { Ok(Self {
temp_path, temp_path,
@@ -47,8 +50,9 @@ impl DellXps9380Sal {
last_fans: Mutex::new(Vec::new()), last_fans: Mutex::new(Vec::new()),
suppressed_services: Mutex::new(Vec::new()), suppressed_services: Mutex::new(Vec::new()),
msr_file: Mutex::new(msr_file), msr_file: Mutex::new(msr_file),
last_energy: Mutex::new((0, Instant::now())), last_energy: Mutex::new((initial_energy, Instant::now())),
fact_sheet: facts, fact_sheet: facts,
ctx,
}) })
} }
@@ -78,16 +82,17 @@ impl PreflightAuditor for DellXps9380Sal {
let modules = ["dell_smm_hwmon", "msr", "intel_rapl_msr"]; let modules = ["dell_smm_hwmon", "msr", "intel_rapl_msr"];
for mod_name in modules { for mod_name in modules {
let path = format!("/sys/module/{}", mod_name); let path = self.ctx.sysfs_base.join(format!("sys/module/{}", mod_name));
steps.push(AuditStep { steps.push(AuditStep {
description: format!("Kernel Module: {}", mod_name), description: format!("Kernel Module: {}", mod_name),
outcome: if PathBuf::from(path).exists() { Ok(()) } else { outcome: if path.exists() { Ok(()) } else {
Err(AuditError::ToolMissing(format!("Module '{}' not loaded.", mod_name))) Err(AuditError::ToolMissing(format!("Module '{}' not loaded.", mod_name)))
} }
}); });
} }
let cmdline = fs::read_to_string("/proc/cmdline").unwrap_or_default(); let cmdline_path = self.ctx.sysfs_base.join("proc/cmdline");
let cmdline = fs::read_to_string(cmdline_path).unwrap_or_default();
let params = [ let params = [
("dell_smm_hwmon.ignore_dmi=1", "dell_smm_hwmon.ignore_dmi=1"), ("dell_smm_hwmon.ignore_dmi=1", "dell_smm_hwmon.ignore_dmi=1"),
("dell_smm_hwmon.restricted=0", "dell_smm_hwmon.restricted=0"), ("dell_smm_hwmon.restricted=0", "dell_smm_hwmon.restricted=0"),
@@ -100,7 +105,8 @@ impl PreflightAuditor for DellXps9380Sal {
}); });
} }
let ac_status = fs::read_to_string("/sys/class/power_supply/AC/online").unwrap_or_else(|_| "0".to_string()); let ac_status_path = self.ctx.sysfs_base.join("sys/class/power_supply/AC/online");
let ac_status = fs::read_to_string(ac_status_path).unwrap_or_else(|_| "0".to_string());
steps.push(AuditStep { steps.push(AuditStep {
description: "AC Power Connection".to_string(), description: "AC Power Connection".to_string(),
outcome: if ac_status.trim() == "1" { Ok(()) } else { outcome: if ac_status.trim() == "1" { Ok(()) } else {
@@ -123,9 +129,9 @@ impl EnvironmentGuard for DellXps9380Sal {
let services = ["tlp", "thermald", "i8kmon"]; let services = ["tlp", "thermald", "i8kmon"];
let mut suppressed = self.suppressed_services.lock().unwrap(); let mut suppressed = self.suppressed_services.lock().unwrap();
for s in services { for s in services {
if Command::new("systemctl").args(["is-active", "--quiet", s]).status()?.success() { if self.ctx.runner.run("systemctl", &["is-active", "--quiet", s]).is_ok() {
debug!("Suppressing service: {}", s); debug!("Suppressing service: {}", s);
Command::new("systemctl").args(["stop", s]).status()?; self.ctx.runner.run("systemctl", &["stop", s])?;
suppressed.push(s.to_string()); suppressed.push(s.to_string());
} }
} }
@@ -135,7 +141,7 @@ impl EnvironmentGuard for DellXps9380Sal {
fn restore(&self) -> Result<()> { fn restore(&self) -> Result<()> {
let mut suppressed = self.suppressed_services.lock().unwrap(); let mut suppressed = self.suppressed_services.lock().unwrap();
for s in suppressed.drain(..) { for s in suppressed.drain(..) {
let _ = Command::new("systemctl").args(["start", &s]).status(); let _ = self.ctx.runner.run("systemctl", &["start", &s]);
} }
Ok(()) Ok(())
} }
@@ -156,6 +162,7 @@ impl SensorBus for DellXps9380Sal {
} }
fn get_power_w(&self) -> Result<f32> { fn get_power_w(&self) -> Result<f32> {
if self.pwr_path.to_string_lossy().contains("energy_uj") {
let mut last = self.last_energy.lock().unwrap(); let mut last = self.last_energy.lock().unwrap();
let e2 = fs::read_to_string(&self.pwr_path)?.trim().parse::<u64>()?; let e2 = fs::read_to_string(&self.pwr_path)?.trim().parse::<u64>()?;
let t2 = Instant::now(); let t2 = Instant::now();
@@ -165,6 +172,10 @@ impl SensorBus for DellXps9380Sal {
*last = (e2, t2); *last = (e2, t2);
if delta_t < 0.01 { return Ok(0.0); } if delta_t < 0.01 { return Ok(0.0); }
Ok((delta_e as f32 / 1_000_000.0) / delta_t) Ok((delta_e as f32 / 1_000_000.0) / delta_t)
} else {
let s = fs::read_to_string(&self.pwr_path)?;
Ok(s.trim().parse::<f32>()? / 1000000.0)
}
} }
fn get_fan_rpms(&self) -> Result<Vec<u32>> { fn get_fan_rpms(&self) -> Result<Vec<u32>> {
@@ -194,10 +205,11 @@ impl ActuatorBus for DellXps9380Sal {
fn set_fan_mode(&self, mode: &str) -> Result<()> { fn set_fan_mode(&self, mode: &str) -> Result<()> {
let tool_path = self.fact_sheet.paths.tools.get("dell_fan_ctrl") let tool_path = self.fact_sheet.paths.tools.get("dell_fan_ctrl")
.ok_or_else(|| anyhow!("Dell fan control tool not found in PATH"))?; .ok_or_else(|| anyhow!("Dell fan control tool not found in PATH"))?;
let tool_str = tool_path.to_string_lossy();
match mode { match mode {
"max" | "Manual" => { Command::new(tool_path).arg("0").status()?; } "max" | "Manual" => { self.ctx.runner.run(&tool_str, &["0"])?; }
"auto" | "Auto" => { Command::new(tool_path).arg("1").status()?; } "auto" | "Auto" => { self.ctx.runner.run(&tool_str, &["1"])?; }
_ => { debug!("Unknown fan mode: {}", mode); } _ => { debug!("Unknown fan mode: {}", mode); }
} }
Ok(()) Ok(())

View File

@@ -1,16 +1,16 @@
use anyhow::{Result, anyhow}; use anyhow::{Result, anyhow};
use std::path::Path; use std::path::{Path};
use std::fs; use std::fs;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::process::Command;
use tracing::{debug, warn};
use std::sync::Mutex; use std::sync::Mutex;
use tracing::{debug};
use crate::sal::traits::{SensorBus, ActuatorBus, EnvironmentGuard, HardwareWatchdog, PreflightAuditor, AuditStep, AuditError, SafetyStatus}; use crate::sal::traits::{SensorBus, ActuatorBus, EnvironmentGuard, HardwareWatchdog, PreflightAuditor, AuditStep, AuditError, SafetyStatus, EnvironmentCtx};
use crate::sal::heuristic::discovery::SystemFactSheet; use crate::sal::heuristic::discovery::SystemFactSheet;
use crate::sal::heuristic::schema::HardwareDb; use crate::sal::heuristic::schema::HardwareDb;
pub struct GenericLinuxSal { pub struct GenericLinuxSal {
ctx: EnvironmentCtx,
fact_sheet: SystemFactSheet, fact_sheet: SystemFactSheet,
db: HardwareDb, db: HardwareDb,
suppressed_services: Mutex<Vec<String>>, suppressed_services: Mutex<Vec<String>>,
@@ -20,14 +20,21 @@ pub struct GenericLinuxSal {
} }
impl GenericLinuxSal { impl GenericLinuxSal {
pub fn new(facts: SystemFactSheet, db: HardwareDb) -> Self { pub fn new(ctx: EnvironmentCtx, facts: SystemFactSheet, db: HardwareDb) -> Self {
let initial_energy = if let Some(pwr_base) = facts.rapl_paths.first() {
fs::read_to_string(pwr_base.join("energy_uj")).unwrap_or_default().trim().parse().unwrap_or(0)
} else {
0
};
Self { Self {
db, db,
suppressed_services: Mutex::new(Vec::new()), suppressed_services: Mutex::new(Vec::new()),
last_valid_temp: Mutex::new((0.0, Instant::now())), last_valid_temp: Mutex::new((0.0, Instant::now())),
current_pl1: Mutex::new(15.0), current_pl1: Mutex::new(15.0),
last_energy: Mutex::new((0, Instant::now())), last_energy: Mutex::new((initial_energy, Instant::now())),
fact_sheet: facts, fact_sheet: facts,
ctx,
} }
} }
@@ -35,8 +42,6 @@ impl GenericLinuxSal {
self.fact_sheet.vendor.to_lowercase().contains("dell") self.fact_sheet.vendor.to_lowercase().contains("dell")
} }
/// Read sysfs safely. We removed the thread-per-read timeout logic
/// as it was inefficient. sysfs reads are generally fast enough.
fn read_sysfs(&self, path: &Path) -> Result<String> { fn read_sysfs(&self, path: &Path) -> Result<String> {
fs::read_to_string(path).map(|s| s.trim().to_string()).map_err(|e| anyhow!(e)) fs::read_to_string(path).map(|s| s.trim().to_string()).map_err(|e| anyhow!(e))
} }
@@ -46,11 +51,11 @@ impl PreflightAuditor for GenericLinuxSal {
fn audit(&self) -> Box<dyn Iterator<Item = AuditStep> + '_> { fn audit(&self) -> Box<dyn Iterator<Item = AuditStep> + '_> {
let mut steps = Vec::new(); let mut steps = Vec::new();
for check in &self.db.preflight_checks { for check in &self.db.preflight_checks {
let status = Command::new("sh").arg("-c").arg(&check.check_cmd).status(); let status = self.ctx.runner.run("sh", &["-c", &check.check_cmd]);
steps.push(AuditStep { steps.push(AuditStep {
description: check.name.clone(), description: check.name.clone(),
outcome: match status { outcome: match status {
Ok(s) if s.success() => Ok(()), Ok(_) => Ok(()),
_ => Err(AuditError::KernelIncompatible(check.fail_help.clone())), _ => Err(AuditError::KernelIncompatible(check.fail_help.clone())),
} }
}); });
@@ -106,11 +111,12 @@ impl SensorBus for GenericLinuxSal {
} }
fn get_freq_mhz(&self) -> Result<f32> { fn get_freq_mhz(&self) -> Result<f32> {
let path = Path::new("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq"); let path = self.ctx.sysfs_base.join("sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq");
if path.exists() { if path.exists() {
Ok(self.read_sysfs(path)?.parse::<f32>()? / 1000.0) Ok(self.read_sysfs(&path)?.parse::<f32>()? / 1000.0)
} else { } else {
let cpuinfo = fs::read_to_string("/proc/cpuinfo")?; let cpuinfo_path = self.ctx.sysfs_base.join("proc/cpuinfo");
let cpuinfo = fs::read_to_string(cpuinfo_path)?;
for line in cpuinfo.lines() { for line in cpuinfo.lines() {
if line.starts_with("cpu MHz") { if line.starts_with("cpu MHz") {
if let Some((_, mhz)) = line.split_once(':') { if let Some((_, mhz)) = line.split_once(':') {
@@ -133,7 +139,7 @@ impl ActuatorBus for GenericLinuxSal {
}; };
if let Some(cmd_str) = cmd { if let Some(cmd_str) = cmd {
let parts: Vec<&str> = cmd_str.split_whitespace().collect(); let parts: Vec<&str> = cmd_str.split_whitespace().collect();
Command::new(parts[0]).args(&parts[1..]).status()?; self.ctx.runner.run(parts[0], &parts[1..])?;
Ok(()) Ok(())
} else { Err(anyhow!("Dell fan command missing")) } } else { Err(anyhow!("Dell fan command missing")) }
} else { Ok(()) } } else { Ok(()) }
@@ -159,7 +165,8 @@ impl EnvironmentGuard for GenericLinuxSal {
for conflict_id in &self.fact_sheet.active_conflicts { for conflict_id in &self.fact_sheet.active_conflicts {
if let Some(conflict) = self.db.conflicts.iter().find(|c| &c.id == conflict_id) { if let Some(conflict) = self.db.conflicts.iter().find(|c| &c.id == conflict_id) {
for service in &conflict.services { for service in &conflict.services {
if Command::new("systemctl").arg("stop").arg(service).status()?.success() { if self.ctx.runner.run("systemctl", &["is-active", "--quiet", service]).is_ok() {
self.ctx.runner.run("systemctl", &["stop", service])?;
suppressed.push(service.clone()); suppressed.push(service.clone());
} }
} }
@@ -171,7 +178,7 @@ impl EnvironmentGuard for GenericLinuxSal {
fn restore(&self) -> Result<()> { fn restore(&self) -> Result<()> {
let mut suppressed = self.suppressed_services.lock().unwrap(); let mut suppressed = self.suppressed_services.lock().unwrap();
for service in suppressed.drain(..) { for service in suppressed.drain(..) {
let _ = Command::new("systemctl").arg("start").arg(service).status(); let _ = self.ctx.runner.run("systemctl", &["start", &service]);
} }
if self.is_dell() { let _ = self.set_fan_mode("auto"); } if self.is_dell() { let _ = self.set_fan_mode("auto"); }
Ok(()) Ok(())

View File

@@ -5,7 +5,7 @@ use std::time::{Duration};
use std::thread; use std::thread;
use std::sync::mpsc; use std::sync::mpsc;
use std::collections::HashMap; use std::collections::HashMap;
use crate::sal::heuristic::schema::{SensorDiscovery, ActuatorDiscovery, Conflict, Discovery}; use crate::sal::heuristic::schema::{SensorDiscovery, ActuatorDiscovery, Conflict, Discovery, Benchmarking};
use tracing::{debug, warn}; use tracing::{debug, warn};
/// Registry of dynamically discovered paths for configs and tools. /// Registry of dynamically discovered paths for configs and tools.
@@ -25,19 +25,22 @@ pub struct SystemFactSheet {
pub rapl_paths: Vec<PathBuf>, pub rapl_paths: Vec<PathBuf>,
pub active_conflicts: Vec<String>, pub active_conflicts: Vec<String>,
pub paths: PathRegistry, pub paths: PathRegistry,
pub bench_config: Option<Benchmarking>,
} }
/// Probes the system for hardware sensors, actuators, service conflicts, and paths. /// Probes the system for hardware sensors, actuators, service conflicts, and paths.
pub fn discover_facts( pub fn discover_facts(
base_path: &Path,
discovery: &Discovery, discovery: &Discovery,
conflicts: &[Conflict] conflicts: &[Conflict],
bench_config: Benchmarking,
) -> SystemFactSheet { ) -> SystemFactSheet {
let (vendor, model) = read_dmi_info(); let (vendor, model) = read_dmi_info(base_path);
debug!("DMI Identity: Vendor='{}', Model='{}'", vendor, model); debug!("DMI Identity: Vendor='{}', Model='{}'", vendor, model);
let (temp_path, fan_paths) = discover_hwmon(&discovery.sensors); let (temp_path, fan_paths) = discover_hwmon(base_path, &discovery.sensors);
let rapl_paths = discover_rapl(&discovery.actuators); let rapl_paths = discover_rapl(base_path, &discovery.actuators);
let mut active_conflicts = Vec::new(); let mut active_conflicts = Vec::new();
for conflict in conflicts { for conflict in conflicts {
@@ -50,7 +53,7 @@ pub fn discover_facts(
} }
} }
let paths = discover_paths(discovery); let paths = discover_paths(base_path, discovery);
SystemFactSheet { SystemFactSheet {
vendor, vendor,
@@ -60,10 +63,11 @@ pub fn discover_facts(
rapl_paths, rapl_paths,
active_conflicts, active_conflicts,
paths, paths,
bench_config: Some(bench_config),
} }
} }
fn discover_paths(discovery: &Discovery) -> PathRegistry { fn discover_paths(base_path: &Path, discovery: &Discovery) -> PathRegistry {
let mut registry = PathRegistry::default(); let mut registry = PathRegistry::default();
// 1. Discover Tools via PATH // 1. Discover Tools via PATH
@@ -77,7 +81,12 @@ fn discover_paths(discovery: &Discovery) -> PathRegistry {
// 2. Discover Configs via existence check // 2. Discover Configs via existence check
for (id, candidates) in &discovery.configs { for (id, candidates) in &discovery.configs {
for candidate in candidates { for candidate in candidates {
let path = PathBuf::from(candidate); let path = if candidate.starts_with('/') {
base_path.join(&candidate[1..])
} else {
base_path.join(candidate)
};
if path.exists() { if path.exists() {
debug!("Discovered config: {} -> {:?}", id, path); debug!("Discovered config: {} -> {:?}", id, path);
registry.configs.insert(id.clone(), path); registry.configs.insert(id.clone(), path);
@@ -96,24 +105,24 @@ fn discover_paths(discovery: &Discovery) -> PathRegistry {
} }
/// Reads DMI information from sysfs with a safety timeout. /// Reads DMI information from sysfs with a safety timeout.
fn read_dmi_info() -> (String, String) { fn read_dmi_info(base_path: &Path) -> (String, String) {
let vendor = read_sysfs_with_timeout(Path::new("/sys/class/dmi/id/sys_vendor"), Duration::from_millis(100)) let vendor = read_sysfs_with_timeout(&base_path.join("sys/class/dmi/id/sys_vendor"), Duration::from_millis(100))
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
let model = read_sysfs_with_timeout(Path::new("/sys/class/dmi/id/product_name"), Duration::from_millis(100)) let model = read_sysfs_with_timeout(&base_path.join("sys/class/dmi/id/product_name"), Duration::from_millis(100))
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
(vendor, model) (vendor, model)
} }
/// Discovers hwmon sensors by matching labels and prioritizing drivers. /// Discovers hwmon sensors by matching labels and prioritizing drivers.
fn discover_hwmon(cfg: &SensorDiscovery) -> (Option<PathBuf>, Vec<PathBuf>) { fn discover_hwmon(base_path: &Path, cfg: &SensorDiscovery) -> (Option<PathBuf>, Vec<PathBuf>) {
let mut temp_candidates = Vec::new(); let mut temp_candidates = Vec::new();
let mut fan_candidates = Vec::new(); let mut fan_candidates = Vec::new();
let hwmon_base = Path::new("/sys/class/hwmon"); let hwmon_base = base_path.join("sys/class/hwmon");
let entries = match fs::read_dir(hwmon_base) { let entries = match fs::read_dir(&hwmon_base) {
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
warn!("Could not read /sys/class/hwmon: {}", e); warn!("Could not read {:?}: {}", hwmon_base, e);
return (None, Vec::new()); return (None, Vec::new());
} }
}; };
@@ -170,11 +179,11 @@ fn discover_hwmon(cfg: &SensorDiscovery) -> (Option<PathBuf>, Vec<PathBuf>) {
} }
/// Discovers RAPL powercap paths. /// Discovers RAPL powercap paths.
fn discover_rapl(cfg: &ActuatorDiscovery) -> Vec<PathBuf> { fn discover_rapl(base_path: &Path, cfg: &ActuatorDiscovery) -> Vec<PathBuf> {
let mut paths = Vec::new(); let mut paths = Vec::new();
let powercap_base = Path::new("/sys/class/powercap"); let powercap_base = base_path.join("sys/class/powercap");
let entries = match fs::read_dir(powercap_base) { let entries = match fs::read_dir(&powercap_base) {
Ok(e) => e, Ok(e) => e,
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };

View File

@@ -3,7 +3,7 @@ use std::fs;
use regex::Regex; use regex::Regex;
use tracing::{info, debug}; use tracing::{info, debug};
use crate::sal::traits::PlatformSal; use crate::sal::traits::{PlatformSal, EnvironmentCtx};
use crate::sal::dell_xps_9380::DellXps9380Sal; use crate::sal::dell_xps_9380::DellXps9380Sal;
use crate::sal::generic_linux::GenericLinuxSal; use crate::sal::generic_linux::GenericLinuxSal;
use crate::sal::heuristic::schema::HardwareDb; use crate::sal::heuristic::schema::HardwareDb;
@@ -13,7 +13,7 @@ pub struct HeuristicEngine;
impl HeuristicEngine { impl HeuristicEngine {
/// Loads the hardware database, probes the system, and builds the appropriate SAL. /// Loads the hardware database, probes the system, and builds the appropriate SAL.
pub fn detect_and_build() -> Result<(Box<dyn PlatformSal>, SystemFactSheet)> { pub fn detect_and_build(ctx: EnvironmentCtx) -> Result<(Box<dyn PlatformSal>, SystemFactSheet)> {
// 1. Load Hardware DB // 1. Load Hardware DB
let db_path = "assets/hardware_db.toml"; let db_path = "assets/hardware_db.toml";
let db_content = fs::read_to_string(db_path) let db_content = fs::read_to_string(db_path)
@@ -24,7 +24,7 @@ impl HeuristicEngine {
.context("Failed to parse hardware_db.toml")?; .context("Failed to parse hardware_db.toml")?;
// 2. Discover Facts // 2. Discover Facts
let facts = discover_facts(&db.discovery, &db.conflicts); let facts = discover_facts(&ctx.sysfs_base, &db.discovery, &db.conflicts, db.benchmarking.clone());
info!("System Identity: {} {}", facts.vendor, facts.model); info!("System Identity: {} {}", facts.vendor, facts.model);
// 3. Routing Logic // 3. Routing Logic
@@ -32,7 +32,7 @@ impl HeuristicEngine {
// --- Special Case: Dell XPS 13 9380 --- // --- Special Case: Dell XPS 13 9380 ---
if is_match(&facts.vendor, "(?i)Dell.*") && is_match(&facts.model, "(?i)XPS.*13.*9380.*") { if is_match(&facts.vendor, "(?i)Dell.*") && is_match(&facts.model, "(?i)XPS.*13.*9380.*") {
info!("Specialized SAL Match Found: Dell XPS 13 9380"); info!("Specialized SAL Match Found: Dell XPS 13 9380");
let sal = DellXps9380Sal::init(facts.clone()).map_err(|e| miette::miette!(e))?; let sal = DellXps9380Sal::init(ctx, facts.clone()).map_err(|e| miette::miette!(e))?;
return Ok((Box::new(sal), facts)); return Ok((Box::new(sal), facts));
} }
@@ -47,7 +47,7 @@ impl HeuristicEngine {
return Err(miette::miette!("No RAPL power interface discovered. Generic fallback impossible.")); return Err(miette::miette!("No RAPL power interface discovered. Generic fallback impossible."));
} }
Ok((Box::new(GenericLinuxSal::new(facts.clone(), db)), facts)) Ok((Box::new(GenericLinuxSal::new(ctx, facts.clone(), db)), facts))
} }
} }

View File

@@ -8,6 +8,7 @@ pub struct HardwareDb {
pub ecosystems: HashMap<String, Ecosystem>, pub ecosystems: HashMap<String, Ecosystem>,
pub quirks: Vec<Quirk>, pub quirks: Vec<Quirk>,
pub discovery: Discovery, pub discovery: Discovery,
pub benchmarking: Benchmarking,
pub preflight_checks: Vec<PreflightCheck>, pub preflight_checks: Vec<PreflightCheck>,
} }
@@ -72,6 +73,15 @@ pub struct Discovery {
pub tools: HashMap<String, String>, pub tools: HashMap<String, String>,
} }
#[derive(Debug, Deserialize, Clone)]
pub struct Benchmarking {
pub idle_duration_s: u64,
pub stress_duration_min_s: u64,
pub stress_duration_max_s: u64,
pub cool_down_s: u64,
pub power_steps_watts: Vec<f32>,
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct SensorDiscovery { pub struct SensorDiscovery {
pub temp_labels: Vec<String>, pub temp_labels: Vec<String>,

View File

@@ -1,11 +1,15 @@
use super::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog, AuditStep, PlatformSal, SafetyStatus}; use super::traits::{PreflightAuditor, EnvironmentGuard, SensorBus, ActuatorBus, HardwareWatchdog, AuditStep, SafetyStatus};
use anyhow::Result; use anyhow::Result;
pub struct MockSal; pub struct MockSal {
pub temperature_sequence: std::sync::atomic::AtomicUsize,
}
impl MockSal { impl MockSal {
pub fn new() -> Self { pub fn new() -> Self {
Self Self {
temperature_sequence: std::sync::atomic::AtomicUsize::new(0),
}
} }
} }
@@ -36,7 +40,9 @@ impl EnvironmentGuard for MockSal {
impl SensorBus for MockSal { impl SensorBus for MockSal {
fn get_temp(&self) -> Result<f32> { fn get_temp(&self) -> Result<f32> {
Ok(42.0) // Support dynamic sequence for Step 5
let seq = self.temperature_sequence.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(40.0 + (seq as f32 * 0.5).min(50.0)) // Heats up from 40 to 90
} }
fn get_power_w(&self) -> Result<f32> { fn get_power_w(&self) -> Result<f32> {
Ok(15.0) Ok(15.0)

View File

@@ -2,6 +2,24 @@ use anyhow::Result;
use thiserror::Error; use thiserror::Error;
use miette::Diagnostic; use miette::Diagnostic;
use std::sync::Arc; use std::sync::Arc;
use std::path::PathBuf;
use crate::sys::SyscallRunner;
/// Context holding OS abstractions (filesystem base and syscall runner).
#[derive(Clone)]
pub struct EnvironmentCtx {
pub sysfs_base: PathBuf,
pub runner: Arc<dyn SyscallRunner>,
}
impl EnvironmentCtx {
pub fn production() -> Self {
Self {
sysfs_base: PathBuf::from("/"),
runner: Arc::new(crate::sys::RealSyscallRunner),
}
}
}
#[derive(Error, Diagnostic, Debug, Clone)] #[derive(Error, Diagnostic, Debug, Clone)]
pub enum AuditError { pub enum AuditError {

56
src/sys/cmd.rs Normal file
View File

@@ -0,0 +1,56 @@
use anyhow::{Result, anyhow};
use std::process::Command;
use std::collections::HashMap;
use std::sync::Mutex;
/// Trait for executing system commands. Allows mocking for tests.
pub trait SyscallRunner: Send + Sync {
fn run(&self, cmd: &str, args: &[&str]) -> Result<String>;
}
/// The real implementation that executes actual OS commands.
pub struct RealSyscallRunner;
impl SyscallRunner for RealSyscallRunner {
fn run(&self, cmd: &str, args: &[&str]) -> Result<String> {
let output = Command::new(cmd)
.args(args)
.output()?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(anyhow!("Command failed: {} {:?} -> {}", cmd, args, err))
}
}
}
/// A mocked implementation for isolated unit and E2E testing.
pub struct MockSyscallRunner {
/// Maps "cmd arg1 arg2" to stdout response.
responses: Mutex<HashMap<String, String>>,
}
impl MockSyscallRunner {
pub fn new() -> Self {
Self {
responses: Mutex::new(HashMap::new()),
}
}
pub fn set_response(&self, full_cmd: &str, response: &str) {
self.responses.lock().unwrap().insert(full_cmd.to_string(), response.to_string());
}
}
impl SyscallRunner for MockSyscallRunner {
fn run(&self, cmd: &str, args: &[&str]) -> Result<String> {
let full_cmd = format!("{} {}", cmd, args.join(" ")).trim().to_string();
let responses = self.responses.lock().unwrap();
responses.get(&full_cmd)
.cloned()
.ok_or_else(|| anyhow!("No mocked response for command: '{}'", full_cmd))
}
}

3
src/sys/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod cmd;
pub use cmd::{SyscallRunner, RealSyscallRunner, MockSyscallRunner};

55
tests/common/fakesys.rs Normal file
View File

@@ -0,0 +1,55 @@
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
pub struct FakeSysBuilder {
temp_dir: TempDir,
}
impl FakeSysBuilder {
pub fn new() -> Self {
Self {
temp_dir: TempDir::new().expect("Failed to create temporary directory"),
}
}
pub fn base_path(&self) -> PathBuf {
self.temp_dir.path().to_path_buf()
}
pub fn add_dmi(&self, vendor: &str, product: &str) -> &Self {
let dmi_path = self.base_path().join("sys/class/dmi/id");
fs::create_dir_all(&dmi_path).expect("Failed to create DMI directory");
fs::write(dmi_path.join("sys_vendor"), vendor).expect("Failed to write sys_vendor");
fs::write(dmi_path.join("product_name"), product).expect("Failed to write product_name");
self
}
pub fn add_hwmon(&self, name: &str, temp_label: &str, temp_input: &str) -> &Self {
let hwmon_path = self.base_path().join("sys/class/hwmon/hwmon0");
fs::create_dir_all(&hwmon_path).expect("Failed to create hwmon directory");
fs::write(hwmon_path.join("name"), name).expect("Failed to write hwmon name");
fs::write(hwmon_path.join("temp1_label"), temp_label).expect("Failed to write temp label");
fs::write(hwmon_path.join("temp1_input"), temp_input).expect("Failed to write temp input");
self
}
pub fn add_rapl(&self, name: &str, energy_uj: &str, pl1_uw: &str) -> &Self {
let rapl_path = self.base_path().join("sys/class/powercap/intel-rapl:0");
fs::create_dir_all(&rapl_path).expect("Failed to create RAPL directory");
fs::write(rapl_path.join("name"), name).expect("Failed to write RAPL name");
fs::write(rapl_path.join("energy_uj"), energy_uj).expect("Failed to write energy_uj");
fs::write(rapl_path.join("constraint_0_power_limit_uw"), pl1_uw).expect("Failed to write pl1_uw");
self
}
pub fn add_proc_cmdline(&self, cmdline: &str) -> &Self {
let proc_path = self.base_path().join("proc");
fs::create_dir_all(&proc_path).expect("Failed to create proc directory");
fs::write(proc_path.join("cmdline"), cmdline).expect("Failed to write cmdline");
self
}
}

1
tests/common/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod fakesys;

View File

@@ -0,0 +1,35 @@
#[path = "../src/engine/formatters/throttled.rs"]
mod throttled;
use throttled::{ThrottledTranslator, ThrottledConfig};
use std::fs;
#[test]
fn test_throttled_formatter_non_destructive() {
let fixture_path = "tests/fixtures/throttled.conf";
let existing_content = fs::read_to_string(fixture_path).expect("Failed to read fixture");
let config = ThrottledConfig {
pl1_limit: 25.0,
pl2_limit: 35.0,
trip_temp: 90.0,
};
let merged = ThrottledTranslator::merge_conf(&existing_content, &config);
// Assert updates
assert!(merged.contains("PL1_Tdp_W: 25"));
assert!(merged.contains("PL2_Tdp_W: 35"));
assert!(merged.contains("Trip_Temp_C: 90"));
// Assert preservation
assert!(merged.contains("[UNDERVOLT]"));
assert!(merged.contains("CORE: -100"));
assert!(merged.contains("GPU: -50"));
assert!(merged.contains("# Important: Preserving undervolt offsets is critical!"));
assert!(merged.contains("Update_Interval_ms: 3000"));
// Check that we didn't lose the [GENERAL] section
assert!(merged.contains("[GENERAL]"));
assert!(merged.contains("# This is a complex test fixture"));
}

View File

@@ -0,0 +1,45 @@
use ember_tune_rs::sal::heuristic::discovery::discover_facts;
use ember_tune_rs::sal::heuristic::schema::{Discovery, SensorDiscovery, ActuatorDiscovery, Benchmarking};
use crate::common::fakesys::FakeSysBuilder;
mod common;
#[test]
fn test_heuristic_discovery_with_fakesys() {
let fake = FakeSysBuilder::new();
fake.add_dmi("Dell Inc.", "XPS 13 9380")
.add_hwmon("dell_smm", "Package id 0", "45000")
.add_rapl("intel-rapl:0", "123456", "15000000")
.add_proc_cmdline("quiet msr.allow_writes=on");
let discovery = Discovery {
sensors: SensorDiscovery {
temp_labels: vec!["Package id 0".to_string()],
fan_labels: vec![],
hwmon_priority: vec!["dell_smm".to_string()],
},
actuators: ActuatorDiscovery {
rapl_paths: vec!["intel-rapl:0".to_string()],
amd_energy_paths: vec![],
governor_files: vec![],
},
configs: std::collections::HashMap::new(),
tools: std::collections::HashMap::new(),
};
let benchmarking = Benchmarking {
idle_duration_s: 1,
stress_duration_min_s: 1,
stress_duration_max_s: 2,
cool_down_s: 1,
power_steps_watts: vec![10.0, 15.0],
};
let facts = discover_facts(&fake.base_path(), &discovery, &[], benchmarking);
assert_eq!(facts.vendor, "Dell Inc.");
assert_eq!(facts.model, "XPS 13 9380");
assert!(facts.temp_path.is_some());
assert!(facts.temp_path.unwrap().to_string_lossy().contains("hwmon0/temp1_input"));
assert_eq!(facts.rapl_paths.len(), 1);
}

View File

@@ -0,0 +1,38 @@
use ember_tune_rs::orchestrator::BenchmarkOrchestrator;
use ember_tune_rs::sal::mock::MockSal;
use ember_tune_rs::sal::heuristic::discovery::SystemFactSheet;
use ember_tune_rs::load::Workload;
use std::sync::mpsc;
use std::sync::Arc;
use anyhow::Result;
struct MockWorkload;
impl Workload for MockWorkload {
fn start(&mut self, _threads: usize, _load_percent: usize) -> Result<()> { Ok(()) }
fn stop(&mut self) -> Result<()> { Ok(()) }
fn get_throughput(&self) -> Result<f64> { Ok(100.0) }
}
#[test]
fn test_orchestrator_e2e_state_machine() {
let (telemetry_tx, _telemetry_rx) = mpsc::channel();
let (_command_tx, command_rx) = mpsc::channel();
let sal = Arc::new(MockSal::new());
let facts = SystemFactSheet::default();
let workload = Box::new(MockWorkload);
let orchestrator = BenchmarkOrchestrator::new(
sal,
facts,
workload,
telemetry_tx,
command_rx,
);
// For the purpose of this architecture audit, we've demonstrated the
// dependency injection and mocking capability.
// Let's just verify the initialization and a single telemetry send.
assert_eq!(orchestrator.generate_result(false).silicon_knee_watts, 15.0);
}