use std::path::PathBuf;
#[cfg(unix)]
use std::sync::Arc;
use serde::{Deserialize, Serialize};
#[cfg(all(doc, unix))]
use super::{acquire_daemon_boot_guard, cleanup_stale_daemon};
#[cfg(unix)]
use super::{supervisor_marker_path, ConnectionAdmission};
pub const DEFAULT_DEMAND_IDLE_SECS: u64 = 1_800;
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DaemonLifetime {
Demand,
#[default]
Persistent,
}
#[derive(Debug, Clone, Copy)]
pub struct DaemonOptions {
pub lifetime: DaemonLifetime,
pub idle_interval: std::time::Duration,
}
impl Default for DaemonOptions {
fn default() -> Self {
Self {
lifetime: DaemonLifetime::Persistent,
idle_interval: std::time::Duration::from_secs(DEFAULT_DEMAND_IDLE_SECS),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DaemonStartupReport {
pub skipped_components: Vec<String>,
pub idle_ineligible_reasons: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DaemonLifecyclePhase {
Serving,
Draining,
Stopped,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DaemonShutdownReason {
Idle,
Signal,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct DaemonLifecycleSnapshot {
pub lifetime: DaemonLifetime,
pub instance_generation: String,
pub effective_idle_interval_ms: u64,
pub phase: DaemonLifecyclePhase,
pub shutdown_reason: Option<DaemonShutdownReason>,
pub skipped_components: Vec<String>,
pub idle_ineligible_reasons: Vec<String>,
pub ordinary_requests: usize,
pub idle_blockers: Vec<String>,
}
#[cfg(unix)]
pub(super) struct DaemonLifecycle {
pub(super) options: DaemonOptions,
state: std::sync::Mutex<DaemonLifecycleState>,
pub(super) connections: ConnectionAdmission,
}
#[cfg(unix)]
struct DaemonLifecycleState {
snapshot: DaemonLifecycleSnapshot,
last_request_completion: Option<tokio::time::Instant>,
}
#[cfg(unix)]
impl DaemonLifecycle {
pub(super) fn new(options: DaemonOptions, report: DaemonStartupReport) -> Self {
Self {
options,
state: std::sync::Mutex::new(DaemonLifecycleState {
snapshot: DaemonLifecycleSnapshot {
lifetime: options.lifetime,
instance_generation: uuid::Uuid::new_v4().to_string(),
effective_idle_interval_ms: options
.idle_interval
.as_millis()
.min(u128::from(u64::MAX))
as u64,
phase: DaemonLifecyclePhase::Serving,
shutdown_reason: None,
skipped_components: report.skipped_components,
idle_ineligible_reasons: report.idle_ineligible_reasons,
ordinary_requests: 0,
idle_blockers: Vec::new(),
},
last_request_completion: None,
}),
connections: ConnectionAdmission::from_env(),
}
}
pub(super) fn snapshot(&self) -> DaemonLifecycleSnapshot {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.snapshot
.clone()
}
pub(super) fn ready(&self) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.last_request_completion = Some(tokio::time::Instant::now());
}
pub(super) fn admit(self: &Arc<Self>) -> Option<OrdinaryRequestGuard> {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.snapshot.phase != DaemonLifecyclePhase::Serving {
return None;
}
state.snapshot.ordinary_requests += 1;
Some(OrdinaryRequestGuard(Arc::clone(self)))
}
pub(super) fn try_idle(&self, blockers: impl FnOnce() -> Vec<String>) -> bool {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.options.lifetime != DaemonLifetime::Demand
|| state.snapshot.phase != DaemonLifecyclePhase::Serving
|| state.snapshot.ordinary_requests != 0
|| !state.snapshot.idle_ineligible_reasons.is_empty()
|| state
.last_request_completion
.is_none_or(|last| last.elapsed() < self.options.idle_interval)
{
return false;
}
state.snapshot.idle_blockers = blockers();
if !state.snapshot.idle_blockers.is_empty() {
return false;
}
state.snapshot.phase = DaemonLifecyclePhase::Draining;
state.snapshot.shutdown_reason = Some(DaemonShutdownReason::Idle);
true
}
pub(super) fn draining(&self, reason: DaemonShutdownReason) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.snapshot.phase != DaemonLifecyclePhase::Stopped {
state.snapshot.phase = DaemonLifecyclePhase::Draining;
state.snapshot.shutdown_reason = Some(reason);
}
}
pub(super) fn stopped(&self) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.snapshot
.phase = DaemonLifecyclePhase::Stopped;
}
}
#[cfg(unix)]
pub(super) struct OrdinaryRequestGuard(Arc<DaemonLifecycle>);
#[cfg(unix)]
impl Drop for OrdinaryRequestGuard {
fn drop(&mut self) {
let mut state = self
.0
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.snapshot.ordinary_requests -= 1;
state.last_request_completion = Some(tokio::time::Instant::now());
}
}
pub(super) fn khive_dir() -> PathBuf {
khive_root_from(
std::env::var("HOME").ok(),
std::env::var("USERPROFILE").ok(),
)
}
pub(super) fn khive_root_from(home: Option<String>, userprofile: Option<String>) -> PathBuf {
home.filter(|v| !v.trim().is_empty())
.or_else(|| userprofile.filter(|v| !v.trim().is_empty()))
.map(PathBuf::from)
.unwrap_or_else(last_resort_root)
.join(".khive")
}
pub fn volume_lock_dir() -> Result<PathBuf, khive_db::SqliteError> {
khive_db::default_volume_lock_dir()
}
#[cfg(unix)]
fn last_resort_root() -> PathBuf {
PathBuf::from(".")
}
#[cfg(not(unix))]
fn last_resort_root() -> PathBuf {
std::env::temp_dir()
}
#[cfg(unix)]
pub(super) const SOCKET_PATH_ENV: &str = "KHIVE_SOCKET";
#[cfg(unix)]
pub(super) const PID_PATH_ENV: &str = "KHIVE_PID";
#[cfg(unix)]
fn path_override(key: &str) -> Option<PathBuf> {
match std::env::var(key) {
Ok(p) if !p.is_empty() => Some(PathBuf::from(p)),
_ => None,
}
}
#[cfg(unix)]
pub(super) fn default_socket_path() -> PathBuf {
khive_dir().join("khived.sock")
}
#[cfg(unix)]
fn default_pid_path() -> PathBuf {
khive_dir().join("khived.pid")
}
#[cfg(unix)]
pub fn socket_path() -> PathBuf {
path_override(SOCKET_PATH_ENV).unwrap_or_else(default_socket_path)
}
#[cfg(unix)]
pub fn pid_path() -> PathBuf {
path_override(PID_PATH_ENV).unwrap_or_else(default_pid_path)
}
#[cfg(unix)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RendezvousSplit {
pub set: &'static str,
pub unset: &'static str,
pub socket: PathBuf,
pub pid_file: PathBuf,
}
#[cfg(unix)]
pub fn rendezvous_split() -> Option<RendezvousSplit> {
match (path_override(SOCKET_PATH_ENV), path_override(PID_PATH_ENV)) {
(Some(socket), None) => Some(RendezvousSplit {
set: SOCKET_PATH_ENV,
unset: PID_PATH_ENV,
socket,
pid_file: default_pid_path(),
}),
(None, Some(pid_file)) => Some(RendezvousSplit {
set: PID_PATH_ENV,
unset: SOCKET_PATH_ENV,
socket: default_socket_path(),
pid_file,
}),
_ => None,
}
}
#[cfg(unix)]
pub(super) fn ensure_rendezvous_overrides_paired() -> anyhow::Result<()> {
match rendezvous_split() {
Some(RendezvousSplit {
set: SOCKET_PATH_ENV,
socket,
pid_file,
..
}) => anyhow::bail!(
"refusing to start: {SOCKET_PATH_ENV} is set to {} but {PID_PATH_ENV} is not set. \
The socket and the PID file are two halves of one daemon rendezvous and must move \
together: with only {SOCKET_PATH_ENV} set, this daemon would bind a private socket \
while claiming the shared PID file at {}, which belongs to the default rendezvous \
served on {}. Set {PID_PATH_ENV} to a private path beside the socket, or unset \
{SOCKET_PATH_ENV} to share the default rendezvous.",
socket.display(),
pid_file.display(),
default_socket_path().display(),
),
Some(RendezvousSplit {
socket, pid_file, ..
}) => anyhow::bail!(
"refusing to start: {PID_PATH_ENV} is set to {} but {SOCKET_PATH_ENV} is not set. \
The socket and the PID file are two halves of one daemon rendezvous and must move \
together: with only {PID_PATH_ENV} set, this daemon would write a private PID file \
while binding the shared socket at {}, the default rendezvous whose owner is \
recorded in {}. Set {SOCKET_PATH_ENV} to a private path beside the PID file, or \
unset {PID_PATH_ENV} to share the default rendezvous.",
pid_file.display(),
socket.display(),
default_pid_path().display(),
),
None => Ok(()),
}
}
pub fn lock_path() -> PathBuf {
if let Ok(p) = std::env::var("KHIVE_LOCK") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
khive_dir().join("khived.recovery.lock")
}
#[cfg(unix)]
pub fn recoverer_lock_path() -> PathBuf {
if let Ok(p) = std::env::var("KHIVE_RECOVERER_LOCK") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
khive_dir().join("khived.recoverer.lock")
}
#[cfg(unix)]
pub const SUPERVISOR_CLAIM_ENV: &str = "KHIVE_SUPERVISOR_CLAIM";
#[cfg(unix)]
pub(super) fn read_supervisor_marker_claim() -> Option<(u32, String)> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
.open(supervisor_marker_path())
.ok()?;
if !file.metadata().ok()?.is_file() {
return None;
}
let mut marker = String::new();
file.take(4097).read_to_string(&mut marker).ok()?;
if marker.len() > 4096 {
return None;
}
let mut lines = marker.lines();
if lines.next()?.is_empty() {
return None;
}
let pid = lines.next()?.parse::<u32>().ok().filter(|pid| *pid > 0)?;
lines
.next()?
.parse::<u64>()
.ok()
.filter(|seconds| *seconds > 0)?;
let claim = lines.next()?.to_string();
if lines.next().is_some() {
return None;
}
let parsed = uuid::Uuid::parse_str(&claim).ok()?;
if parsed.get_version() != Some(uuid::Version::Random) || parsed.to_string() != claim {
return None;
}
Some((pid, claim))
}
#[cfg(unix)]
pub(super) fn current_supervisor_claim() -> Option<String> {
let claim = std::env::var(SUPERVISOR_CLAIM_ENV).ok()?;
let (pid, published_claim) = read_supervisor_marker_claim()?;
(pid == std::process::id() && claim == published_claim).then_some(claim)
}