use lesavka_common::lesavka::CapturePowerState; #[cfg(not(coverage))] use { anyhow::{anyhow, Context, Result}, std::process::Command, std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }, tokio::{ sync::Mutex, time::{Duration, Instant}, }, tracing::{info, warn}, }; #[cfg(not(coverage))] #[derive(Debug, Default)] struct CapturePowerInner { preview_leases: u32, session_leases: u32, manual_override: Option, session_grace_deadline: Option, sync_generation: u64, } #[cfg(not(coverage))] #[derive(Debug, Clone)] pub struct CapturePowerManager { unit: Arc, session_grace: Duration, inner: Arc>, } #[cfg(not(coverage))] #[derive(Clone)] pub struct CapturePowerLease { manager: CapturePowerManager, kind: LeaseKind, released: Arc, } #[cfg(not(coverage))] #[derive(Clone, Copy, Debug)] enum LeaseKind { Preview, Session, } #[cfg(not(coverage))] #[derive(Debug)] struct UnitSnapshot { available: bool, enabled: bool, detail: String, } #[cfg(not(coverage))] impl LeaseKind { fn as_str(self) -> &'static str { match self { Self::Preview => "preview", Self::Session => "session", } } } #[cfg(not(coverage))] impl CapturePowerManager { pub fn new() -> Self { let unit = std::env::var("LESAVKA_CAPTURE_POWER_UNIT") .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "relay.service".to_string()); Self { unit: Arc::::from(unit), session_grace: capture_power_session_grace_from_env(), inner: Arc::new(Mutex::new(CapturePowerInner::default())), } } pub async fn acquire(&self) -> CapturePowerLease { self.acquire_kind(LeaseKind::Preview).await } pub async fn acquire_session(&self) -> CapturePowerLease { self.acquire_kind(LeaseKind::Session).await } async fn acquire_kind(&self, kind: LeaseKind) -> CapturePowerLease { let (desired, unit, leases, manual_override, grace_remaining) = { let mut inner = self.inner.lock().await; match kind { LeaseKind::Preview => { inner.preview_leases = inner.preview_leases.saturating_add(1); } LeaseKind::Session => { inner.session_leases = inner.session_leases.saturating_add(1); if inner.session_grace_deadline.take().is_some() { inner.sync_generation = inner.sync_generation.saturating_add(1); } } } let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); ( desired, self.unit.to_string(), active_leases(&inner), inner.manual_override, grace_remaining, ) }; if let Err(err) = sync_unit_state(unit.as_str(), desired).await { warn!( unit = %unit, kind = kind.as_str(), leases, desired, ?manual_override, ?grace_remaining, ?err, "capture power sync failed on acquire" ); } CapturePowerLease { manager: self.clone(), kind, released: Arc::new(AtomicBool::new(false)), } } pub async fn set_manual(&self, enabled: bool) -> Result { let unit = self.unit.to_string(); { let mut inner = self.inner.lock().await; inner.manual_override = Some(enabled); inner.session_grace_deadline = None; inner.sync_generation = inner.sync_generation.saturating_add(1); } sync_unit_state(unit.as_str(), enabled).await?; self.snapshot().await } pub async fn set_auto(&self) -> Result { let unit = self.unit.to_string(); let desired = { let mut inner = self.inner.lock().await; inner.manual_override = None; desired_state_and_grace(&inner, Instant::now()).0 }; sync_unit_state(unit.as_str(), desired).await?; self.snapshot().await } pub async fn snapshot(&self) -> Result { let (active_leases, manual_override, grace_remaining) = { let inner = self.inner.lock().await; ( active_leases(&inner), inner.manual_override, desired_state_and_grace(&inner, Instant::now()).1, ) }; let unit = self.unit.to_string(); let snapshot = inspect_unit(unit.as_str()).await?; let mut detail = snapshot.detail; if let Some(grace_remaining) = grace_remaining { detail = format!( "{detail} • disconnect grace {}s", grace_remaining.as_secs().max(1) ); } Ok(CapturePowerState { available: snapshot.available, enabled: snapshot.enabled, unit, detail, active_leases, mode: match manual_override { Some(true) => "forced-on".to_string(), Some(false) => "forced-off".to_string(), None => "auto".to_string(), }, }) } async fn release_one(&self, kind: LeaseKind) { let (desired, unit, leases, manual_override, grace_remaining, grace_sync) = { let mut inner = self.inner.lock().await; match kind { LeaseKind::Preview => { inner.preview_leases = inner.preview_leases.saturating_sub(1); } LeaseKind::Session => { inner.session_leases = inner.session_leases.saturating_sub(1); if inner.session_leases == 0 { let deadline = Instant::now() + self.session_grace; inner.session_grace_deadline = Some(deadline); inner.sync_generation = inner.sync_generation.saturating_add(1); } } } let grace_sync = match kind { LeaseKind::Session if inner.session_leases == 0 => inner .session_grace_deadline .map(|deadline| (inner.sync_generation, deadline)), _ => None, }; let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); ( desired, self.unit.to_string(), active_leases(&inner), inner.manual_override, grace_remaining, grace_sync, ) }; if let Err(err) = sync_unit_state(unit.as_str(), desired).await { warn!( unit = %unit, kind = kind.as_str(), leases, desired, ?manual_override, ?grace_remaining, ?err, "capture power sync failed on release" ); } else { info!( unit = %unit, kind = kind.as_str(), leases, desired, ?manual_override, ?grace_remaining, "capture power synced" ); } if let Some((generation, deadline)) = grace_sync { self.schedule_grace_sync(generation, deadline); } } fn schedule_grace_sync(&self, generation: u64, deadline: Instant) { let manager = self.clone(); tokio::spawn(async move { tokio::time::sleep_until(deadline).await; let (desired, unit, leases, manual_override, grace_remaining, current_generation) = { let inner = manager.inner.lock().await; let (desired, grace_remaining) = desired_state_and_grace(&inner, Instant::now()); ( desired, manager.unit.to_string(), active_leases(&inner), inner.manual_override, grace_remaining, inner.sync_generation, ) }; if current_generation != generation { return; } if let Err(err) = sync_unit_state(unit.as_str(), desired).await { warn!( unit = %unit, generation, leases, desired, ?manual_override, ?grace_remaining, ?err, "capture power sync failed after grace" ); } else { info!( unit = %unit, generation, leases, desired, ?manual_override, ?grace_remaining, "capture power synced after grace" ); } }); } } #[cfg(not(coverage))] impl Drop for CapturePowerLease { fn drop(&mut self) { if self.released.swap(true, Ordering::AcqRel) { return; } let manager = self.manager.clone(); let kind = self.kind; tokio::spawn(async move { manager.release_one(kind).await; }); } } #[cfg(not(coverage))] fn active_leases(inner: &CapturePowerInner) -> u32 { inner.preview_leases.saturating_add(inner.session_leases) } #[cfg(not(coverage))] fn desired_state_and_grace(inner: &CapturePowerInner, now: Instant) -> (bool, Option) { if let Some(manual_override) = inner.manual_override { return (manual_override, None); } let grace_remaining = inner .session_grace_deadline .and_then(|deadline| deadline.checked_duration_since(now)); let desired = inner.preview_leases > 0 || inner.session_leases > 0 || grace_remaining.is_some(); (desired, grace_remaining) } #[cfg(not(coverage))] fn capture_power_session_grace_from_env() -> Duration { std::env::var("LESAVKA_CAPTURE_POWER_GRACE_SECS") .ok() .and_then(|raw| raw.parse::().ok()) .map(Duration::from_secs) .unwrap_or_else(|| Duration::from_secs(30)) } #[cfg(not(coverage))] async fn inspect_unit(unit: &str) -> Result { let unit = unit.to_string(); tokio::task::spawn_blocking(move || inspect_unit_blocking(unit.as_str())) .await .map_err(|err| anyhow!("capture power inspect task failed: {err}"))? } #[cfg(not(coverage))] fn inspect_unit_blocking(unit: &str) -> Result { let output = Command::new("systemctl") .args([ "show", unit, "--property=LoadState,ActiveState,SubState", "--value", ]) .output() .with_context(|| format!("querying systemd unit {unit}"))?; if !output.status.success() { return Err(anyhow!( "systemctl show {unit} failed: {}", String::from_utf8_lossy(&output.stderr).trim() )); } let stdout = String::from_utf8_lossy(&output.stdout); let mut lines = stdout.lines(); let load_state = lines.next().unwrap_or_default().trim().to_string(); let active_state = lines.next().unwrap_or_default().trim().to_string(); let sub_state = lines.next().unwrap_or_default().trim().to_string(); let available = !load_state.is_empty() && load_state != "not-found"; let enabled = active_state == "active"; let detail = if available { format!("{active_state}/{sub_state}") } else { "unit not found".to_string() }; Ok(UnitSnapshot { available, enabled, detail, }) } #[cfg(not(coverage))] async fn sync_unit_state(unit: &str, enabled: bool) -> Result<()> { let unit = unit.to_string(); tokio::task::spawn_blocking(move || sync_unit_state_blocking(unit.as_str(), enabled)) .await .map_err(|err| anyhow!("capture power sync task failed: {err}"))? } #[cfg(not(coverage))] fn sync_unit_state_blocking(unit: &str, enabled: bool) -> Result<()> { let action = if enabled { "start" } else { "stop" }; let status = Command::new("systemctl") .args([action, unit]) .status() .with_context(|| format!("running systemctl {action} {unit}"))?; if status.success() { Ok(()) } else { Err(anyhow!("systemctl {action} {unit} failed with {status}")) } } #[cfg(coverage)] #[derive(Debug, Clone, Default)] pub struct CapturePowerManager; #[cfg(coverage)] #[derive(Clone, Default)] pub struct CapturePowerLease; #[cfg(coverage)] impl CapturePowerManager { pub fn new() -> Self { Self } pub async fn acquire(&self) -> CapturePowerLease { CapturePowerLease } pub async fn acquire_session(&self) -> CapturePowerLease { CapturePowerLease } pub async fn set_manual(&self, enabled: bool) -> anyhow::Result { Ok(CapturePowerState { available: true, enabled, unit: "relay.service".to_string(), detail: if enabled { "active/running".to_string() } else { "inactive/dead".to_string() }, active_leases: 0, mode: if enabled { "forced-on".to_string() } else { "forced-off".to_string() }, }) } pub async fn set_auto(&self) -> anyhow::Result { Ok(CapturePowerState { available: true, enabled: false, unit: "relay.service".to_string(), detail: "inactive/dead".to_string(), active_leases: 0, mode: "auto".to_string(), }) } pub async fn snapshot(&self) -> anyhow::Result { Ok(CapturePowerState { available: true, enabled: false, unit: "relay.service".to_string(), detail: "inactive/dead".to_string(), active_leases: 0, mode: "auto".to_string(), }) } } #[cfg(all(test, coverage))] mod tests { use super::*; #[tokio::test] async fn coverage_stub_supports_preview_and_session_leases() { let manager = CapturePowerManager::new(); let _preview = manager.acquire().await; let _session = manager.acquire_session().await; } #[tokio::test] async fn coverage_stub_reports_auto_snapshot() { let state = CapturePowerManager::new() .snapshot() .await .expect("snapshot"); assert!(state.available); assert!(!state.enabled); assert_eq!(state.mode, "auto"); } #[tokio::test] async fn coverage_stub_toggles_manual_modes() { let manager = CapturePowerManager::new(); let on = manager.set_manual(true).await.expect("on"); assert!(on.enabled); assert_eq!(on.mode, "forced-on"); let off = manager.set_manual(false).await.expect("off"); assert!(!off.enabled); assert_eq!(off.mode, "forced-off"); } #[tokio::test] async fn coverage_stub_returns_to_auto_mode() { let manager = CapturePowerManager::new(); let state = manager.set_auto().await.expect("auto"); assert!(state.available); assert!(!state.enabled); assert_eq!(state.mode, "auto"); } }