use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
mod serve;
pub use serve::{serve_at, serve_env, ControlServer};
#[cfg(feature = "client")]
mod client;
#[cfg(feature = "client")]
pub use client::{fetch, fetch_at, ReadyOutcome, wait_ready};
pub const CONTROL_SOCK_ENV: &str = "YAH_CONTROL_SOCK";
pub const DEFAULT_HTTP_PATH: &str = "/_yah/status";
pub const STATUS_CMD: &str = "status";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcState {
Pending,
Starting,
Running,
Draining,
Exited,
Failed,
}
impl ProcState {
pub fn is_ready(self) -> bool {
matches!(self, ProcState::Running)
}
pub fn is_terminal(self) -> bool {
matches!(self, ProcState::Exited | ProcState::Failed)
}
pub fn as_str(self) -> &'static str {
match self {
ProcState::Pending => "pending",
ProcState::Starting => "starting",
ProcState::Running => "running",
ProcState::Draining => "draining",
ProcState::Exited => "exited",
ProcState::Failed => "failed",
}
}
}
impl std::fmt::Display for ProcState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(feature = "kamaji")]
impl From<ProcState> for kamaji_proto::WorkloadState {
fn from(s: ProcState) -> Self {
match s {
ProcState::Pending => kamaji_proto::WorkloadState::Pending,
ProcState::Starting => kamaji_proto::WorkloadState::Starting,
ProcState::Running => kamaji_proto::WorkloadState::Running,
ProcState::Draining => kamaji_proto::WorkloadState::Draining,
ProcState::Exited => kamaji_proto::WorkloadState::Exited,
ProcState::Failed => kamaji_proto::WorkloadState::Failed,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProcStatus {
pub state: ProcState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ready: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pid: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uptime_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub endpoints: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub metrics: std::collections::BTreeMap<String, f64>,
}
impl ProcStatus {
pub fn new(state: ProcState) -> Self {
Self {
state,
ready: None,
pid: None,
uptime_secs: None,
version: None,
detail: None,
endpoints: Default::default(),
metrics: Default::default(),
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn with_pid(mut self, pid: u32) -> Self {
self.pid = Some(pid);
self
}
pub fn with_uptime_secs(mut self, secs: u64) -> Self {
self.uptime_secs = Some(secs);
self
}
pub fn with_endpoint(mut self, name: impl Into<String>, addr: impl Into<String>) -> Self {
self.endpoints.insert(name.into(), addr.into());
self
}
pub fn with_metric(mut self, name: impl Into<String>, value: f64) -> Self {
self.metrics.insert(name.into(), value);
self
}
pub fn is_ready(&self) -> bool {
self.state.is_ready()
}
pub fn summary(&self) -> String {
let mut s = self.state.as_str().to_string();
if let Some(detail) = &self.detail {
s.push_str(" — ");
s.push_str(detail);
}
if let Some(v) = &self.version {
s.push_str(&format!(" (v{v})"));
}
s
}
}
pub fn control_sock_path() -> Option<PathBuf> {
match std::env::var_os(CONTROL_SOCK_ENV) {
Some(v) if !v.is_empty() => Some(PathBuf::from(v)),
_ => None,
}
}
fn clear_stale_socket(path: &Path) -> std::io::Result<()> {
if !path.exists() {
return Ok(());
}
match std::os::unix::net::UnixStream::connect(path) {
Ok(_) => Err(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
format!(
"{} is already bound by a live listener — refusing to unlink it",
path.display()
),
)),
Err(_) => std::fs::remove_file(path),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_state_vocabulary_is_exactly_kamajis() {
for (state, wire) in [
(ProcState::Pending, "\"pending\""),
(ProcState::Starting, "\"starting\""),
(ProcState::Running, "\"running\""),
(ProcState::Draining, "\"draining\""),
(ProcState::Exited, "\"exited\""),
(ProcState::Failed, "\"failed\""),
] {
assert_eq!(serde_json::to_string(&state).unwrap(), wire);
assert_eq!(serde_json::from_str::<ProcState>(wire).unwrap(), state);
assert_eq!(format!("\"{state}\""), wire, "Display must match the wire");
}
}
#[test]
fn state_is_the_only_required_field() {
let s: ProcStatus = serde_json::from_str(r#"{"state":"running"}"#).unwrap();
assert!(s.is_ready());
assert_eq!(s.pid, None);
assert!(s.endpoints.is_empty());
}
#[test]
fn a_ready_flag_never_overrides_a_not_running_state() {
let s: ProcStatus = serde_json::from_str(r#"{"state":"starting","ready":true}"#).unwrap();
assert_eq!(s.ready, Some(true), "the claim is preserved verbatim");
assert!(!s.is_ready(), "but state decides");
}
#[test]
fn absent_optionals_are_not_emitted() {
let json = serde_json::to_string(&ProcStatus::new(ProcState::Running)).unwrap();
assert_eq!(json, r#"{"state":"running"}"#);
}
#[test]
fn the_builder_composes_a_full_document() {
let s = ProcStatus::new(ProcState::Starting)
.with_detail("replaying WAL 3/7")
.with_version("0.8.23")
.with_pid(71455)
.with_uptime_secs(41)
.with_endpoint("gui", "winit://main")
.with_metric("fps", 59.9);
assert_eq!(s.summary(), "starting — replaying WAL 3/7 (v0.8.23)");
let round: ProcStatus = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(round, s);
}
#[test]
fn terminal_and_ready_are_disjoint_and_only_running_is_ready() {
for st in [
ProcState::Pending,
ProcState::Starting,
ProcState::Draining,
ProcState::Exited,
ProcState::Failed,
] {
assert!(!st.is_ready(), "{st} must not be ready");
}
assert!(ProcState::Running.is_ready());
assert!(ProcState::Exited.is_terminal() && ProcState::Failed.is_terminal());
assert!(!ProcState::Starting.is_terminal());
}
#[test]
fn a_live_listener_is_never_unlinked() {
let tmp = tempfile::tempdir().unwrap();
let sock = tmp.path().join("live.sock");
let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
let err = clear_stale_socket(&sock).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::AddrInUse);
assert!(sock.exists(), "the live socket must survive");
}
#[test]
fn a_dead_socket_file_is_unlinked() {
let tmp = tempfile::tempdir().unwrap();
let sock = tmp.path().join("dead.sock");
{
let _l = std::os::unix::net::UnixListener::bind(&sock).unwrap();
}
assert!(sock.exists(), "dropping a listener leaves the file behind");
clear_stale_socket(&sock).unwrap();
assert!(!sock.exists());
}
#[cfg(feature = "kamaji")]
#[test]
fn every_proc_state_maps_onto_the_kamaji_state_of_the_same_name() {
for st in [
ProcState::Pending,
ProcState::Starting,
ProcState::Running,
ProcState::Draining,
ProcState::Exited,
ProcState::Failed,
] {
let via: kamaji_proto::WorkloadState = st.into();
assert_eq!(
format!("{via:?}").to_lowercase(),
st.as_str(),
"{st} must map onto the kamaji state of the same name"
);
}
}
}