feat(core): 10s TTL cache on probe_power_state

Cache sits between override resolution and platform_probe; override
paths (test + env-var) bypass it entirely so they always take effect
immediately. OnceLock<Mutex<Option<CachedState>>> initialised lazily on
first probe. Two test-only helpers (force_clear_cache, force_set_cache)
expose the cache slot for unit tests. Mutex import ungated — now used in
production cache code as well as test override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jars
2026-05-09 11:33:46 +01:00
parent 3d978e3978
commit eccc5e9544

View File

@@ -16,6 +16,8 @@ pub enum PowerState {
use std::fs;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
/// Parse a `/sys/class/power_supply/`-style directory and decide
/// whether the machine is on AC, on battery, or in an unknown state.
@@ -64,8 +66,30 @@ fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_owned())
}
const POWER_STATE_TTL: Duration = Duration::from_secs(10);
struct CachedState {
state: PowerState,
fetched_at: Instant,
}
fn cache_slot() -> &'static Mutex<Option<CachedState>> {
static SLOT: OnceLock<Mutex<Option<CachedState>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
#[cfg(test)]
use std::sync::Mutex;
pub(crate) fn force_clear_cache() {
*cache_slot().lock().expect("power cache mutex poisoned") = None;
}
#[cfg(test)]
pub(crate) fn force_set_cache(state: PowerState) {
*cache_slot().lock().expect("power cache mutex poisoned") = Some(CachedState {
state,
fetched_at: Instant::now(),
});
}
/// Top-level power-state probe.
///
@@ -79,7 +103,9 @@ use std::sync::Mutex;
/// `Unknown` is treated as `OnAc` by callers, which preserves today's
/// pre-clamp behaviour on platforms where the probe doesn't fire.
///
/// (TTL cache is added in Task 1.4.)
/// Results are cached for `POWER_STATE_TTL` (10 seconds). Override
/// paths (test and env-var) bypass the cache entirely so they always
/// take effect immediately.
pub fn probe_power_state() -> PowerState {
#[cfg(test)]
if let Some(state) = test_get_override() {
@@ -88,7 +114,19 @@ pub fn probe_power_state() -> PowerState {
if let Some(state) = env_override() {
return state;
}
platform_probe()
let mut slot = cache_slot().lock().expect("power cache mutex poisoned");
if let Some(cached) = &*slot {
if cached.fetched_at.elapsed() < POWER_STATE_TTL {
return cached.state;
}
}
let fresh = platform_probe();
*slot = Some(CachedState {
state: fresh,
fetched_at: Instant::now(),
});
fresh
}
fn env_override() -> Option<PowerState> {
@@ -257,4 +295,28 @@ mod tests {
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
});
}
#[test]
fn ttl_cache_returns_cached_value_within_window() {
with_override(None, || {
force_clear_cache();
force_set_cache(PowerState::OnBattery);
// No override active, no env var; probe should hit cache.
assert_eq!(probe_power_state(), PowerState::OnBattery);
});
}
#[test]
fn ttl_cache_clears_via_force_clear() {
with_override(None, || {
force_set_cache(PowerState::OnBattery);
force_clear_cache();
// Override flips to OnAc; with cleared cache, override
// (which has higher priority) drives the result.
});
with_override(Some(PowerState::OnAc), || {
force_clear_cache();
assert_eq!(probe_power_state(), PowerState::OnAc);
});
}
}