514 lines
16 KiB
Rust
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");
|
|
}
|
|
}
|