use std::sync::Arc;
#[cfg(unix)]
use std::io::Write as _;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
#[cfg(unix)]
use std::path::PathBuf;
#[cfg(unix)]
use async_trait::async_trait;
#[cfg(unix)]
use libc;
use serde::{Deserialize, Serialize};
#[cfg(unix)]
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[cfg(unix)]
use tokio::net::{UnixListener, UnixStream};
#[cfg(unix)]
use khive_db::{run_checkpoint_task, CheckpointConfig, ConnectionPool};
#[cfg(unix)]
use crate::pack::RequestIdentity;
pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
pub const PROTOCOL_VERSION: u32 = 3;
#[cfg(unix)]
const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 10;
#[cfg(unix)]
fn khive_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".khive")
}
#[cfg(unix)]
pub fn socket_path() -> PathBuf {
if let Ok(p) = std::env::var("KHIVE_SOCKET") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
khive_dir().join("khived.sock")
}
#[cfg(unix)]
pub fn pid_path() -> PathBuf {
if let Ok(p) = std::env::var("KHIVE_PID") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
khive_dir().join("khived.pid")
}
#[cfg(unix)]
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)]
fn open_lock_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path)
}
#[cfg(unix)]
fn acquire_flock_blocking(path: &std::path::Path, label: &str) -> Option<std::fs::File> {
let file = match open_lock_file(path) {
Ok(f) => f,
Err(e) => {
tracing::warn!(error = %e, path = ?path, "cannot open {label} lock file");
return None;
}
};
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
if rc != 0 {
tracing::warn!("flock LOCK_EX failed on {label} lock");
return None;
}
Some(file)
}
#[cfg(unix)]
pub fn acquire_recovery_lock() -> Option<std::fs::File> {
acquire_flock_blocking(&lock_path(), "recovery")
}
#[cfg(unix)]
fn try_acquire_flock_until(
path: &std::path::Path,
deadline: std::time::Instant,
) -> std::io::Result<Option<std::fs::File>> {
let file = open_lock_file(path)?;
let poll_interval = std::time::Duration::from_millis(10);
loop {
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
return Ok(Some(file));
}
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
return Err(err);
}
let now = std::time::Instant::now();
if now >= deadline {
return Ok(None);
}
std::thread::sleep(poll_interval.min(deadline - now));
}
}
#[cfg(unix)]
pub fn try_acquire_daemon_boot_guard_until(
deadline: std::time::Instant,
) -> std::io::Result<Option<DaemonBootGuard>> {
try_acquire_flock_until(&lock_path(), deadline)
}
#[cfg(unix)]
pub fn try_acquire_recoverer_lock_until(
deadline: std::time::Instant,
) -> std::io::Result<Option<std::fs::File>> {
try_acquire_flock_until(&recoverer_lock_path(), deadline)
}
#[cfg(unix)]
pub type DaemonBootGuard = std::fs::File;
#[cfg(unix)]
pub fn acquire_daemon_boot_guard() -> anyhow::Result<DaemonBootGuard> {
acquire_recovery_lock()
.ok_or_else(|| anyhow::anyhow!("failed to acquire daemon boot/recovery lock"))
}
#[cfg(unix)]
#[derive(Clone, Copy, PartialEq, Eq)]
struct SocketIdentity {
dev: u64,
ino: u64,
}
#[cfg(unix)]
fn socket_identity(path: &std::path::Path) -> Option<SocketIdentity> {
use std::os::unix::fs::MetadataExt;
let meta = std::fs::metadata(path).ok()?;
Some(SocketIdentity {
dev: meta.dev(),
ino: meta.ino(),
})
}
#[derive(Serialize, Deserialize, Default)]
pub struct DaemonRequestFrame {
pub ops: String,
pub presentation: Option<String>,
pub presentation_per_op: Option<Vec<Option<String>>>,
pub namespace: String,
#[serde(default)]
pub actor_id: Option<String>,
#[serde(default)]
pub visible_namespaces: Vec<String>,
#[serde(default)]
pub config_id: String,
#[serde(default)]
pub protocol_version: u32,
#[serde(default)]
pub probe_only: bool,
#[serde(default)]
pub metrics_only: bool,
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub format_per_op: Option<Vec<Option<String>>>,
#[serde(default)]
pub from_wire: bool,
#[serde(default)]
pub request_id: Option<u64>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct DaemonResponseFrame {
pub ok: bool,
pub result: Option<String>,
pub error: Option<String>,
pub namespace_mismatch: bool,
#[serde(default)]
pub config_mismatch: bool,
#[serde(default)]
pub served_config_id: Option<String>,
#[serde(default)]
pub version_mismatch: bool,
#[serde(default)]
pub daemon_protocol_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metrics: Option<MetricsSnapshot>,
#[serde(default)]
pub request_id: Option<u64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
pub struct MetricsSnapshot {
pub wal_pages: Option<u64>,
pub wal_truncate_attempts: u64,
pub wal_truncate_consecutive_failures: u64,
#[serde(default)]
pub wal_checkpoint_skipped_ticks: u64,
#[serde(default)]
pub wal_checkpoint_consecutive_skips: u64,
#[serde(default)]
pub wal_checkpoint_last_skip_wal_pages: Option<u64>,
pub oldest_pinned_tx_micros: Option<u64>,
pub oldest_pinned_tx_label: Option<String>,
pub open_tx_count: usize,
pub write_queue_depth: Option<usize>,
pub write_queue_capacity: Option<usize>,
}
#[cfg(unix)]
pub async fn read_frame(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
let mut len_buf = [0u8; 4];
stream.read_exact(&mut len_buf).await?;
let len = u32::from_be_bytes(len_buf) as usize;
if len > MAX_FRAME_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("daemon frame of {len} bytes exceeds {MAX_FRAME_BYTES} cap"),
));
}
let mut buf = vec![0u8; len];
stream.read_exact(&mut buf).await?;
Ok(buf)
}
#[cfg(unix)]
pub async fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> {
if payload.len() > MAX_FRAME_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"daemon frame of {} bytes exceeds {MAX_FRAME_BYTES} cap",
payload.len()
),
));
}
let len = (payload.len() as u32).to_be_bytes();
stream.write_all(&len).await?;
stream.write_all(payload).await?;
stream.flush().await?;
Ok(())
}
#[cfg(unix)]
#[async_trait]
pub trait DaemonDispatch: Clone + Send + Sync + 'static {
#[allow(clippy::too_many_arguments)]
async fn dispatch(
&self,
ops: String,
presentation: Option<String>,
presentation_per_op: Option<Vec<Option<String>>>,
format: Option<String>,
format_per_op: Option<Vec<Option<String>>>,
from_wire: bool,
identity: Option<RequestIdentity>,
) -> Result<String, String>;
async fn warm_all(&self);
fn namespace(&self) -> &str;
fn config_id(&self) -> &str;
fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
None
}
fn event_store_for_checkpoint(&self) -> Option<Arc<dyn khive_storage::EventStore>> {
None
}
}
static BACKGROUND_TASKS: std::sync::OnceLock<Arc<std::sync::atomic::AtomicUsize>> =
std::sync::OnceLock::new();
fn background_tasks() -> &'static Arc<std::sync::atomic::AtomicUsize> {
BACKGROUND_TASKS.get_or_init(|| Arc::new(std::sync::atomic::AtomicUsize::new(0)))
}
struct BackgroundTaskGuard {
counter: Arc<std::sync::atomic::AtomicUsize>,
}
impl Drop for BackgroundTaskGuard {
fn drop(&mut self) {
self.counter
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
pub fn track_background_task<F>(fut: F)
where
F: std::future::Future<Output = ()> + Send + 'static,
{
background_tasks().fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let guard = BackgroundTaskGuard {
counter: background_tasks().clone(),
};
tokio::spawn(async move {
let _guard = guard;
fut.await;
});
}
pub fn background_task_count() -> usize {
background_tasks().load(std::sync::atomic::Ordering::Relaxed)
}
static ACTIVE_PHASES: std::sync::OnceLock<
std::sync::Mutex<std::collections::HashMap<String, usize>>,
> = std::sync::OnceLock::new();
fn active_phases() -> &'static std::sync::Mutex<std::collections::HashMap<String, usize>> {
ACTIVE_PHASES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
pub struct PhaseGuard {
name: String,
}
impl Drop for PhaseGuard {
fn drop(&mut self) {
let mut map = active_phases()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(count) = map.get_mut(&self.name) {
*count -= 1;
if *count == 0 {
map.remove(&self.name);
}
}
}
}
pub fn register_active_phase(name: &str) -> PhaseGuard {
let mut map = active_phases()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*map.entry(name.to_string()).or_insert(0) += 1;
PhaseGuard {
name: name.to_string(),
}
}
pub fn active_phase_names() -> Vec<String> {
let map = active_phases()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut names: Vec<String> = map.keys().cloned().collect();
names.sort();
names
}
#[cfg(unix)]
fn build_metrics_snapshot<D: DaemonDispatch>(dispatcher: &D) -> MetricsSnapshot {
let open_tx_count = khive_storage::tx_registry::snapshot().len();
let (oldest_pinned_tx_micros, oldest_pinned_tx_label) =
match khive_storage::tx_registry::oldest() {
Some((_id, age, label)) => (Some(age.as_micros() as u64), label),
None => (None, None),
};
let (write_queue_depth, write_queue_capacity) = dispatcher
.pool_for_checkpoint()
.and_then(|pool| pool.writer_task_handle().ok().flatten())
.map(|handle| (Some(handle.queue_depth()), Some(handle.capacity())))
.unwrap_or((None, None));
MetricsSnapshot {
wal_pages: khive_db::checkpoint::last_observed_wal_pages(),
wal_truncate_attempts: khive_db::checkpoint::truncate_attempts(),
wal_truncate_consecutive_failures: khive_db::checkpoint::truncate_consecutive_failures(),
wal_checkpoint_skipped_ticks: khive_db::checkpoint::checkpoint_skipped_ticks(),
wal_checkpoint_consecutive_skips: khive_db::checkpoint::checkpoint_consecutive_skips(),
wal_checkpoint_last_skip_wal_pages: khive_db::checkpoint::checkpoint_last_skip_wal_pages(),
oldest_pinned_tx_micros,
oldest_pinned_tx_label,
open_tx_count,
write_queue_depth,
write_queue_capacity,
}
}
#[cfg(unix)]
async fn handle_conn<D: DaemonDispatch>(mut stream: UnixStream, dispatcher: D) {
let raw = match read_frame(&mut stream).await {
Ok(r) => r,
Err(e) => {
tracing::debug!(error = %e, "failed to read daemon request frame");
return;
}
};
let frame: DaemonRequestFrame = match serde_json::from_slice(&raw) {
Ok(f) => f,
Err(e) => {
tracing::debug!(error = %e, "failed to decode daemon request frame");
return;
}
};
let served_config_id = Some(dispatcher.config_id().to_string());
let resp = if frame.protocol_version != PROTOCOL_VERSION {
let msg = format!(
"daemon protocol mismatch: client={} daemon={} — \
rebuild/update the client binary (make local)",
frame.protocol_version, PROTOCOL_VERSION,
);
tracing::warn!(
client_version = frame.protocol_version,
daemon_version = PROTOCOL_VERSION,
"daemon protocol version mismatch"
);
DaemonResponseFrame {
ok: false,
result: None,
error: Some(msg),
namespace_mismatch: false,
config_mismatch: false,
served_config_id,
version_mismatch: true,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
request_id: frame.request_id,
}
} else if frame.metrics_only {
DaemonResponseFrame {
ok: true,
result: None,
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id,
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: Some(build_metrics_snapshot(&dispatcher)),
request_id: frame.request_id,
}
} else if frame.config_id != dispatcher.config_id() {
DaemonResponseFrame {
ok: false,
result: None,
error: None,
namespace_mismatch: false,
config_mismatch: true,
served_config_id,
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
request_id: frame.request_id,
}
} else if frame.probe_only {
DaemonResponseFrame {
ok: true,
result: None,
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id,
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
request_id: frame.request_id,
}
} else {
let identity = RequestIdentity {
namespace: frame.namespace.clone(),
actor_id: frame.actor_id.clone(),
visible_namespaces: frame.visible_namespaces.clone(),
request_id: frame.request_id,
};
match dispatcher
.dispatch(
frame.ops,
frame.presentation,
frame.presentation_per_op,
frame.format,
frame.format_per_op,
frame.from_wire,
Some(identity),
)
.await
{
Ok(result) => DaemonResponseFrame {
ok: true,
result: Some(result),
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id,
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
request_id: frame.request_id,
},
Err(e) => DaemonResponseFrame {
ok: false,
result: None,
error: Some(e),
namespace_mismatch: false,
config_mismatch: false,
served_config_id,
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
request_id: frame.request_id,
},
}
};
match serde_json::to_vec(&resp) {
Ok(payload) => {
if payload.len() > MAX_FRAME_BYTES {
tracing::warn!(
bytes = payload.len(),
limit = MAX_FRAME_BYTES,
"daemon response exceeds MAX_FRAME_BYTES; sending explicit error frame"
);
let err_resp = DaemonResponseFrame {
ok: false,
result: None,
error: Some(format!(
"response too large: {} bytes exceeds {} byte IPC cap",
payload.len(),
MAX_FRAME_BYTES,
)),
namespace_mismatch: false,
config_mismatch: false,
served_config_id: resp.served_config_id,
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
request_id: resp.request_id,
};
if let Ok(err_payload) = serde_json::to_vec(&err_resp) {
if let Err(e) = write_frame(&mut stream, &err_payload).await {
tracing::debug!(error = %e, "failed to write oversized-response error frame");
}
}
} else if let Err(e) = write_frame(&mut stream, &payload).await {
tracing::debug!(error = %e, "failed to write daemon response frame");
}
}
Err(e) => tracing::warn!(error = %e, "failed to serialize daemon response frame"),
}
}
#[cfg(unix)]
pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
let boot_guard = Some(acquire_daemon_boot_guard()?);
run_daemon_with_boot_guard(dispatcher, boot_guard).await
}
#[cfg(unix)]
pub async fn run_daemon_with_boot_guard<D: DaemonDispatch>(
dispatcher: D,
boot_guard: Option<std::fs::File>,
) -> anyhow::Result<()> {
let sock = socket_path();
let pid_file = pid_path();
if let Some(parent) = sock.parent() {
std::fs::create_dir_all(parent)?;
if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
tracing::warn!(error = %e, path = ?parent, "failed to chmod 0700 khive dir");
}
}
let _startup_lock = boot_guard;
if !cleanup_stale_daemon(&sock, &pid_file).await {
tracing::info!("a responsive khived is already running; exiting");
return Ok(());
}
let listener = UnixListener::bind(&sock)?;
if let Err(e) = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)) {
tracing::warn!(error = %e, path = ?sock, "failed to chmod 0600 socket");
}
let bound_identity = socket_identity(&sock);
if let Err(e) = write_pid_file_exclusive(&pid_file) {
if e.kind() == std::io::ErrorKind::AlreadyExists {
if bound_identity.is_some() && socket_identity(&sock) == bound_identity {
drop(listener);
let _ = std::fs::remove_file(&sock);
}
if pid_file_names_a_reachable_daemon(&pid_file, &sock).await {
tracing::info!(
"a replacement khived already claimed the pid/socket rendezvous; exiting"
);
return Ok(());
}
anyhow::bail!(
"failed to claim daemon pid file at {pid_file:?}: it already exists \
and does not name a reachable daemon"
);
}
return Err(e.into());
}
drop(_startup_lock);
tracing::info!(socket = ?sock, pid = std::process::id(), "khived listening");
{
let warm = dispatcher.clone();
tokio::spawn(async move {
warm.warm_all().await;
});
}
let (checkpoint_shutdown_tx, checkpoint_shutdown_rx) = tokio::sync::watch::channel(());
if let Some(pool) = dispatcher.pool_for_checkpoint() {
let cfg = CheckpointConfig::from_env();
let event_store = dispatcher.event_store_for_checkpoint();
let namespace = dispatcher.namespace().to_string();
track_background_task(run_checkpoint_task(
pool,
cfg,
event_store,
namespace,
checkpoint_shutdown_rx,
));
tracing::info!("WAL checkpoint task started");
}
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let shutdown = async {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.expect("install SIGINT handler");
tokio::select! {
_ = sigterm.recv() => tracing::info!("received SIGTERM"),
_ = sigint.recv() => tracing::info!("received SIGINT"),
}
};
tokio::select! {
_ = async {
loop {
match listener.accept().await {
Ok((stream, _)) => {
let d = dispatcher.clone();
let active = Arc::clone(&active);
tokio::spawn(async move {
active.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
handle_conn(stream, d).await;
active.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
});
}
Err(e) => tracing::error!(error = %e, "accept failed"),
}
}
} => {}
_ = shutdown => {}
}
let _ = checkpoint_shutdown_tx.send(());
drain(&active).await;
match acquire_recovery_lock() {
Some(_shutdown_lock) => {
shutdown_cleanup_if_owned(&sock, &pid_file, bound_identity);
}
None => {
tracing::warn!(
"could not acquire recovery lock for shutdown cleanup; \
skipping unlink to avoid deleting a replacement daemon's paths"
);
}
}
tracing::info!("khived stopped");
Ok(())
}
#[cfg(unix)]
fn shutdown_cleanup_if_owned(
sock: &std::path::Path,
pid_file: &std::path::Path,
bound_identity: Option<SocketIdentity>,
) -> bool {
let pid_is_ours = std::fs::read_to_string(pid_file)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
== Some(std::process::id());
let socket_is_ours = bound_identity.is_some() && socket_identity(sock) == bound_identity;
if pid_is_ours && socket_is_ours {
let _ = std::fs::remove_file(sock);
let _ = std::fs::remove_file(pid_file);
true
} else {
tracing::warn!(
socket = ?sock,
pid_file = ?pid_file,
"skipping shutdown cleanup — a replacement daemon already owns this socket/PID"
);
false
}
}
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PidLiveness {
Alive,
Dead,
PermissionDenied,
}
#[cfg(unix)]
impl PidLiveness {
fn is_running(self) -> bool {
!matches!(self, PidLiveness::Dead)
}
}
#[cfg(unix)]
fn classify_kill_result(rc: i32, errno: i32) -> PidLiveness {
if rc == 0 {
return PidLiveness::Alive;
}
match errno {
libc::EPERM => PidLiveness::PermissionDenied,
_ => PidLiveness::Dead,
}
}
#[cfg(unix)]
fn is_process_running(pid: u32) -> bool {
let Ok(pid) = i32::try_from(pid) else {
return false;
};
if pid <= 0 {
return false;
}
let rc = unsafe { libc::kill(pid, 0) };
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
classify_kill_result(rc, errno).is_running()
}
#[cfg(unix)]
async fn cleanup_stale_daemon(sock: &std::path::Path, pid_file: &std::path::Path) -> bool {
if let Ok(pid_str) = std::fs::read_to_string(pid_file) {
if let Ok(pid) = pid_str.trim().parse::<u32>() {
if pid != std::process::id()
&& is_process_running(pid)
&& sock.exists()
&& UnixStream::connect(sock).await.is_ok()
{
return false;
}
}
}
if sock.exists() {
if let Err(e) = std::fs::remove_file(sock) {
tracing::warn!(error = %e, path = ?sock, "failed to remove stale socket");
}
}
if pid_file.exists() {
if let Err(e) = std::fs::remove_file(pid_file) {
tracing::warn!(error = %e, path = ?pid_file, "failed to remove stale PID file");
}
}
true
}
#[cfg(unix)]
fn write_pid_file_exclusive(pid_file: &std::path::Path) -> std::io::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true).mode(0o600);
let mut f = opts.open(pid_file)?;
f.write_all(std::process::id().to_string().as_bytes())?;
Ok(())
}
#[cfg(unix)]
async fn pid_file_names_a_reachable_daemon(
pid_file: &std::path::Path,
sock: &std::path::Path,
) -> bool {
let Ok(pid_str) = std::fs::read_to_string(pid_file) else {
return false;
};
let Ok(pid) = pid_str.trim().parse::<u32>() else {
return false;
};
pid != std::process::id()
&& is_process_running(pid)
&& sock.exists()
&& UnixStream::connect(sock).await.is_ok()
}
#[cfg(unix)]
async fn drain(active: &std::sync::atomic::AtomicUsize) {
use std::sync::atomic::Ordering;
let remaining = || active.load(Ordering::Relaxed) + background_task_count();
if remaining() == 0 {
return;
}
let deadline = tokio::time::Instant::now() + drain_timeout();
while remaining() > 0 {
if tokio::time::Instant::now() >= deadline {
tracing::warn!(
remaining_connections = active.load(Ordering::Relaxed),
remaining_background_tasks = background_task_count(),
"drain timeout reached; forcing shutdown"
);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
#[cfg(unix)]
fn drain_timeout() -> std::time::Duration {
let secs = std::env::var("KHIVE_DRAIN_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS);
std::time::Duration::from_secs(secs)
}
#[cfg(unix)]
pub fn env_truthy(key: &str) -> bool {
std::env::var(key)
.map(|v| {
let v = v.trim();
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
})
.unwrap_or(false)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use serial_test::serial;
#[test]
fn current_process_is_running() {
let pid = std::process::id();
assert!(
is_process_running(pid),
"current process {pid} should be detected as running"
);
}
#[test]
fn pid_zero_is_not_running() {
assert!(
!is_process_running(0),
"pid 0 must be rejected by the guard before the unsafe call"
);
}
#[test]
fn very_large_pid_is_not_running() {
assert!(
!is_process_running(u32::MAX),
"u32::MAX should fail i32 conversion and return false"
);
}
#[test]
fn classify_kill_result_zero_is_alive() {
assert_eq!(classify_kill_result(0, 0), PidLiveness::Alive);
assert!(classify_kill_result(0, 0).is_running());
}
#[test]
fn classify_kill_result_esrch_is_dead() {
assert_eq!(classify_kill_result(-1, libc::ESRCH), PidLiveness::Dead);
assert!(!classify_kill_result(-1, libc::ESRCH).is_running());
}
#[test]
fn classify_kill_result_eperm_is_permission_denied_and_counts_as_running() {
assert_eq!(
classify_kill_result(-1, libc::EPERM),
PidLiveness::PermissionDenied
);
assert!(
classify_kill_result(-1, libc::EPERM).is_running(),
"EPERM must be unknown-safe: treated as running, never as a basis \
for stale cleanup to unlink a live daemon's rendezvous files"
);
}
#[test]
fn pid_1_probe_is_running_regardless_of_permission_outcome() {
assert!(
is_process_running(1),
"PID 1 always exists; EPERM must not read as dead"
);
}
#[test]
fn env_truthy_recognises_set_values() {
assert!(!env_truthy("__KHIVE_TEST_ABSENT_VAR_XYZ__"));
let key = "__KHIVE_TEST_TRUTHY_ABC__";
std::env::set_var(key, "1");
assert!(env_truthy(key));
std::env::set_var(key, "false");
assert!(!env_truthy(key));
std::env::set_var(key, "0");
assert!(!env_truthy(key));
std::env::remove_var(key);
}
#[tokio::test]
#[serial(background_tasks)]
async fn drain_waits_for_tracked_background_tasks_before_returning() {
let active = std::sync::atomic::AtomicUsize::new(0);
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
track_background_task(async move {
let _ = rx.await;
});
assert!(
background_task_count() >= 1,
"track_background_task must make the in-flight task visible immediately"
);
let drain_fut = drain(&active);
tokio::pin!(drain_fut);
let too_early =
tokio::time::timeout(std::time::Duration::from_millis(150), &mut drain_fut).await;
assert!(
too_early.is_err(),
"drain() must not return while a tracked background task is still running"
);
tx.send(())
.expect("tracked task still awaiting the oneshot");
let done = tokio::time::timeout(std::time::Duration::from_secs(5), drain_fut).await;
assert!(
done.is_ok(),
"drain() must return once the tracked background task finishes"
);
}
#[tokio::test]
#[serial(background_tasks)]
async fn track_background_task_count_returns_to_zero_after_completion() {
let before = background_task_count();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
track_background_task(async move {
let _ = rx.await;
});
assert_eq!(background_task_count(), before + 1);
tx.send(()).expect("still awaiting");
for _ in 0..100 {
if background_task_count() == before {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(background_task_count(), before);
}
#[tokio::test]
#[serial(background_tasks)]
async fn track_background_task_count_returns_to_baseline_after_panic() {
let before = background_task_count();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
track_background_task(async move {
let _ = rx.await;
panic!("intentional panic to exercise the Drop-guard decrement path");
});
assert_eq!(background_task_count(), before + 1);
tx.send(()).expect("still awaiting");
for _ in 0..100 {
if background_task_count() == before {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(
background_task_count(),
before,
"background task counter must return to baseline after the tracked future panics"
);
}
#[test]
#[serial(active_phases)]
fn register_active_phase_appears_and_disappears_with_the_guard() {
assert!(
!active_phase_names().contains(&"adr103_test_phase".to_string()),
"must start absent (leaked from a prior failed run would poison this test)"
);
let guard = register_active_phase("adr103_test_phase");
assert!(active_phase_names().contains(&"adr103_test_phase".to_string()));
drop(guard);
assert!(
!active_phase_names().contains(&"adr103_test_phase".to_string()),
"the phase name must drop out of the gauge once its guard is dropped"
);
}
#[test]
#[serial(active_phases)]
fn register_active_phase_counts_concurrent_occurrences_of_the_same_name() {
let first = register_active_phase("adr103_concurrent_phase");
let second = register_active_phase("adr103_concurrent_phase");
assert!(active_phase_names().contains(&"adr103_concurrent_phase".to_string()));
drop(first);
assert!(
active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
"one of two concurrent occurrences ending must not remove the name early"
);
drop(second);
assert!(
!active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
"the name must be removed only once every concurrent occurrence has ended"
);
}
#[derive(Clone)]
struct MockDispatch {
namespace: String,
config_id: String,
dispatch_calls: Arc<std::sync::atomic::AtomicUsize>,
pool: Option<Arc<ConnectionPool>>,
dispatch_err: Option<String>,
}
#[async_trait]
impl DaemonDispatch for MockDispatch {
async fn dispatch(
&self,
_ops: String,
_presentation: Option<String>,
_presentation_per_op: Option<Vec<Option<String>>>,
_format: Option<String>,
_format_per_op: Option<Vec<Option<String>>>,
_from_wire: bool,
_identity: Option<RequestIdentity>,
) -> Result<String, String> {
self.dispatch_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
match &self.dispatch_err {
Some(msg) => Err(msg.clone()),
None => Ok("{}".to_string()),
}
}
async fn warm_all(&self) {}
fn namespace(&self) -> &str {
&self.namespace
}
fn config_id(&self) -> &str {
&self.config_id
}
fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
self.pool.clone()
}
}
fn base_request_frame(config_id: &str) -> DaemonRequestFrame {
DaemonRequestFrame {
ops: String::new(),
presentation: None,
presentation_per_op: None,
namespace: "local".to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
request_id: None,
}
}
async fn round_trip(dispatcher: MockDispatch, req: &DaemonRequestFrame) -> DaemonResponseFrame {
let (mut client, server) = UnixStream::pair().expect("unix stream pair");
let payload = serde_json::to_vec(req).expect("encode request frame");
let handle = tokio::spawn(async move {
handle_conn(server, dispatcher).await;
});
write_frame(&mut client, &payload)
.await
.expect("write request frame");
let raw = read_frame(&mut client).await.expect("read response frame");
handle.await.expect("handle_conn task panicked");
serde_json::from_slice(&raw).expect("decode response frame")
}
#[tokio::test]
async fn metrics_only_frame_returns_snapshot_without_dispatching() {
let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-a".to_string(),
dispatch_calls: Arc::clone(&dispatch_calls),
pool: None,
dispatch_err: None,
};
let mut metrics_req = base_request_frame("cfg-a");
metrics_req.metrics_only = true;
let metrics_resp = round_trip(dispatcher.clone(), &metrics_req).await;
assert!(metrics_resp.ok, "metrics_only response must be ok=true");
assert!(
metrics_resp.metrics.is_some(),
"metrics_only=true must return Some(snapshot)"
);
assert_eq!(
dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"metrics_only must never reach the ops-dispatch path"
);
let mut mismatched_req = base_request_frame("some-other-config");
mismatched_req.metrics_only = true;
let mismatched_resp = round_trip(dispatcher.clone(), &mismatched_req).await;
assert!(mismatched_resp.ok);
assert!(mismatched_resp.metrics.is_some());
assert!(!mismatched_resp.config_mismatch);
assert_eq!(
dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
0,
"a mismatched-config metrics_only request must still skip dispatch"
);
let normal_req = base_request_frame("cfg-a");
let normal_resp = round_trip(dispatcher, &normal_req).await;
assert!(normal_resp.ok);
assert!(normal_resp.metrics.is_none());
assert_eq!(dispatch_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn metrics_snapshot_wal_pages_reflects_recent_write() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("metrics_wal_test.db");
let pool = Arc::new(
ConnectionPool::new(khive_db::PoolConfig {
path: Some(path),
..khive_db::PoolConfig::default()
})
.expect("pool open"),
);
{
let writer = pool.try_writer().expect("writer");
writer
.conn()
.execute_batch(
"CREATE TABLE t (x INTEGER); \
INSERT INTO t VALUES (1); \
INSERT INTO t VALUES (2);",
)
.expect("seed writes");
}
let tick = khive_db::checkpoint_once(
&pool,
&CheckpointConfig::default(),
&mut khive_db::checkpoint::TruncateState::default(),
);
assert!(
matches!(tick, khive_db::CheckpointTick::Observed(_)),
"checkpoint_once on a freshly-writer-held pool must observe, not skip: {tick:?}"
);
let dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-wal".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: Some(pool),
dispatch_err: None,
};
let snapshot = build_metrics_snapshot(&dispatcher);
assert!(
snapshot.wal_pages.is_some(),
"wal_pages must be observed after a real checkpoint tick, got {snapshot:?}"
);
assert_eq!(
snapshot.wal_checkpoint_consecutive_skips, 0,
"an observed (non-skipped) tick must report zero consecutive skips, got {snapshot:?}"
);
}
#[test]
#[serial(tx_registry)]
fn metrics_snapshot_reflects_open_transaction_registry() {
let dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-tx".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: None,
dispatch_err: None,
};
let departing_handle = khive_storage::tx_registry::register(Some(
"daemon_metrics_snapshot_departing_test_tx".to_string(),
));
let before = build_metrics_snapshot(&dispatcher).open_tx_count;
assert!(before >= 1);
let handle = khive_storage::tx_registry::register(Some(
"daemon_metrics_snapshot_owned_test_tx".to_string(),
));
drop(departing_handle);
let during = build_metrics_snapshot(&dispatcher);
assert!(
during.open_tx_count >= 1,
"open_tx_count must reflect the live owned transaction despite registry churn: \
churn_baseline={before} during={}",
during.open_tx_count
);
assert!(
during.oldest_pinned_tx_micros.is_some(),
"oldest_pinned_tx_micros must be Some while a transaction is open"
);
drop(handle);
assert!(
!khive_storage::tx_registry::snapshot()
.iter()
.any(|(_, label)| label.as_deref()
== Some("daemon_metrics_snapshot_owned_test_tx")),
"the owned registry entry must disappear when its handle is dropped"
);
}
#[tokio::test]
async fn metrics_snapshot_write_queue_depth_flag_gated() {
let dir = tempfile::tempdir().expect("tempdir");
let enabled_pool = Arc::new(
ConnectionPool::new(khive_db::PoolConfig {
path: Some(dir.path().join("wq_enabled.db")),
write_queue_enabled: true,
..khive_db::PoolConfig::default()
})
.expect("pool open"),
);
let enabled_dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-wq-on".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: Some(enabled_pool),
dispatch_err: None,
};
let snapshot_on = build_metrics_snapshot(&enabled_dispatcher);
assert!(
snapshot_on.write_queue_depth.is_some(),
"write_queue_depth must be Some when write_queue_enabled=true, got {snapshot_on:?}"
);
assert!(snapshot_on.write_queue_capacity.is_some());
let disabled_pool = Arc::new(
ConnectionPool::new(khive_db::PoolConfig {
path: Some(dir.path().join("wq_disabled.db")),
write_queue_enabled: false,
..khive_db::PoolConfig::default()
})
.expect("pool open"),
);
let disabled_dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-wq-off".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: Some(disabled_pool),
dispatch_err: None,
};
let snapshot_off = build_metrics_snapshot(&disabled_dispatcher);
assert!(
snapshot_off.write_queue_depth.is_none(),
"write_queue_depth must be None when write_queue_enabled=false, got {snapshot_off:?}"
);
assert!(snapshot_off.write_queue_capacity.is_none());
let no_pool_dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-no-pool".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: None,
dispatch_err: None,
};
let snapshot_no_pool = build_metrics_snapshot(&no_pool_dispatcher);
assert!(snapshot_no_pool.write_queue_depth.is_none());
assert!(snapshot_no_pool.write_queue_capacity.is_none());
}
#[test]
fn frame_serde_defaults_metrics_fields_when_absent() {
let req_json = serde_json::json!({
"ops": "",
"presentation": null,
"presentation_per_op": null,
"namespace": "local",
"actor_id": null,
"visible_namespaces": [],
"config_id": "cfg",
"protocol_version": PROTOCOL_VERSION,
"probe_only": false,
"format": null,
"format_per_op": null,
"from_wire": false
});
let frame: DaemonRequestFrame =
serde_json::from_value(req_json).expect("decode a metrics_only-absent request frame");
assert!(
!frame.metrics_only,
"metrics_only must default to false when absent from the wire payload"
);
assert_eq!(
frame.request_id, None,
"request_id must default to None when absent from the wire payload (khive#948)"
);
let resp_json = serde_json::json!({
"ok": true,
"result": null,
"error": null,
"namespace_mismatch": false,
"config_mismatch": false,
"served_config_id": "cfg",
"version_mismatch": false,
"daemon_protocol_version": PROTOCOL_VERSION
});
let resp: DaemonResponseFrame =
serde_json::from_value(resp_json).expect("decode a metrics-absent response frame");
assert!(
resp.metrics.is_none(),
"metrics must default to None when absent from the wire payload"
);
assert_eq!(
resp.request_id, None,
"request_id must default to None when absent from the wire payload (khive#948)"
);
}
#[tokio::test]
async fn request_id_echoed_on_success_and_error_arms() {
let dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-a".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: None,
dispatch_err: None,
};
let mut ok_req = base_request_frame("cfg-a");
ok_req.request_id = Some(42);
let ok_resp = round_trip(dispatcher, &ok_req).await;
assert!(ok_resp.ok, "expected successful dispatch: {ok_resp:?}");
assert_eq!(
ok_resp.request_id,
Some(42),
"request_id must be echoed back on a successful dispatch response"
);
let mismatched_dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-a".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: None,
dispatch_err: None,
};
let mut mismatch_req = base_request_frame("cfg-WRONG");
mismatch_req.request_id = Some(99);
let mismatch_resp = round_trip(mismatched_dispatcher, &mismatch_req).await;
assert!(mismatch_resp.config_mismatch);
assert_eq!(
mismatch_resp.request_id,
Some(99),
"request_id must be echoed on the config_mismatch rejection arm too"
);
let erroring_dispatcher = MockDispatch {
namespace: "local".to_string(),
config_id: "cfg-a".to_string(),
dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
pool: None,
dispatch_err: Some("simulated dispatch error".to_string()),
};
let mut err_req = base_request_frame("cfg-a");
err_req.request_id = Some(7);
let err_resp = round_trip(erroring_dispatcher, &err_req).await;
assert!(!err_resp.ok, "expected a dispatch error: {err_resp:?}");
assert_eq!(
err_resp.request_id,
Some(7),
"request_id must be echoed on the real ops-dispatch error arm"
);
}
#[test]
fn shutdown_cleanup_removes_paths_it_still_owns() {
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let identity = socket_identity(&sock);
assert!(
identity.is_some(),
"must read identity of a freshly bound socket"
);
let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
assert!(
cleaned,
"cleanup must proceed when PID and socket still match"
);
assert!(!sock.exists(), "owned socket must be removed");
assert!(!pid_file.exists(), "owned pid file must be removed");
}
#[test]
fn shutdown_cleanup_skips_when_pid_file_names_a_different_process() {
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
let identity = socket_identity(&sock);
std::fs::write(&pid_file, "1").expect("write foreign pid file");
let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
assert!(
!cleaned,
"cleanup must be skipped when the PID file no longer names this process"
);
assert!(sock.exists(), "replacement daemon's socket must survive");
assert!(
pid_file.exists(),
"replacement daemon's pid file must survive"
);
}
#[test]
fn shutdown_cleanup_skips_when_socket_was_rebound_by_a_replacement() {
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let original_sock = dir.path().join("original.sock");
let pid_file = dir.path().join("khived.pid");
let _original_listener =
std::os::unix::net::UnixListener::bind(&original_sock).expect("bind original socket");
let _replacement_listener =
std::os::unix::net::UnixListener::bind(&sock).expect("bind replacement socket");
let original_identity = socket_identity(&original_sock);
let replacement_identity = socket_identity(&sock);
assert!(
original_identity.is_some(),
"must read identity of the original socket"
);
assert!(
replacement_identity.is_some(),
"must read identity of the replacement socket"
);
assert!(
original_identity != replacement_identity,
"two concurrently bound sockets must have distinct identities"
);
std::fs::write(&pid_file, std::process::id().to_string())
.expect("write pid file matching this process");
let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, original_identity);
assert!(
!cleaned,
"cleanup must be skipped when the socket at this path is a different \
inode than the one this daemon originally bound"
);
assert!(sock.exists(), "replacement daemon's socket must survive");
assert!(
pid_file.exists(),
"replacement daemon's pid file must survive"
);
}
#[test]
#[serial]
fn recovery_lock_serializes_two_concurrent_boot_sequences() {
let dir = tempfile::tempdir().expect("tempdir");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_LOCK", &lock_file);
let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let overlap_detected = Arc::new(std::sync::atomic::AtomicBool::new(false));
let run_one_boot =
|active: Arc<std::sync::atomic::AtomicUsize>,
overlap: Arc<std::sync::atomic::AtomicBool>| {
move || {
let _guard = acquire_recovery_lock().expect("acquire recovery lock");
if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) != 0 {
overlap.store(true, std::sync::atomic::Ordering::SeqCst);
}
std::thread::sleep(std::time::Duration::from_millis(50));
active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
}
};
let t1 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
let t2 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
t1.join().expect("boot thread 1 must not panic");
t2.join().expect("boot thread 2 must not panic");
assert!(
!overlap_detected.load(std::sync::atomic::Ordering::SeqCst),
"two concurrent boot sequences must never hold the schema-init \
critical section at the same time (#667)"
);
std::env::remove_var("KHIVE_LOCK");
}
#[test]
#[serial]
fn acquire_daemon_boot_guard_returns_guard_when_lock_available() {
let dir = tempfile::tempdir().expect("tempdir");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_LOCK", &lock_file);
let guard = acquire_daemon_boot_guard();
assert!(
guard.is_ok(),
"daemon boot guard must succeed when the lock file can be opened and flocked"
);
drop(guard);
std::env::remove_var("KHIVE_LOCK");
}
#[test]
#[serial]
fn acquire_daemon_boot_guard_fails_loudly_when_lock_file_cannot_be_opened() {
let dir = tempfile::tempdir().expect("tempdir");
std::env::set_var("KHIVE_LOCK", dir.path());
let result = acquire_daemon_boot_guard();
assert!(
result.is_err(),
"daemon boot guard must fail loudly, never silently proceed unguarded, \
when the underlying recovery lock cannot be acquired"
);
std::env::remove_var("KHIVE_LOCK");
}
#[test]
fn write_pid_file_exclusive_creates_new_file_with_own_pid() {
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = dir.path().join("khived.pid");
write_pid_file_exclusive(&pid_file).expect("first writer must win");
let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
assert_eq!(contents, std::process::id().to_string());
}
#[test]
fn write_pid_file_exclusive_refuses_to_overwrite_an_existing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = dir.path().join("khived.pid");
std::fs::write(&pid_file, "999999").expect("seed an existing pid file");
let err = write_pid_file_exclusive(&pid_file)
.expect_err("must not silently overwrite an existing pid file");
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
assert_eq!(
contents, "999999",
"an existing pid file must never be truncated by a losing writer"
);
}
#[test]
fn two_concurrent_writers_converge_on_exactly_one_pid_file_owner() {
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = std::sync::Arc::new(dir.path().join("khived.pid"));
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let spawn_writer =
|pid_file: std::sync::Arc<std::path::PathBuf>,
barrier: std::sync::Arc<std::sync::Barrier>| {
std::thread::spawn(move || {
barrier.wait();
write_pid_file_exclusive(&pid_file)
})
};
let t1 = spawn_writer(pid_file.clone(), barrier.clone());
let t2 = spawn_writer(pid_file.clone(), barrier.clone());
let r1 = t1.join().expect("writer 1 must not panic");
let r2 = t2.join().expect("writer 2 must not panic");
let results = [&r1, &r2];
let ok_count = results.iter().filter(|r| r.is_ok()).count();
let already_exists_count = results
.iter()
.filter(|r| matches!(r, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists))
.count();
assert_eq!(
ok_count, 1,
"exactly one of two concurrent writers must win the pid file"
);
assert_eq!(
already_exists_count, 1,
"the other writer must observe AlreadyExists, never a silent overwrite"
);
assert!(pid_file.exists(), "the winner's pid file must exist");
let contents = std::fs::read_to_string(&*pid_file).expect("read pid file");
assert_eq!(
contents,
std::process::id().to_string(),
"the surviving pid file must contain the winner's pid — both threads \
share this process's pid, so an unexpected value would also prove a \
lost/garbled write raced through"
);
}
}