//! Power-assertion helpers for long-running work (recording + LLM). //! //! Item #9 in docs/whisper-ecosystem/brief.md: macOS App Nap silently //! throttles apps that look idle from the OS's perspective — even when //! they are actively capturing audio in the background — which causes //! the kind of "my recording stopped halfway through" failure surfaced //! in Whispering #549 / #559. //! //! On macOS we use `NSProcessInfo.beginActivityWithOptions:reason:` to //! pin the process into a "latency-critical, user-initiated" state for //! the duration of a live session or an LLM generation. The returned //! activity object must be retained; dropping it ends the assertion. //! Runtime verification on Apple Silicon against actual idle-throttling //! is still pending. See `KNOWN-ISSUES.md` (KI-01). //! //! On Linux and Windows, `PowerAssertion::begin` is currently a no-op //! that registers a snapshot in the process-wide registry for diagnostics //! but does not inhibit OS-level idle throttling. The planned //! implementations are: //! //! - Linux: systemd-logind / GNOME session idle inhibit via //! `org.freedesktop.login1.Inhibit` where available. //! - Windows: `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED //! | ES_AWAYMODE_REQUIRED)` on begin and `ES_CONTINUOUS` alone on end. //! //! Until those land, long sessions on Linux and Windows can still be //! idled by the OS. See `KNOWN-ISSUES.md` (KI-02, KI-03) for workarounds. //! //! All paths return a guard so the caller's code is unchanged. Failures //! to acquire a real assertion are logged so the diagnostics bundle has //! a breadcrumb. use std::collections::HashMap; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; #[derive(Debug, Clone)] pub struct PowerAssertionSnapshot { pub id: usize, pub reason: &'static str, pub backend: &'static str, pub acquired: bool, } /// Handle for a single power assertion. Dropping it releases the /// assertion. Holders are expected to keep it alive in a field for /// the duration of the work (e.g., live session state, LLM generation /// guard). #[must_use = "dropping the guard ends the power assertion"] pub struct PowerAssertion { #[allow(dead_code)] id: usize, reason: &'static str, backend: &'static str, acquired: bool, #[cfg(target_os = "macos")] activity: Option, } static NEXT_ID: AtomicUsize = AtomicUsize::new(1); fn assertion_registry() -> &'static Mutex> { static REGISTRY: OnceLock>> = OnceLock::new(); REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) } pub fn active_assertions_snapshot() -> Vec { let mut snapshots = assertion_registry() .lock() .unwrap() .values() .cloned() .collect::>(); snapshots.sort_by_key(|snapshot| snapshot.id); snapshots } impl PowerAssertion { /// Begin a power assertion for the given reason. On macOS this /// pins beginActivityWithOptions; on Linux/Windows it logs only /// today (stub). pub fn begin(reason: &'static str) -> Self { let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); #[cfg(target_os = "macos")] let activity = objc_bridge::begin_activity(reason).ok(); #[cfg(target_os = "macos")] let backend = "macos"; #[cfg(target_os = "macos")] let acquired = activity.is_some(); #[cfg(target_os = "macos")] if acquired { tracing::info!(assertion_id = id, reason, "began macOS App Nap guard"); } else { tracing::warn!(reason, "macOS App Nap guard could not begin activity"); } #[cfg(not(target_os = "macos"))] { // No-op on non-macOS today; #9 acceptance text only cites // macOS App Nap. Linux/Windows placeholder handled if // future feedback requires it. let _ = reason; } #[cfg(not(target_os = "macos"))] let backend = "noop"; #[cfg(not(target_os = "macos"))] let acquired = false; assertion_registry().lock().unwrap().insert( id, PowerAssertionSnapshot { id, reason, backend, acquired, }, ); Self { id, reason, backend, acquired, #[cfg(target_os = "macos")] activity, } } } impl Drop for PowerAssertion { fn drop(&mut self) { #[cfg(target_os = "macos")] if let Some(handle) = self.activity.take() { objc_bridge::end_activity(handle); tracing::info!( assertion_id = self.id, reason = self.reason, "ended macOS App Nap guard" ); } assertion_registry().lock().unwrap().remove(&self.id); let _ = (self.reason, self.id); let _ = (self.backend, self.acquired); } } #[cfg(target_os = "macos")] mod objc_bridge { use objc2::rc::Retained; use objc2::runtime::ProtocolObject; use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString}; pub struct ActivityHandle { activity: Retained>, } unsafe impl Send for ActivityHandle {} pub fn begin_activity(reason: &str) -> Result { let process_info = NSProcessInfo::processInfo(); let reason = NSString::from_str(reason); let options = NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical; let activity = process_info.beginActivityWithOptions_reason(options, &reason); Ok(ActivityHandle { activity }) } pub fn end_activity(handle: ActivityHandle) { let process_info = NSProcessInfo::processInfo(); unsafe { process_info.endActivity(&handle.activity); } } } #[cfg(test)] mod tests { use super::*; use std::sync::{Mutex, MutexGuard, OnceLock}; fn power_test_guard() -> MutexGuard<'static, ()> { static TEST_GUARD: OnceLock> = OnceLock::new(); TEST_GUARD.get_or_init(|| Mutex::new(())).lock().unwrap() } fn clear_assertion_registry() { assertion_registry().lock().unwrap().clear(); } #[test] fn power_assertion_is_a_no_op_drop() { let _guard = power_test_guard(); clear_assertion_registry(); let guard = PowerAssertion::begin("test-reason"); let snapshots = active_assertions_snapshot(); assert!(snapshots.iter().any(|snapshot| snapshot.id == guard.id)); drop(guard); assert!(active_assertions_snapshot().is_empty()); } #[test] fn multiple_assertions_get_unique_ids() { let _guard = power_test_guard(); clear_assertion_registry(); let a = PowerAssertion::begin("a"); let b = PowerAssertion::begin("b"); assert_ne!(a.id, b.id); let snapshots = active_assertions_snapshot(); assert_eq!(snapshots.len(), 2); assert_eq!(snapshots[0].reason, "a"); assert_eq!(snapshots[1].reason, "b"); drop(a); drop(b); assert!(active_assertions_snapshot().is_empty()); } }