redbear-power: bulletproof cpufreq backend with writability check

- cpufreq.rs: complete rewrite — Backend enum (Redox/Linux), Cpufreq
  struct with self-discovering governor list, probe-based discovery
  on Redox, writability verification by writing current governor
  back. No hardcoded governor names — cycle order follows discovered
  list. Handles intel_pstate (no ondemand), permission-denied,
  and absent cpufreq gracefully.
- app.rs: Cpufreq stored in App instead of Governor enum, eliminates
  per-poll probe overhead. cycle_governor flashes root-required
  when sysfs denies write.
- render.rs, dbus.rs: use cpufreq.active string directly
This commit is contained in:
2026-06-28 18:13:34 +03:00
parent ee086ded2d
commit d2b969eb05
4 changed files with 308 additions and 109 deletions
@@ -16,7 +16,7 @@ use ratatui::widgets::TableState;
use crate::acpi::{detect_cpus, read_acpi_pss, read_cpu_id, read_load, PState,
read_cpu_freq_khz_sysfs, read_rapl_package_energy, rapl_power_watts};
use crate::cpufreq::{read_governor_state, write_governor_hint};
use crate::cpufreq::Cpufreq;
use crate::cpuid::{self, CoreType, CpuId};
use crate::msr::{
read_current_perf_ctl, read_package_thermal_status, read_thermal_status,
@@ -105,7 +105,7 @@ pub struct App {
pub cpus: Vec<CpuRow>,
pub table_state: TableState,
pub expanded_cpu: Option<u32>,
pub governor: Governor,
pub cpufreq: Cpufreq,
pub throttle: ThrottleMode,
pub cpu_vendor: String,
pub cpu_model: String,
@@ -346,7 +346,7 @@ impl App {
cpus: rows,
table_state,
expanded_cpu: None,
governor: Governor::Ondemand,
cpufreq: Cpufreq::probe(),
throttle: ThrottleMode::Auto,
cpu_vendor,
cpu_model,
@@ -648,17 +648,8 @@ impl App {
self.rapl_prev = Some(curr);
}
}
// Pick the current governor from the cpufreq state file. If
// the file lacks the line, keep the previously-known value —
// the user's selection must not be silently dropped on a
// transient cpufreqd write that omits it.
if let Some(rest) = read_governor_state() {
self.governor = match rest.trim() {
"performance" => Governor::Performance,
"powersave" => Governor::Powersave,
_ => Governor::Ondemand,
};
}
// Re-read cpufreq state (catches external governor changes).
self.cpufreq.refresh();
if let Some(pkg) = read_package_thermal_status(self.cpus[0].id) {
self.pkg_thermal = PackageThermal::from_msr(pkg);
// PROCHOT at the package level means the entire chip is
@@ -678,54 +669,23 @@ impl App {
}
pub fn cycle_governor(&mut self) {
let prev = self.governor;
let target = self.governor.cycle();
self.governor = target;
if !write_governor_hint(self.governor.name()) {
self.flash_status("governor hint queued (cpufreqd not running yet)");
if !self.cpufreq.writable {
self.flash_status("governor write requires root (sysfs permission denied)");
return;
}
// Verify the kernel accepted the governor. On Linux, writing to
// /sys/.../scaling_governor may succeed from the FS perspective
// while the cpufreq driver silently rejects the governor (e.g.
// driver doesn't support "ondemand" on this CPU). Read back
// immediately to check. If rejected, revert and flash an error.
if let Some(actual) = read_governor_state() {
if actual.trim() != self.governor.name() {
self.governor = prev;
self.flash_status(format!(
"governor \"{}\" rejected by kernel (driver supports \"{}\")",
target.name(),
actual.trim()
));
return;
}
if let Some(name) = self.cpufreq.cycle() {
self.flash_status(format!("governor → {name}"));
} else {
self.flash_status("governor cycle: no other governor available");
}
self.flash_status(format!("governor → {}", self.governor.name()));
}
/// Set governor to a specific value (no cycling). Used by D-Bus
/// method calls so clients can set a known target directly.
pub fn set_governor(&mut self, gov: Governor) {
let prev = self.governor;
self.governor = gov;
if !write_governor_hint(self.governor.name()) {
self.flash_status("governor hint queued (cpufreqd not running yet)");
self.governor = prev;
return;
if self.cpufreq.set(gov.name()) {
self.flash_status(format!("governor → {}", gov.name()));
} else {
self.flash_status(format!("governor \"{}\" rejected", gov.name()));
}
if let Some(actual) = read_governor_state() {
if actual.trim() != self.governor.name() {
self.governor = prev;
self.flash_status(format!(
"governor \"{}\" rejected by kernel (driver supports \"{}\")",
gov.name(),
actual.trim()
));
return;
}
}
self.flash_status(format!("governor → {}", self.governor.name()));
}
/// Set the selected CPU to a specific P-state index (clamped to
@@ -1,63 +1,302 @@
//! cpufreq governor hint read/write.
//! cpufreq governor interface — platform-agnostic, self-discovering.
//!
//! `cpufreqd` exposes the active governor via a flat key=value text
//! file at `/scheme/cpufreq/state`. The file may contain other keys
//! in the future; reads short-circuit on the first `governor=` line
//! and writes preserve the rest of the file.
//! ## Backend discovery
//!
//! On Linux the equivalent is `/sys/devices/system/cpu/cpu0/cpufreq/
//! scaling_governor` (a single-line text file). Writes to that file
//! require root and are propagated by the kernel ACPI cpufreq driver.
//! On startup, `Cpufreq::probe()` detects the active backend:
//! 1. `/scheme/cpufreq/state` exists → Redox (cpufreqd daemon)
//! 2. `/sys/.../scaling_governor` exists → Linux sysfs
//! 3. Neither → governor support absent (QEMU, containers, etc.)
//!
//! ## Governor discovery
//!
//! Once a backend is active, available governors are discovered:
//! - Linux: read `/sys/.../scaling_available_governors`
//! - Redox: probe by writing each candidate and reading back (the
//! cpufreqd daemon may not expose a separate "available" file)
//! - Candidates tried: performance, powersave, ondemand, conservative,
//! schedutil, userspace — in that order.
//! - The active governor is always added to the available list (even
//! if not in the candidates — some drivers have custom governors).
//!
//! ## No hardcoded assumptions
//!
//! No governor name is special-cased. The cycle order follows the
//! discovered list. If the kernel rejects a write, it is silently
//! skipped. External governor changes (thermal events, other tools)
//! are reflected on the next poll.
//!
//! ## Write verification
//!
//! Every write is immediately verified by reading back. If the kernel/
//! daemon rejects the change, the caller is notified. This catches
//! intel_pstate dropping "ondemand", permissions issues, and locked
//! governors.
use std::fs;
use std::io::BufRead;
const REDOX_PATH: &str = "/scheme/cpufreq/state";
const LINUX_PATH: &str = "/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor";
// ── Backend abstraction ────────────────────────────────────────────
/// Read the live governor (e.g. "performance", "ondemand",
/// "powersave"). Tries Redox `/scheme/cpufreq/state` first, then
/// Linux `/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor`.
/// Returns `None` if neither source is reachable.
pub fn read_governor_state() -> Option<String> {
// Try Redox scheme path first.
if let Ok(file) = fs::File::open(REDOX_PATH) {
for line in std::io::BufReader::new(file).lines().map_while(Result::ok) {
if let Some(rest) = line.strip_prefix("governor=") {
return Some(rest.trim().to_string());
}
}
}
// Linux fallback: sysfs single-line file.
if let Ok(s) = fs::read_to_string(LINUX_PATH) {
return Some(s.trim().to_string());
}
None
#[derive(Clone, Debug)]
enum Backend {
/// `/scheme/cpufreq/state` — key=value text file served by cpufreqd
Redox,
/// `/sys/.../scaling_governor` — single-line sysfs file
Linux,
}
/// Write a new governor hint. On Redox this updates
/// `/scheme/cpufreq/state` (preserving other keys). On Linux this
/// writes to the sysfs `scaling_governor` file (kernel ACPI cpufreq
/// driver propagates the change).
pub fn write_governor_hint(governor: &str) -> bool {
// Try Redox scheme path first.
if let Ok(current) = fs::read_to_string(REDOX_PATH) {
let mut out = String::with_capacity(current.len() + 32);
let mut replaced = false;
for line in current.lines() {
if line.starts_with("governor=") {
out.push_str(&format!("governor={governor}\n"));
replaced = true;
} else {
out.push_str(line);
out.push('\n');
impl Backend {
fn probe() -> Option<Self> {
if std::path::Path::new("/scheme/cpufreq/state").exists() {
return Some(Self::Redox);
}
if std::path::Path::new("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").exists() {
return Some(Self::Linux);
}
None
}
fn read_active(&self) -> Option<String> {
match self {
Self::Redox => {
let file = fs::File::open("/scheme/cpufreq/state").ok()?;
for line in std::io::BufReader::new(file).lines().map_while(Result::ok) {
if let Some(rest) = line.strip_prefix("governor=") {
return Some(rest.trim().to_string());
}
}
None
}
Self::Linux => {
fs::read_to_string("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
.ok()
.map(|s| s.trim().to_string())
}
}
if !replaced {
out.insert_str(0, &format!("governor={governor}\n"));
}
return fs::write(REDOX_PATH, out).is_ok();
}
// Linux fallback: write to sysfs (single line, no newline preservation).
fs::write(LINUX_PATH, governor).is_ok()
}
fn read_available(&self) -> Option<Vec<String>> {
match self {
Self::Linux => {
let s = fs::read_to_string(
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors",
)
.ok()?;
Some(s.split_whitespace().map(|s| s.to_string()).collect())
}
// Redox cpufreqd doesn't expose an "available" list, so we
// discover by probing (see discover_governors below).
Self::Redox => None,
}
}
fn write(&self, governor: &str) -> bool {
match self {
Self::Redox => {
let current = match fs::read_to_string("/scheme/cpufreq/state") {
Ok(c) => c,
Err(_) => return false,
};
let mut out = String::with_capacity(current.len() + 32);
let mut replaced = false;
for line in current.lines() {
if line.starts_with("governor=") {
out.push_str(&format!("governor={governor}\n"));
replaced = true;
} else {
out.push_str(line);
out.push('\n');
}
}
if !replaced {
out.insert_str(0, &format!("governor={governor}\n"));
}
fs::write("/scheme/cpufreq/state", out).is_ok()
}
Self::Linux => {
fs::write(
"/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor",
governor,
)
.is_ok()
}
}
}
}
// ── Public API ─────────────────────────────────────────────────────
/// Complete cpufreq state — discovered once at startup, kept current
/// by polling.
#[derive(Clone, Debug)]
pub struct Cpufreq {
backend: Option<Backend>,
/// Governors known to work on this platform (may be empty before
/// first discovery).
pub available: Vec<String>,
/// Currently active governor (may be empty before first poll).
pub active: String,
/// Whether the governor file is writable (false if RO or absent).
pub writable: bool,
}
impl Cpufreq {
/// Probe the platform and discover available governors.
/// Safe to call multiple times — subsequent calls re-poll.
pub fn probe() -> Self {
let backend = Backend::probe();
let mut this = Self {
backend,
available: Vec::new(),
active: String::new(),
writable: false,
};
this.refresh();
this
}
/// Re-read the active governor and re-discover available governors
/// if the list is empty. Call periodically (~500ms) to catch
/// external changes.
pub fn refresh(&mut self) {
let Some(ref backend) = self.backend else {
self.available.clear();
self.active.clear();
self.writable = false;
return;
};
// Read current governor.
if let Some(active) = backend.read_active() {
self.active = active;
}
// Discover available governors (once).
if self.available.is_empty() {
self.available = backend
.read_available()
.unwrap_or_else(|| discover_by_probing(backend));
}
// Always ensure the active governor is in the list (handles
// custom governors that our probe didn't try).
if !self.active.is_empty() && !self.available.contains(&self.active) {
self.available.push(self.active.clone());
}
// Check writability: try writing the CURRENT governor back.
// This catches permission-denied (sysfs is root-only on Linux,
// /scheme/cpufreq/state may be RO in containers).
self.writable = if let Some(ref active) = self.active.clone().into() {
backend.write(active)
} else {
false
};
}
/// Cycle to the next available governor. Returns the new governor
/// name on success, or None if no change was possible.
pub fn cycle(&mut self) -> Option<String> {
let Some(ref backend) = self.backend else { return None };
if self.available.len() < 2 {
return None;
}
let cur_pos = self.available.iter().position(|g| *g == self.active);
let start = cur_pos.unwrap_or(0);
// Try each candidate in order until one sticks.
for offset in 1..=self.available.len() {
let candidate = &self.available[(start + offset) % self.available.len()];
if !backend.write(candidate) {
continue;
}
// Verify.
if let Some(actual) = backend.read_active() {
if actual == *candidate {
self.active = candidate.clone();
return Some(candidate.clone());
}
}
}
None
}
/// Set a specific governor by name. Returns true on success.
pub fn set(&mut self, name: &str) -> bool {
let Some(ref backend) = self.backend else { return false };
if !backend.write(name) {
return false;
}
if let Some(actual) = backend.read_active() {
if actual == name {
self.active = name.to_string();
return true;
}
}
false
}
}
// ── Discovery by probing ──────────────────────────────────────────
/// Candidates to try when the platform doesn't expose an "available
/// governors" list (Redox cpufreqd, some embedded kernels). Ordered
/// by likelihood: the most common governors first.
const PROBE_CANDIDATES: &[&str] = &[
"performance",
"powersave",
"ondemand",
"conservative",
"schedutil",
"userspace",
];
/// Write each candidate, read back. The ones that stick are available.
/// Restores the original governor when done.
fn discover_by_probing(backend: &Backend) -> Vec<String> {
let original = backend.read_active().unwrap_or_default();
let mut found: Vec<String> = Vec::new();
// Always include the original — it works by definition.
if !original.is_empty() && !found.contains(&original) {
found.push(original.clone());
}
for candidate in PROBE_CANDIDATES {
if candidate == &original || found.iter().any(|g| g == candidate) {
continue;
}
if !backend.write(candidate) {
continue;
}
if let Some(actual) = backend.read_active() {
if actual == *candidate && !found.contains(&candidate.to_string()) {
found.push(candidate.to_string());
}
}
}
// Restore original.
if !original.is_empty() && backend.read_active().as_deref() != Some(&original) {
let _ = backend.write(&original);
}
found
}
// ── Legacy compatibility (used by redbear-power App) ──────────────
/// Legacy: read the active governor string. Prefer `Cpufreq::probe()`
/// for new code.
pub fn read_governor_state() -> Option<String> {
let cpufreq = Cpufreq::probe();
if cpufreq.active.is_empty() { None } else { Some(cpufreq.active) }
}
/// Legacy: write a governor hint. Prefer `Cpufreq::set()` for new code.
pub fn write_governor_hint(governor: &str) -> bool {
if let Some(ref backend) = Backend::probe() {
backend.write(governor)
} else {
false
}
}
@@ -73,7 +73,7 @@ impl PowerSnapshot {
avg_freq_khz,
max_temp_c,
avg_load_pct,
governor: app.governor.name().to_string(),
governor: app.cpufreq.active.clone(),
throttle_mode: format!("{:?}", app.throttle),
prochot_asserted,
}
@@ -162,7 +162,7 @@ pub fn render_header<'a>(app: &'a App, focused: bool) -> Paragraph<'a> {
"Cores: ".set_style(theme::LABEL),
format!("{} ", app.cpus.len()).into(),
"Governor: ".set_style(theme::LABEL),
app.governor.name().set_style(theme::HEADER_GOVERNOR),
app.cpufreq.active.as_str().set_style(theme::HEADER_GOVERNOR),
" ".into(),
"Throttle: ".set_style(theme::LABEL),
match app.throttle {
@@ -1532,7 +1532,7 @@ pub fn render_cpu_table<'a>(
pub fn render_keybar<'a>(app: &'a App) -> Paragraph<'a> {
let line = format!(
" g:{} ↑↓:cpu p/P:±pstate m/M:min/max t:throttle r:refresh T:tab ?:help q:quit",
app.governor.name()
app.cpufreq.active.as_str()
);
Paragraph::new(line)
.style(theme::VALUE_OFF)