//! Platform-specific sleep prevention. //! //! Prevents the machine from idle-sleeping while an agent turn is in progress. //! macOS uses IOKit power assertions; Linux spawns `systemd-inhibit`. //! //! Threading: lives on `AppView` (single-threaded, `!Send`). //! Uses `Cell`/`RefCell` instead of atomics/mutexes. use std::cell::Cell; /// Prevents idle sleep while an agent turn is running. /// /// Calls are idempotent: repeated `inhibit()` or `release()` calls are no-ops /// when already in the requested state. On `Drop`, any held assertion is released. pub struct SleepInhibitor { #[cfg(target_os = "macos")] assertion_id: Cell>, #[cfg(target_os = "linux")] child: std::cell::RefCell>, active: Cell, /// Set on first `platform_inhibit` failure to avoid repeated spawn /// attempts on platforms where the inhibitor is unavailable (e.g. /// containers without systemd-inhibit). platform_unavailable: Cell, enabled: bool, } impl SleepInhibitor { pub fn new(enabled: bool) -> Self { Self { #[cfg(target_os = "macos")] assertion_id: Cell::new(None), #[cfg(target_os = "linux")] child: std::cell::RefCell::new(None), active: Cell::new(false), platform_unavailable: Cell::new(false), enabled, } } /// Prevent idle sleep. No-op if already inhibiting, disabled, or /// platform support was already determined to be unavailable. pub fn inhibit(&self) { if !self.enabled || self.active.get() || self.platform_unavailable.get() { return; } if self.platform_inhibit() { self.active.set(true); } else { self.platform_unavailable.set(true); } } /// Allow idle sleep again. No-op if not currently inhibiting. pub fn release(&self) { if !self.active.get() { return; } self.platform_release(); self.active.set(false); } #[cfg(target_os = "macos")] fn platform_inhibit(&self) -> bool { let mut assertion_id: u32 = 0; let reason = core_foundation::string::CFString::new("kigi: agent turn in progress"); let assertion_type = core_foundation::string::CFString::from_static_string("NoIdleSleepAssertion"); // IOPMAssertionCreateWithName returns kIOReturnSuccess (0) on success. let result = unsafe { IOPMAssertionCreateWithName( assertion_type.as_concrete_TypeRef(), 255, // kIOPMAssertionLevelOn reason.as_concrete_TypeRef(), &mut assertion_id, ) }; if result == 0 { self.assertion_id.set(Some(assertion_id)); true } else { tracing::warn!(error_code = result, "failed to create IOPMAssertion"); false } } #[cfg(target_os = "macos")] fn platform_release(&self) { if let Some(id) = self.assertion_id.get() { let result = unsafe { IOPMAssertionRelease(id) }; if result != 0 { tracing::warn!(error_code = result, "failed to release IOPMAssertion"); } self.assertion_id.set(None); } } #[cfg(target_os = "linux")] fn platform_inhibit(&self) -> bool { let mut cmd = std::process::Command::new("systemd-inhibit"); cmd.args([ "--what=idle", "--who=kigi", "--why=agent turn in progress", "sleep", "infinity", ]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); kigi_tty_utils::detach_std_command(&mut cmd); let result = cmd.spawn(); match result { Ok(child) => { *self.child.borrow_mut() = Some(child); true } Err(e) => { tracing::debug!(error = %e, "systemd-inhibit not available"); false } } } #[cfg(target_os = "linux")] fn platform_release(&self) { if let Some(mut child) = self.child.borrow_mut().take() { let _ = nix::sys::signal::kill( nix::unistd::Pid::from_raw(child.id() as i32), nix::sys::signal::Signal::SIGTERM, ); let _ = child.wait(); } } #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn platform_inhibit(&self) -> bool { false } #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn platform_release(&self) {} } impl Drop for SleepInhibitor { fn drop(&mut self) { self.release(); } } // -- macOS IOKit FFI --------------------------------------------------------- #[cfg(target_os = "macos")] use core_foundation::base::TCFType; #[cfg(target_os = "macos")] #[link(name = "IOKit", kind = "framework")] unsafe extern "C" { fn IOPMAssertionCreateWithName( assertion_type: core_foundation::string::CFStringRef, assertion_level: u32, reason_for_activity: core_foundation::string::CFStringRef, assertion_id: *mut u32, ) -> i32; fn IOPMAssertionRelease(assertion_id: u32) -> i32; } #[cfg(test)] mod tests { use super::*; #[test] fn disabled_inhibitor_is_noop() { let inhibitor = SleepInhibitor::new(false); inhibitor.inhibit(); assert!(!inhibitor.active.get()); inhibitor.release(); assert!(!inhibitor.active.get()); } /// On unsupported platforms (not macOS/Linux), platform_inhibit returns /// false and `platform_unavailable` latches to prevent retry spam. #[cfg(not(any(target_os = "macos", target_os = "linux")))] #[test] fn platform_unavailable_prevents_retries() { let inhibitor = SleepInhibitor::new(true); // First attempt: platform_inhibit fails, flag latches. inhibitor.inhibit(); assert!(!inhibitor.active.get()); assert!(inhibitor.platform_unavailable.get()); // Subsequent calls are short-circuited by the flag. inhibitor.inhibit(); assert!(!inhibitor.active.get()); } #[test] fn inhibit_is_idempotent() { let inhibitor = SleepInhibitor::new(true); inhibitor.inhibit(); let was_active = inhibitor.active.get(); // Second call should not change state (and not spawn a second child/assertion). inhibitor.inhibit(); assert_eq!(inhibitor.active.get(), was_active); inhibitor.release(); } #[test] fn release_is_idempotent() { let inhibitor = SleepInhibitor::new(true); // Release without prior inhibit is a no-op. inhibitor.release(); assert!(!inhibitor.active.get()); // Double release after inhibit is safe. inhibitor.inhibit(); inhibitor.release(); assert!(!inhibitor.active.get()); inhibitor.release(); assert!(!inhibitor.active.get()); } #[test] fn inhibit_release_cycle() { let inhibitor = SleepInhibitor::new(true); inhibitor.inhibit(); // On Linux, this spawns systemd-inhibit; on other platforms it's a no-op. // Either way, the active flag tracks the intent. let was_active = inhibitor.active.get(); inhibitor.release(); assert!(!inhibitor.active.get()); // Can re-inhibit after release. inhibitor.inhibit(); assert_eq!(inhibitor.active.get(), was_active); inhibitor.release(); } #[test] fn drop_releases() { let inhibitor = SleepInhibitor::new(true); inhibitor.inhibit(); drop(inhibitor); // No assertion — just verify no panic/leak. } #[cfg(target_os = "linux")] #[test] fn linux_inhibit_spawns_child() { let inhibitor = SleepInhibitor::new(true); inhibitor.inhibit(); if inhibitor.active.get() { assert!(inhibitor.child.borrow().is_some()); inhibitor.release(); assert!(inhibitor.child.borrow().is_none()); } // If systemd-inhibit isn't available, active stays false — that's fine. } #[cfg(target_os = "linux")] #[test] fn linux_release_kills_child() { let inhibitor = SleepInhibitor::new(true); inhibitor.inhibit(); if inhibitor.active.get() { // Grab the pid before release. let pid = inhibitor.child.borrow().as_ref().map(|c| c.id()); assert!(pid.is_some()); inhibitor.release(); assert!(inhibitor.child.borrow().is_none()); assert!(!inhibitor.active.get()); } } }