From eccc5e9544b900e35cb0330cd100140f359856e6 Mon Sep 17 00:00:00 2001 From: jars Date: Sat, 9 May 2026 11:33:46 +0100 Subject: [PATCH] feat(core): 10s TTL cache on probe_power_state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache sits between override resolution and platform_probe; override paths (test + env-var) bypass it entirely so they always take effect immediately. OnceLock>> 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 --- crates/core/src/power.rs | 68 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/core/src/power.rs b/crates/core/src/power.rs index 8f83ede..5712b61 100644 --- a/crates/core/src/power.rs +++ b/crates/core/src/power.rs @@ -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 { 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> { + static SLOT: OnceLock>> = 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 { @@ -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); + }); + } }