lesavka/server/src/capture_power.rs

514 lines
16 KiB
Rust

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<bool>,
session_grace_deadline: Option<Instant>,
sync_generation: u64,
}
#[cfg(not(coverage))]
#[derive(Debug, Clone)]
pub struct CapturePowerManager {
unit: Arc<str>,
session_grace: Duration,
inner: Arc<Mutex<CapturePowerInner>>,
}
#[cfg(not(coverage))]
#[derive(Clone)]
pub struct CapturePowerLease {
manager: CapturePowerManager,
kind: LeaseKind,
released: Arc<AtomicBool>,
}
#[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::<str>::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<CapturePowerState> {
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<CapturePowerState> {
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<CapturePowerState> {
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<Duration>) {
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::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or_else(|| Duration::from_secs(30))
}
#[cfg(not(coverage))]
async fn inspect_unit(unit: &str) -> Result<UnitSnapshot> {
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<UnitSnapshot> {
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<CapturePowerState> {
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<CapturePowerState> {
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<CapturePowerState> {
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");
}
}