use std::process::Stdio;
use async_trait::async_trait;
use khive_runtime::daemon::{
self, acquire_recovery_lock, env_truthy, pid_path, read_frame, socket_path, write_frame,
DaemonRequestFrame, DaemonResponseFrame, PROTOCOL_VERSION,
};
use rmcp::ErrorData as McpError;
use tokio::net::UnixStream;
use crate::tools::request::RequestParams;
#[cfg(test)]
pub(crate) static KILL_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
pub(crate) static SPAWN_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
pub(crate) static FORCE_PID_IS_DAEMON: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
#[cfg(test)]
pub(crate) static DAEMON_DISPATCH: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
pub(crate) static RECOVERY_RACE_BARRIER: std::sync::Mutex<
Option<std::sync::Arc<tokio::sync::Barrier>>,
> = std::sync::Mutex::new(None);
#[cfg(test)]
pub(crate) static SPAWN_COMMIT_BARRIER: std::sync::Mutex<
Option<std::sync::Arc<tokio::sync::Barrier>>,
> = std::sync::Mutex::new(None);
#[cfg(test)]
pub(crate) fn reset_counters() {
KILL_COUNT.store(0, std::sync::atomic::Ordering::SeqCst);
SPAWN_COUNT.store(0, std::sync::atomic::Ordering::SeqCst);
FORCE_PID_IS_DAEMON.store(false, std::sync::atomic::Ordering::SeqCst);
DAEMON_DISPATCH.store(0, std::sync::atomic::Ordering::SeqCst);
*RECOVERY_RACE_BARRIER
.lock()
.expect("barrier mutex poisoned") = None;
*SPAWN_COMMIT_BARRIER.lock().expect("barrier mutex poisoned") = None;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FallbackReason {
ConfigMismatch,
NamespaceMismatch,
NoSocket,
#[allow(dead_code)]
ParseFailure,
#[allow(dead_code)]
ProtocolMismatch,
}
impl FallbackReason {
fn as_str(self) -> &'static str {
match self {
FallbackReason::ConfigMismatch => "config_mismatch",
FallbackReason::NamespaceMismatch => "namespace_mismatch",
FallbackReason::NoSocket => "no_socket",
FallbackReason::ParseFailure => "parse_failure",
FallbackReason::ProtocolMismatch => "protocol_mismatch",
}
}
fn counter(self) -> &'static std::sync::atomic::AtomicUsize {
match self {
FallbackReason::ConfigMismatch => &FALLBACK_CONFIG_MISMATCH,
FallbackReason::NamespaceMismatch => &FALLBACK_NAMESPACE_MISMATCH,
FallbackReason::NoSocket => &FALLBACK_NO_SOCKET,
FallbackReason::ParseFailure => &FALLBACK_PARSE_FAILURE,
FallbackReason::ProtocolMismatch => &FALLBACK_PROTOCOL_MISMATCH,
}
}
fn severity(self) -> FallbackSeverity {
match self {
FallbackReason::ConfigMismatch | FallbackReason::NamespaceMismatch => {
FallbackSeverity::Illegitimate
}
FallbackReason::ProtocolMismatch | FallbackReason::ParseFailure => {
FallbackSeverity::RolloutTransient
}
FallbackReason::NoSocket => FallbackSeverity::NoDaemon,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FallbackSeverity {
Illegitimate,
RolloutTransient,
NoDaemon,
}
fn is_daemon_strict_mode() -> bool {
env_truthy("KHIVE_DAEMON_STRICT")
}
static FALLBACK_CONFIG_MISMATCH: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
static FALLBACK_NAMESPACE_MISMATCH: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
static FALLBACK_NO_SOCKET: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
static FALLBACK_PARSE_FAILURE: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
static FALLBACK_PROTOCOL_MISMATCH: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
static FALLBACK_STRICT_VIOLATIONS: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
#[allow(dead_code)]
pub(crate) fn fallback_total() -> usize {
use std::sync::atomic::Ordering::SeqCst;
FALLBACK_CONFIG_MISMATCH.load(SeqCst)
+ FALLBACK_NAMESPACE_MISMATCH.load(SeqCst)
+ FALLBACK_NO_SOCKET.load(SeqCst)
+ FALLBACK_PARSE_FAILURE.load(SeqCst)
+ FALLBACK_PROTOCOL_MISMATCH.load(SeqCst)
}
#[allow(dead_code)]
pub(crate) fn fallback_count(reason: FallbackReason) -> usize {
reason.counter().load(std::sync::atomic::Ordering::SeqCst)
}
#[allow(dead_code)]
pub(crate) fn fallback_strict_violations() -> usize {
FALLBACK_STRICT_VIOLATIONS.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(test)]
pub(crate) fn reset_fallback_counters() {
use std::sync::atomic::Ordering::SeqCst;
FALLBACK_CONFIG_MISMATCH.store(0, SeqCst);
FALLBACK_NAMESPACE_MISMATCH.store(0, SeqCst);
FALLBACK_NO_SOCKET.store(0, SeqCst);
FALLBACK_PARSE_FAILURE.store(0, SeqCst);
FALLBACK_PROTOCOL_MISMATCH.store(0, SeqCst);
FALLBACK_STRICT_VIOLATIONS.store(0, SeqCst);
}
fn record_fallback(
reason: FallbackReason,
config_id_client: &str,
config_id_daemon: Option<&str>,
namespace_client: &str,
) {
reason
.counter()
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let strict_violation =
reason.severity() == FallbackSeverity::Illegitimate && is_daemon_strict_mode();
if strict_violation {
FALLBACK_STRICT_VIOLATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
tracing::error!(
reason = reason.as_str(),
config_id_client,
config_id_daemon = config_id_daemon.unwrap_or("none"),
namespace_client,
pid = std::process::id(),
strict = true,
"daemon_fallback"
);
} else {
tracing::warn!(
reason = reason.as_str(),
config_id_client,
config_id_daemon = config_id_daemon.unwrap_or("none"),
namespace_client,
pid = std::process::id(),
"daemon_fallback"
);
}
}
#[async_trait]
impl daemon::DaemonDispatch for crate::server::KhiveMcpServer {
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<khive_runtime::RequestIdentity>,
) -> Result<String, String> {
let params = RequestParams {
ops,
presentation,
presentation_per_op,
save_to: None,
format,
format_per_op,
};
self.dispatch_request_inner(params, from_wire, identity)
.await
.map_err(|e| e.message.to_string())
}
async fn warm_all(&self) {
crate::server::KhiveMcpServer::warm_all(self).await;
}
fn namespace(&self) -> &str {
self.default_namespace()
}
fn config_id(&self) -> &str {
crate::server::KhiveMcpServer::config_id(self)
}
fn pool_for_checkpoint(&self) -> Option<std::sync::Arc<khive_db::ConnectionPool>> {
self.pool()
}
fn event_store_for_checkpoint(&self) -> Option<std::sync::Arc<dyn khive_storage::EventStore>> {
self.event_store()
}
}
enum ForwardOutcome {
Response(Box<DaemonResponseFrame>),
NoSocket,
ParseFailure,
ProtocolMismatch,
}
async fn try_forward_inner(frame: &DaemonRequestFrame) -> ForwardOutcome {
let sock = socket_path();
let mut stream = match UnixStream::connect(&sock).await {
Ok(s) => s,
Err(_) => return ForwardOutcome::NoSocket,
};
let payload = match serde_json::to_vec(frame) {
Ok(p) => p,
Err(_) => return ForwardOutcome::NoSocket,
};
if write_frame(&mut stream, &payload).await.is_err() {
return ForwardOutcome::NoSocket;
}
let resp = match read_frame(&mut stream).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(
error = %e,
"daemon closed connection without sending a response \
(crash during dispatch?) — treating as stale"
);
return ForwardOutcome::ParseFailure;
}
};
match serde_json::from_slice::<DaemonResponseFrame>(&resp) {
Ok(frame) => {
let is_stale_daemon = frame.daemon_protocol_version != PROTOCOL_VERSION
&& (!frame.version_mismatch || frame.daemon_protocol_version < PROTOCOL_VERSION);
if is_stale_daemon {
tracing::warn!(
daemon_version = frame.daemon_protocol_version,
expected = PROTOCOL_VERSION,
explicit_mismatch = frame.version_mismatch,
"daemon protocol version mismatch (stale daemon) — treating as stale",
);
return ForwardOutcome::ProtocolMismatch;
}
ForwardOutcome::Response(Box::new(frame))
}
Err(e) => {
tracing::warn!(
error = %e,
bytes = resp.len(),
"daemon response could not be decoded — stale daemon binary on {}?",
sock.display()
);
ForwardOutcome::ParseFailure
}
}
}
fn map_response(
resp: DaemonResponseFrame,
expected_config_id: &str,
namespace_client: &str,
) -> Option<Result<String, McpError>> {
if resp.version_mismatch {
let msg = resp.error.unwrap_or_else(|| {
format!(
"daemon protocol mismatch: client={} daemon={} — \
rebuild/update the client binary (make local)",
PROTOCOL_VERSION, resp.daemon_protocol_version,
)
});
return Some(Err(McpError::internal_error(msg, None)));
}
if resp.namespace_mismatch {
record_fallback(
FallbackReason::NamespaceMismatch,
expected_config_id,
resp.served_config_id.as_deref(),
namespace_client,
);
return None;
}
if resp.config_mismatch {
record_fallback(
FallbackReason::ConfigMismatch,
expected_config_id,
resp.served_config_id.as_deref(),
namespace_client,
);
return None;
}
if resp.served_config_id.as_deref() != Some(expected_config_id) {
record_fallback(
FallbackReason::ConfigMismatch,
expected_config_id,
resp.served_config_id.as_deref(),
namespace_client,
);
return None;
}
if resp.ok {
Some(Ok(resp.result.unwrap_or_default()))
} else {
let msg = resp.error.unwrap_or_else(|| {
format!(
"daemon returned an error without a message \
(code: internal_error; daemon config: {})",
resp.served_config_id.as_deref().unwrap_or("unknown"),
)
});
Some(Err(McpError::internal_error(msg, None)))
}
}
const DAEMON_LOG_MAX_BYTES: u64 = 16 * 1024 * 1024;
fn daemon_log_path_from_home(home: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> {
let home = home?;
Some(
std::path::Path::new(home)
.join(".khive")
.join("logs")
.join("khived.log"),
)
}
fn daemon_log_path() -> Option<std::path::PathBuf> {
daemon_log_path_from_home(std::env::var_os("HOME").as_deref())
}
fn daemon_log_should_rotate(current_size: u64, cap: u64) -> bool {
current_size >= cap
}
fn prepare_daemon_log_file_with_cap(log_path: &std::path::Path, cap: u64) -> Option<std::fs::File> {
let dir = log_path.parent()?;
std::fs::create_dir_all(dir).ok()?;
if let Ok(meta) = std::fs::metadata(log_path) {
if daemon_log_should_rotate(meta.len(), cap) {
let backup = dir.join("khived.log.1");
let _ = std::fs::rename(log_path, &backup);
}
}
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.ok()
}
fn prepare_daemon_log_file(log_path: &std::path::Path) -> Option<std::fs::File> {
prepare_daemon_log_file_with_cap(log_path, DAEMON_LOG_MAX_BYTES)
}
fn spawn_daemon() -> std::io::Result<()> {
#[cfg(test)]
SPAWN_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let exe = std::env::current_exe()?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("mcp")
.arg("--daemon")
.stdin(Stdio::null())
.stdout(Stdio::null());
match daemon_log_path().and_then(|path| prepare_daemon_log_file(&path)) {
Some(file) => {
cmd.stderr(file);
}
None => {
cmd.stderr(Stdio::null());
}
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
cmd.spawn()?;
Ok(())
}
fn argv_is_khive_daemon(args: &str) -> bool {
let mut tokens = args.split_whitespace();
let Some(exe_token) = tokens.next() else {
return false;
};
let basename = std::path::Path::new(exe_token)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if basename != "kkernel" && basename != "kkernel-bench" {
return false;
}
let rest: Vec<&str> = tokens.collect();
rest.contains(&"mcp") && rest.contains(&"--daemon")
}
fn pid_is_khive_daemon(pid: u32) -> bool {
let Ok(pid_i32) = i32::try_from(pid) else {
return false;
};
if pid_i32 <= 0 {
return false;
}
#[cfg(test)]
if FORCE_PID_IS_DAEMON.load(std::sync::atomic::Ordering::SeqCst) {
return unsafe { libc::kill(pid_i32, 0) } == 0;
}
if unsafe { libc::kill(pid_i32, 0) } != 0 {
return false;
}
match std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "args="])
.output()
{
Ok(out) if out.status.success() => {
let args = String::from_utf8_lossy(&out.stdout);
argv_is_khive_daemon(args.trim())
}
_ => false,
}
}
fn kill_stale_daemon_inner() {
#[cfg(test)]
KILL_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let pid_file = pid_path();
let expected_pid = std::fs::read_to_string(&pid_file)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
if let Some(pid) = expected_pid {
if pid_is_khive_daemon(pid) {
if let Ok(signed) = i32::try_from(pid) {
if signed > 0 {
unsafe {
libc::kill(signed, libc::SIGTERM);
}
}
}
} else {
tracing::warn!(
pid,
"PID in daemon file does not belong to a khive daemon — skipping SIGTERM"
);
}
}
remove_daemon_paths_if_still_stale(&pid_file, expected_pid);
}
fn remove_daemon_paths_if_still_stale(pid_file: &std::path::Path, expected_pid: Option<u32>) {
let current_pid = std::fs::read_to_string(pid_file)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok());
if current_pid != expected_pid {
tracing::warn!(
expected_pid = ?expected_pid,
current_pid = ?current_pid,
"pid file changed during stale-daemon cleanup — a replacement daemon \
already claimed it; skipping unlink to avoid deleting its live paths"
);
return;
}
let sock = socket_path();
if std::os::unix::net::UnixStream::connect(&sock).is_ok() {
tracing::warn!(
socket = ?sock,
"a live listener now answers the daemon socket — skipping unlink to \
avoid deleting a replacement daemon's rendezvous"
);
return;
}
let _ = std::fs::remove_file(pid_file);
let _ = std::fs::remove_file(&sock);
}
#[derive(Debug)]
enum ProbeOutcome {
Alive,
Dead,
Timeout,
LockContended,
}
async fn probe_daemon_identity(config_id: &str, namespace: &str, timeout_ms: u64) -> ProbeOutcome {
let probe = DaemonRequestFrame {
ops: String::new(),
presentation: None,
presentation_per_op: None,
namespace: namespace.to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_only: true,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let deadline = std::time::Duration::from_millis(timeout_ms);
match tokio::time::timeout(deadline, try_forward_inner(&probe)).await {
Err(_elapsed) => {
tracing::debug!(
timeout_ms,
"under-lock probe timed out — daemon may be busy; skipping kill"
);
ProbeOutcome::Timeout
}
Ok(ForwardOutcome::Response(resp)) => {
let is_probe_ack = resp.ok && resp.result.is_none() && resp.error.is_none();
if is_probe_ack
&& !resp.version_mismatch
&& !resp.namespace_mismatch
&& !resp.config_mismatch
&& resp.daemon_protocol_version == PROTOCOL_VERSION
&& resp.served_config_id.as_deref() == Some(config_id)
{
tracing::debug!("under-lock probe: live matching daemon confirmed; skipping kill");
ProbeOutcome::Alive
} else {
tracing::debug!(
is_probe_ack,
version_mismatch = resp.version_mismatch,
namespace_mismatch = resp.namespace_mismatch,
config_mismatch = resp.config_mismatch,
"under-lock probe: daemon did not return probe-ack or identity mismatch — will kill+spawn"
);
ProbeOutcome::Dead
}
}
Ok(
ForwardOutcome::NoSocket
| ForwardOutcome::ParseFailure
| ForwardOutcome::ProtocolMismatch,
) => ProbeOutcome::Dead,
}
}
#[derive(Debug)]
enum RecoveryOutcome {
Skipped,
Spawned,
Uncertain,
}
const DEAD_CONFIRM_ROUNDS: u32 = 4;
const DEAD_CONFIRM_POLL_MS: u64 = 75;
const BOOT_QUIESCENCE_LOCK_TIMEOUT_MS: u64 = 500;
async fn quiesce_then_probe_identity(
config_id: &str,
namespace: &str,
timeout_ms: u64,
) -> ProbeOutcome {
let deadline = std::time::Instant::now()
+ std::time::Duration::from_millis(BOOT_QUIESCENCE_LOCK_TIMEOUT_MS);
match tokio::task::spawn_blocking(move || {
khive_runtime::daemon::try_acquire_daemon_boot_guard_until(deadline)
})
.await
{
Ok(Ok(Some(guard))) => drop(guard),
Ok(Ok(None)) => {
tracing::debug!(
BOOT_QUIESCENCE_LOCK_TIMEOUT_MS,
"boot/recovery lock still contended past its bounded wait; \
could not confirm quiescence this round"
);
return ProbeOutcome::LockContended;
}
Ok(Err(e)) => {
tracing::warn!(error = %e, "failed to probe boot/recovery lock state");
return ProbeOutcome::LockContended;
}
Err(_join_err) => return ProbeOutcome::LockContended,
}
probe_daemon_identity(config_id, namespace, timeout_ms).await
}
async fn confirm_genuinely_dead(config_id: &str, namespace: &str) -> ProbeOutcome {
let mut saw_contention = false;
for round in 0..DEAD_CONFIRM_ROUNDS {
match quiesce_then_probe_identity(config_id, namespace, BOOT_FENCE_PROBE_TIMEOUT_MS).await {
ProbeOutcome::Dead => {}
ProbeOutcome::LockContended => saw_contention = true,
other => return other,
}
if round + 1 < DEAD_CONFIRM_ROUNDS {
tokio::time::sleep(std::time::Duration::from_millis(DEAD_CONFIRM_POLL_MS)).await;
}
}
if saw_contention {
ProbeOutcome::LockContended
} else {
ProbeOutcome::Dead
}
}
const RECOVERER_LOCK_TIMEOUT_MS: u64 = 8_000;
async fn kill_and_respawn(config_id: &str, namespace: &str) -> std::io::Result<RecoveryOutcome> {
let initial_probe = {
let _lock = acquire_recovery_lock();
probe_daemon_identity(config_id, namespace, 500).await
};
match initial_probe {
ProbeOutcome::Alive | ProbeOutcome::Timeout | ProbeOutcome::LockContended => {
return Ok(RecoveryOutcome::Skipped);
}
ProbeOutcome::Dead => {}
}
#[cfg(test)]
{
let barrier = RECOVERY_RACE_BARRIER
.lock()
.expect("barrier mutex poisoned")
.clone();
if let Some(barrier) = barrier {
barrier.wait().await;
}
}
let recoverer_deadline =
std::time::Instant::now() + std::time::Duration::from_millis(RECOVERER_LOCK_TIMEOUT_MS);
let recoverer_guard = match tokio::time::timeout(
std::time::Duration::from_millis(RECOVERER_LOCK_TIMEOUT_MS),
tokio::task::spawn_blocking(move || {
khive_runtime::daemon::try_acquire_recoverer_lock_until(recoverer_deadline)
}),
)
.await
{
Ok(Ok(Ok(Some(guard)))) => guard,
Ok(Ok(Ok(None))) => {
tracing::warn!(
RECOVERER_LOCK_TIMEOUT_MS,
"recoverer lock still contended past its deadline; a peer recoverer \
is likely still mid dead-confirmation/kill/spawn — skipping without \
a positive confirmation rather than risking a double-spawn"
);
return Ok(RecoveryOutcome::Uncertain);
}
Ok(Ok(Err(e))) => {
tracing::warn!(error = %e, "failed to acquire recoverer lock");
return Ok(RecoveryOutcome::Uncertain);
}
Ok(Err(_)) | Err(_) => {
tracing::warn!("recoverer lock acquisition task failed or exceeded its deadline");
return Ok(RecoveryOutcome::Uncertain);
}
};
let outcome = match confirm_genuinely_dead(config_id, namespace).await {
ProbeOutcome::Alive | ProbeOutcome::Timeout => Ok(RecoveryOutcome::Skipped),
ProbeOutcome::LockContended => {
tracing::warn!(
"confirm_genuinely_dead could not establish quiescence within its \
bounded rounds; skipping kill+spawn without a positive confirmation"
);
Ok(RecoveryOutcome::Uncertain)
}
ProbeOutcome::Dead => {
#[cfg(test)]
{
let barrier = SPAWN_COMMIT_BARRIER
.lock()
.expect("barrier mutex poisoned")
.clone();
if let Some(barrier) = barrier {
let _ =
tokio::time::timeout(std::time::Duration::from_millis(80), barrier.wait())
.await;
}
}
let _boot_lock = acquire_recovery_lock();
kill_stale_daemon_inner();
spawn_daemon().map(|()| RecoveryOutcome::Spawned)
}
};
drop(recoverer_guard);
outcome
}
fn ambiguous_forward_error() -> McpError {
McpError::internal_error(
"daemon response lost after request was sent; not retrying or locally \
dispatching to avoid duplicate execution",
None,
)
}
static PENDING_SELF_HEAL: std::sync::Mutex<Option<MismatchRecovery>> = std::sync::Mutex::new(None);
fn arm_pending_self_heal(action: MismatchRecovery) {
let mut slot = PENDING_SELF_HEAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*slot = Some(action);
}
#[cfg(unix)]
pub(crate) fn fire_pending_self_heal() {
let action = PENDING_SELF_HEAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
match action {
Some(MismatchRecovery::ReexecScheduled) => reexec_in_place(),
Some(MismatchRecovery::DrainAndExit) => exit_process(),
None => {}
}
}
#[cfg(not(unix))]
pub(crate) fn fire_pending_self_heal() {
let action = PENDING_SELF_HEAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
if action.is_some() {
exit_process();
}
}
const RESUMED_GENERATION_ARG_PREFIX: &str = "--resumed-generation=";
pub(crate) fn resumed_generation() -> Option<u32> {
resumed_generation_from_args(std::env::args())
}
fn resumed_generation_from_args(args: impl Iterator<Item = String>) -> Option<u32> {
args.filter_map(|a| {
a.strip_prefix(RESUMED_GENERATION_ARG_PREFIX)
.map(str::to_owned)
})
.last()
.and_then(|s| s.parse::<u32>().ok())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MismatchRecovery {
ReexecScheduled,
DrainAndExit,
}
fn decide_mismatch_recovery(resumed_generation: Option<u32>) -> MismatchRecovery {
match resumed_generation {
None => MismatchRecovery::ReexecScheduled,
Some(_) => MismatchRecovery::DrainAndExit,
}
}
fn trigger_bridge_self_heal() {
match decide_mismatch_recovery(resumed_generation()) {
MismatchRecovery::ReexecScheduled => schedule_reexec_on_mismatch(),
MismatchRecovery::DrainAndExit => {
tracing::warn!(
"resumed generation observed ProtocolMismatch again — loop-breaker \
tripped (#714 §2.2, exec-once guard); draining and exiting instead \
of re-exec'ing a second time"
);
schedule_drain_and_exit();
}
}
}
#[cfg(unix)]
pub(crate) fn schedule_reexec_on_mismatch() {
tracing::warn!(
client_version = PROTOCOL_VERSION,
"protocol mismatch: arming in-place re-exec of the freshest on-disk binary, \
to fire once the mismatch response has flushed to the client"
);
arm_pending_self_heal(MismatchRecovery::ReexecScheduled);
}
#[cfg(not(unix))]
pub(crate) fn schedule_reexec_on_mismatch() {
schedule_drain_and_exit();
}
#[cfg(all(unix, not(test)))]
fn reexec_in_place() {
use std::os::unix::process::CommandExt;
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
tracing::error!(error = %e, "bridge self-heal re-exec failed: could not resolve current_exe");
return;
}
};
let args: Vec<String> = std::env::args()
.skip(1)
.filter(|a| !a.starts_with(RESUMED_GENERATION_ARG_PREFIX))
.chain(std::iter::once(format!("{RESUMED_GENERATION_ARG_PREFIX}1")))
.collect();
let err = std::process::Command::new(exe).args(&args).exec();
tracing::error!(
error = %err,
"bridge self-heal re-exec failed; continuing under the stale binary"
);
}
pub(crate) fn schedule_drain_and_exit() {
arm_pending_self_heal(MismatchRecovery::DrainAndExit);
}
#[cfg(not(test))]
fn exit_process() {
std::process::exit(1);
}
#[cfg(all(test, unix))]
pub(crate) static REEXEC_INVOKED_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
pub(crate) static DRAIN_EXIT_INVOKED_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
#[cfg(all(test, unix))]
pub(crate) fn reset_self_heal_counters() {
REEXEC_INVOKED_COUNT.store(0, std::sync::atomic::Ordering::SeqCst);
DRAIN_EXIT_INVOKED_COUNT.store(0, std::sync::atomic::Ordering::SeqCst);
clear_pending_self_heal();
}
#[cfg(all(test, not(unix)))]
pub(crate) fn reset_self_heal_counters() {
DRAIN_EXIT_INVOKED_COUNT.store(0, std::sync::atomic::Ordering::SeqCst);
clear_pending_self_heal();
}
#[cfg(test)]
fn clear_pending_self_heal() {
*PENDING_SELF_HEAL
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
#[cfg(all(test, unix))]
fn reexec_in_place() {
REEXEC_INVOKED_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
pub(crate) struct SelfHealOnFlushTransport<T> {
inner: T,
}
impl<T> SelfHealOnFlushTransport<T> {
pub(crate) fn new(inner: T) -> Self {
Self { inner }
}
}
impl<T> rmcp::transport::Transport<rmcp::RoleServer> for SelfHealOnFlushTransport<T>
where
T: rmcp::transport::Transport<rmcp::RoleServer>,
{
type Error = T::Error;
fn send(
&mut self,
item: rmcp::service::TxJsonRpcMessage<rmcp::RoleServer>,
) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send + 'static {
let send = self.inner.send(item);
async move {
let result = send.await;
if result.is_ok() {
fire_pending_self_heal();
}
result
}
}
fn receive(
&mut self,
) -> impl std::future::Future<Output = Option<rmcp::service::RxJsonRpcMessage<rmcp::RoleServer>>>
+ Send {
self.inner.receive()
}
fn close(&mut self) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
self.inner.close()
}
}
#[cfg(test)]
fn exit_process() {
DRAIN_EXIT_INVOKED_COUNT.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
const BOOT_FENCE_PROBE_TIMEOUT_MS: u64 = 500;
enum BootFenceOutcome {
DaemonReady,
SafeLocalFallback,
HardError(McpError),
}
async fn wait_for_boot_quiescence_then_reprobe(frame: &DaemonRequestFrame) -> BootFenceOutcome {
let quiesced =
tokio::task::spawn_blocking(khive_runtime::daemon::acquire_daemon_boot_guard).await;
match quiesced {
Ok(Ok(guard)) => {
drop(guard);
}
Ok(Err(e)) => {
return BootFenceOutcome::HardError(McpError::internal_error(
format!(
"failed to acquire daemon boot/recovery lock while waiting for \
cold-boot quiescence: {e}"
),
None,
));
}
Err(e) => {
return BootFenceOutcome::HardError(McpError::internal_error(
format!("boot-quiescence wait task failed: {e}"),
None,
));
}
}
match probe_daemon_identity(
&frame.config_id,
&frame.namespace,
BOOT_FENCE_PROBE_TIMEOUT_MS,
)
.await
{
ProbeOutcome::Alive => BootFenceOutcome::DaemonReady,
ProbeOutcome::Dead => BootFenceOutcome::SafeLocalFallback,
ProbeOutcome::Timeout => BootFenceOutcome::HardError(McpError::internal_error(
"daemon state uncertain after cold-boot quiescence; not falling back to \
local dispatch to avoid racing a possibly still-initializing index",
None,
)),
ProbeOutcome::LockContended => BootFenceOutcome::HardError(McpError::internal_error(
"daemon state uncertain after cold-boot quiescence (lock probe unexpectedly \
contended); not falling back to local dispatch",
None,
)),
}
}
pub async fn forward_or_spawn(frame: &DaemonRequestFrame) -> Option<Result<String, McpError>> {
if env_truthy("KHIVE_NO_DAEMON") {
return None;
}
match try_forward_inner(frame).await {
ForwardOutcome::Response(resp) => {
return map_response(*resp, &frame.config_id, &frame.namespace)
}
ForwardOutcome::NoSocket => {
}
ForwardOutcome::ParseFailure => {
tracing::warn!(
config_id = %frame.config_id,
namespace = %frame.namespace,
retry_suppressed = true,
"daemon connection lost after the request was fully written — \
not retrying or falling back locally to avoid duplicate dispatch"
);
return Some(Err(ambiguous_forward_error()));
}
ForwardOutcome::ProtocolMismatch => {
tracing::warn!(
config_id = %frame.config_id,
namespace = %frame.namespace,
retry_suppressed = true,
"daemon protocol mismatch discovered after the request was fully \
written — not retrying or falling back locally to avoid duplicate dispatch"
);
trigger_bridge_self_heal();
return Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
run `make local` to rebuild the daemon binary"
),
None,
)));
}
}
match kill_and_respawn(&frame.config_id, &frame.namespace).await {
Err(e) => {
tracing::warn!(error = %e, "failed to spawn/recover the daemon; falling back to local dispatch");
record_fallback(
FallbackReason::NoSocket,
&frame.config_id,
None,
&frame.namespace,
);
return None;
}
Ok(RecoveryOutcome::Skipped) => {
}
Ok(RecoveryOutcome::Spawned) => {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
Ok(RecoveryOutcome::Uncertain) => {
tracing::debug!(
"daemon recovery state uncertain; forwarding without a fresh kill+spawn"
);
}
}
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if tokio::time::Instant::now() >= deadline {
match wait_for_boot_quiescence_then_reprobe(frame).await {
BootFenceOutcome::DaemonReady => {
}
BootFenceOutcome::SafeLocalFallback => {
record_fallback(
FallbackReason::NoSocket,
&frame.config_id,
None,
&frame.namespace,
);
return None;
}
BootFenceOutcome::HardError(err) => return Some(Err(err)),
}
}
match try_forward_inner(frame).await {
ForwardOutcome::Response(resp) => {
return map_response(*resp, &frame.config_id, &frame.namespace)
}
ForwardOutcome::ParseFailure => {
tracing::warn!(
config_id = %frame.config_id,
namespace = %frame.namespace,
retry_suppressed = true,
"freshly-established daemon connection lost after the request \
was fully written — not retrying or falling back locally"
);
return Some(Err(ambiguous_forward_error()));
}
ForwardOutcome::ProtocolMismatch => {
tracing::warn!(
config_id = %frame.config_id,
namespace = %frame.namespace,
"daemon protocol mismatch discovered on the post-recovery retry \
— not retrying again or falling back locally"
);
trigger_bridge_self_heal();
return Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
run `make local` to rebuild the daemon binary"
),
None,
)));
}
ForwardOutcome::NoSocket => {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use khive_runtime::daemon::run_daemon;
use serial_test::serial;
use khive_runtime::engine_config::ActorConfig;
use khive_runtime::{
runtime_config_from_khive_config, KhiveConfig, KhiveRuntime, Namespace, RuntimeConfig,
};
fn memory_runtime_config() -> RuntimeConfig {
KhiveRuntime::memory()
.expect("memory runtime")
.config()
.clone()
}
fn make_test_server() -> crate::server::KhiveMcpServer {
let mut config = memory_runtime_config();
config.default_namespace = Namespace::parse("test").unwrap();
config.packs = vec!["kg".to_string(), "gtd".to_string()];
let runtime = KhiveRuntime::new(config).expect("in-memory runtime");
crate::server::KhiveMcpServer::new(runtime).expect("server builds with kg+gtd")
}
fn make_subhandler_test_server() -> crate::server::KhiveMcpServer {
let mut config = memory_runtime_config();
config.default_namespace = Namespace::parse("braintest").unwrap();
config.packs = vec!["kg".to_string(), "brain".to_string()];
let runtime = KhiveRuntime::new(config).expect("in-memory runtime");
crate::server::KhiveMcpServer::new(runtime).expect("server builds with kg+brain")
}
fn make_comm_test_server(actor_id: Option<&str>) -> crate::server::KhiveMcpServer {
let mut config = memory_runtime_config();
config.default_namespace = Namespace::parse("test").unwrap();
config.packs = vec!["kg".to_string(), "comm".to_string()];
config.actor_id = actor_id.map(str::to_string);
let runtime = KhiveRuntime::new(config).expect("in-memory runtime");
crate::server::KhiveMcpServer::new(runtime).expect("server builds with kg+comm")
}
fn folded_actor_memory_config(actor: &str) -> RuntimeConfig {
runtime_config_from_khive_config(
&KhiveConfig {
actor: ActorConfig {
id: Some(actor.to_string()),
..ActorConfig::default()
},
..KhiveConfig::default()
},
memory_runtime_config(),
)
}
fn clear_daemon_env() {
std::env::remove_var("KHIVE_SOCKET");
std::env::remove_var("KHIVE_PID");
std::env::remove_var("KHIVE_NO_DAEMON");
std::env::remove_var("KHIVE_LOCK");
std::env::remove_var("KHIVE_RECOVERER_LOCK");
}
async fn connect_when_ready(sock: &std::path::Path) -> UnixStream {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if let Ok(s) = UnixStream::connect(sock).await {
return s;
}
assert!(
tokio::time::Instant::now() < deadline,
"daemon never bound {sock:?} within 5s"
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
}
async fn exchange(sock: &std::path::Path, frame: &DaemonRequestFrame) -> DaemonResponseFrame {
let mut stream = UnixStream::connect(sock)
.await
.expect("connect to daemon socket");
let payload = serde_json::to_vec(frame).expect("serialize request frame");
write_frame(&mut stream, &payload)
.await
.expect("write request frame");
let resp = read_frame(&mut stream).await.expect("read response frame");
serde_json::from_slice(&resp).expect("decode response frame")
}
const CFG: &str = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
const NS: &str = "test";
fn frame_ok(result: &str) -> DaemonResponseFrame {
DaemonResponseFrame {
ok: true,
result: Some(result.to_string()),
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(CFG.to_string()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
}
}
fn frame_err(error: Option<&str>) -> DaemonResponseFrame {
DaemonResponseFrame {
ok: false,
result: None,
error: error.map(str::to_string),
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(CFG.to_string()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
}
}
#[test]
#[serial]
fn map_response_namespace_mismatch_yields_none() {
reset_fallback_counters();
let resp = DaemonResponseFrame {
ok: false,
result: None,
error: None,
namespace_mismatch: true,
config_mismatch: false,
served_config_id: Some(CFG.to_string()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
};
assert!(map_response(resp, CFG, NS).is_none());
assert_eq!(fallback_count(FallbackReason::NamespaceMismatch), 1);
assert_eq!(fallback_count(FallbackReason::ConfigMismatch), 0);
assert_eq!(fallback_total(), 1);
}
#[test]
#[serial]
fn map_response_config_mismatch_yields_none() {
reset_fallback_counters();
let resp = DaemonResponseFrame {
ok: false,
result: None,
error: None,
namespace_mismatch: false,
config_mismatch: true,
served_config_id: Some(CFG.to_string()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
};
assert!(map_response(resp, CFG, NS).is_none());
assert_eq!(fallback_count(FallbackReason::ConfigMismatch), 1);
assert_eq!(fallback_count(FallbackReason::NamespaceMismatch), 0);
assert_eq!(fallback_total(), 1);
}
#[test]
#[serial]
fn map_response_legacy_daemon_missing_echo_yields_none() {
reset_fallback_counters();
let resp = DaemonResponseFrame {
ok: true,
result: Some("served-by-broad-registry".to_string()),
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: None,
version_mismatch: false,
daemon_protocol_version: 0,
metrics: None,
};
assert!(map_response(resp, CFG, NS).is_none());
assert_eq!(fallback_count(FallbackReason::ConfigMismatch), 1);
assert_eq!(fallback_total(), 1);
}
#[test]
#[serial]
fn map_response_echo_drift_yields_none() {
reset_fallback_counters();
let resp = DaemonResponseFrame {
ok: true,
result: Some("served-by-other-config".to_string()),
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(
"packs=[kg,gtd];db=/x;embed=none;extra=[];backend=main".to_string(),
),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
};
assert!(map_response(resp, CFG, NS).is_none());
assert_eq!(fallback_count(FallbackReason::ConfigMismatch), 1);
assert_eq!(fallback_total(), 1);
}
#[test]
fn map_response_ok_with_result_yields_some_ok() {
match map_response(frame_ok("the-result"), CFG, NS) {
Some(Ok(s)) => assert_eq!(s, "the-result"),
other => panic!("expected Some(Ok(\"the-result\")), got {other:?}"),
}
}
#[test]
fn map_response_ok_with_no_result_yields_some_ok_empty() {
let resp = DaemonResponseFrame {
ok: true,
result: None,
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(CFG.to_string()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
};
match map_response(resp, CFG, NS) {
Some(Ok(s)) => assert_eq!(s, ""),
other => panic!("expected Some(Ok(\"\")), got {other:?}"),
}
}
#[test]
fn map_response_not_ok_yields_some_err_preserving_message() {
match map_response(frame_err(Some("boom: bad verb")), CFG, NS) {
Some(Err(McpError { message, .. })) => {
assert!(message.contains("boom: bad verb"), "got: {message}");
}
other => panic!("expected Some(Err(..)), got {other:?}"),
}
}
#[test]
fn map_response_not_ok_without_message_yields_contextual_err() {
match map_response(frame_err(None), CFG, NS) {
Some(Err(McpError { message, .. })) => {
assert!(!message.is_empty(), "fallback message must not be empty");
assert!(
message.contains("daemon returned an error"),
"fallback must say 'daemon returned an error'; got: {message}"
);
}
other => panic!("expected Some(Err(..)), got {other:?}"),
}
}
#[test]
fn map_response_version_mismatch_yields_explicit_error() {
let resp = DaemonResponseFrame {
ok: false,
result: None,
error: Some("daemon protocol mismatch: client=0 daemon=1 — rebuild/update the client binary (make local)".to_string()),
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(CFG.to_string()),
version_mismatch: true,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
};
match map_response(resp, CFG, NS) {
Some(Err(McpError { message, .. })) => {
assert!(
message.contains("protocol mismatch"),
"version mismatch error must name the mismatch; got: {message}"
);
assert!(
message.contains("make local"),
"version mismatch error must tell the operator what to do; got: {message}"
);
}
other => panic!("expected Some(Err(..)): got {other:?}"),
}
}
#[test]
fn map_response_version_mismatch_without_error_field_synthesizes_message() {
let resp = DaemonResponseFrame {
ok: false,
result: None,
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(CFG.to_string()),
version_mismatch: true,
daemon_protocol_version: 99,
metrics: None,
};
match map_response(resp, CFG, NS) {
Some(Err(McpError { message, .. })) => {
assert!(
message.contains("protocol mismatch"),
"synthesized message must name the mismatch; got: {message}"
);
assert!(
message.contains("99"),
"synthesized message must include daemon version; got: {message}"
);
}
other => panic!("expected Some(Err(..)): got {other:?}"),
}
}
#[test]
#[serial]
fn record_fallback_no_socket_increments_matching_counter_and_total() {
reset_fallback_counters();
record_fallback(FallbackReason::NoSocket, CFG, None, NS);
assert_eq!(fallback_count(FallbackReason::NoSocket), 1);
assert_eq!(fallback_count(FallbackReason::ParseFailure), 0);
assert_eq!(fallback_count(FallbackReason::ProtocolMismatch), 0);
assert_eq!(fallback_count(FallbackReason::ConfigMismatch), 0);
assert_eq!(fallback_count(FallbackReason::NamespaceMismatch), 0);
assert_eq!(fallback_total(), 1);
}
#[test]
#[serial]
fn record_fallback_parse_failure_increments_matching_counter_and_total() {
reset_fallback_counters();
record_fallback(FallbackReason::ParseFailure, CFG, None, NS);
assert_eq!(fallback_count(FallbackReason::ParseFailure), 1);
assert_eq!(fallback_count(FallbackReason::NoSocket), 0);
assert_eq!(fallback_total(), 1);
}
#[test]
#[serial]
fn record_fallback_protocol_mismatch_increments_matching_counter_and_total() {
reset_fallback_counters();
record_fallback(FallbackReason::ProtocolMismatch, CFG, None, NS);
assert_eq!(fallback_count(FallbackReason::ProtocolMismatch), 1);
assert_eq!(fallback_count(FallbackReason::ParseFailure), 0);
assert_eq!(fallback_total(), 1);
}
#[test]
#[serial]
fn record_fallback_config_id_daemon_defaults_to_none_literal_when_absent() {
reset_fallback_counters();
record_fallback(FallbackReason::NoSocket, CFG, None, NS);
assert_eq!(fallback_total(), 1);
}
#[test]
#[serial]
fn fallback_total_sums_all_reason_counters() {
reset_fallback_counters();
record_fallback(FallbackReason::ConfigMismatch, CFG, Some("other-cfg"), NS);
record_fallback(
FallbackReason::NamespaceMismatch,
CFG,
Some(CFG),
"other-ns",
);
record_fallback(FallbackReason::NoSocket, CFG, None, NS);
record_fallback(FallbackReason::ParseFailure, CFG, None, NS);
record_fallback(FallbackReason::ProtocolMismatch, CFG, None, NS);
let sum = fallback_count(FallbackReason::ConfigMismatch)
+ fallback_count(FallbackReason::NamespaceMismatch)
+ fallback_count(FallbackReason::NoSocket)
+ fallback_count(FallbackReason::ParseFailure)
+ fallback_count(FallbackReason::ProtocolMismatch);
assert_eq!(sum, 5);
assert_eq!(
fallback_total(),
5,
"total must equal the sum of all reasons"
);
}
fn with_daemon_strict<T>(value: Option<&str>, f: impl FnOnce() -> T) -> T {
let prev = std::env::var("KHIVE_DAEMON_STRICT").ok();
match value {
Some(v) => std::env::set_var("KHIVE_DAEMON_STRICT", v),
None => std::env::remove_var("KHIVE_DAEMON_STRICT"),
}
let result = f();
match prev {
Some(v) => std::env::set_var("KHIVE_DAEMON_STRICT", v),
None => std::env::remove_var("KHIVE_DAEMON_STRICT"),
}
result
}
#[test]
#[serial]
fn record_fallback_config_mismatch_strict_off_never_bumps_strict_violations() {
with_daemon_strict(None, || {
reset_fallback_counters();
record_fallback(FallbackReason::ConfigMismatch, CFG, Some("other-cfg"), NS);
assert_eq!(fallback_count(FallbackReason::ConfigMismatch), 1);
assert_eq!(
fallback_strict_violations(),
0,
"strict mode is OFF (default local-dev behavior) — the illegitimate \
reason must still bump its own counter, but never the strict-violations one"
);
});
}
#[test]
#[serial]
fn record_fallback_config_mismatch_strict_on_bumps_strict_violations() {
with_daemon_strict(Some("1"), || {
reset_fallback_counters();
record_fallback(FallbackReason::ConfigMismatch, CFG, Some("other-cfg"), NS);
assert_eq!(fallback_count(FallbackReason::ConfigMismatch), 1);
assert_eq!(
fallback_strict_violations(),
1,
"KHIVE_DAEMON_STRICT=1 + an Illegitimate reason (config_mismatch) must \
bump the strict-violations counter (D2-R1)"
);
});
}
#[test]
#[serial]
fn record_fallback_namespace_mismatch_strict_on_bumps_strict_violations() {
with_daemon_strict(Some("1"), || {
reset_fallback_counters();
record_fallback(
FallbackReason::NamespaceMismatch,
CFG,
Some(CFG),
"other-ns",
);
assert_eq!(
fallback_strict_violations(),
1,
"KHIVE_DAEMON_STRICT=1 + an Illegitimate reason (namespace_mismatch) must \
bump the strict-violations counter (D2-R1)"
);
});
}
#[test]
#[serial]
fn record_fallback_no_socket_strict_on_never_bumps_strict_violations() {
with_daemon_strict(Some("1"), || {
reset_fallback_counters();
record_fallback(FallbackReason::NoSocket, CFG, None, NS);
assert_eq!(
fallback_strict_violations(),
0,
"NoSocket is the ADR-049-mandated no-daemon path — it must NEVER be \
elevated, even in strict mode (D2-R3)"
);
});
}
#[test]
#[serial]
fn record_fallback_protocol_mismatch_strict_on_never_bumps_strict_violations() {
with_daemon_strict(Some("1"), || {
reset_fallback_counters();
record_fallback(FallbackReason::ProtocolMismatch, CFG, None, NS);
assert_eq!(
fallback_strict_violations(),
0,
"ProtocolMismatch is the rollout-transient (version_mismatch) tier — \
it must NEVER be elevated, even in strict mode (D2-R3)"
);
});
}
#[test]
#[serial]
fn record_fallback_parse_failure_strict_on_never_bumps_strict_violations() {
with_daemon_strict(Some("1"), || {
reset_fallback_counters();
record_fallback(FallbackReason::ParseFailure, CFG, None, NS);
assert_eq!(
fallback_strict_violations(),
0,
"ParseFailure is folded into the rollout-transient tier alongside \
ProtocolMismatch (see FallbackReason::severity) — never elevated"
);
});
}
#[test]
fn fallback_reason_severity_matches_the_d2_legitimacy_table() {
assert_eq!(
FallbackReason::ConfigMismatch.severity(),
FallbackSeverity::Illegitimate
);
assert_eq!(
FallbackReason::NamespaceMismatch.severity(),
FallbackSeverity::Illegitimate
);
assert_eq!(
FallbackReason::ProtocolMismatch.severity(),
FallbackSeverity::RolloutTransient
);
assert_eq!(
FallbackReason::ParseFailure.severity(),
FallbackSeverity::RolloutTransient
);
assert_eq!(
FallbackReason::NoSocket.severity(),
FallbackSeverity::NoDaemon
);
}
#[tokio::test]
#[serial]
async fn forward_or_spawn_returns_none_when_no_daemon_set() {
clear_daemon_env();
reset_fallback_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", dir.path().join("khived.pid"));
std::env::set_var("KHIVE_NO_DAEMON", "1");
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: "test".to_string(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let out = forward_or_spawn(&frame).await;
assert!(out.is_none());
assert!(!sock.exists());
assert_eq!(fallback_total(), 0);
clear_daemon_env();
}
#[tokio::test]
#[serial]
async fn daemon_round_trip_dispatches_and_enforces_config_id() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid = dir.path().join("khived.pid");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid);
std::env::remove_var("KHIVE_NO_DAEMON");
let reference = make_test_server();
let config_id = reference.config_id().to_string();
let daemon_server = reference.clone();
let handle = tokio::spawn(async move {
let _ = run_daemon(daemon_server).await;
});
let _ready = connect_when_ready(&sock).await;
drop(_ready);
let req = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: Some("verbose".to_string()),
presentation_per_op: None,
namespace: "test".to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let resp = exchange(&sock, &req).await;
assert!(resp.ok, "valid op must succeed; error={:?}", resp.error);
assert!(!resp.namespace_mismatch);
assert!(!resp.config_mismatch);
assert!(!resp.version_mismatch);
assert_eq!(resp.daemon_protocol_version, PROTOCOL_VERSION);
assert_eq!(
resp.served_config_id.as_deref(),
Some(config_id.as_str()),
"daemon must echo the config it served under"
);
let reference_result = reference
.dispatch_request_local(RequestParams {
ops: "stats()".to_string(),
presentation: Some("verbose".to_string()),
presentation_per_op: None,
save_to: None,
format: None,
format_per_op: None,
})
.await
.expect("local dispatch of stats() must succeed");
assert_eq!(resp.result.as_deref(), Some(reference_result.as_str()));
assert!(reference_result.contains("\"entities\""));
let other = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "other".to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let resp_other = exchange(&sock, &other).await;
assert!(
resp_other.ok,
"a differently-namespaced frame with a matching config_id must be \
served, not rejected; error={:?}",
resp_other.error
);
assert!(
!resp_other.namespace_mismatch,
"ADR-096 Fork 1 removed the namespace_mismatch reject"
);
assert!(!resp_other.config_mismatch);
assert_eq!(
resp_other.served_config_id.as_deref(),
Some(config_id.as_str())
);
let mismatched_config = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: "packs=[kg];db=:memory:;embed=none;extra=[];backend=main".to_string(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let resp_cfg = exchange(&sock, &mismatched_config).await;
assert!(
resp_cfg.config_mismatch,
"differing config must be rejected"
);
assert!(!resp_cfg.namespace_mismatch);
assert!(!resp_cfg.ok);
let wrong_version = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: config_id.clone(),
protocol_version: 0,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let resp_ver = exchange(&sock, &wrong_version).await;
assert!(
resp_ver.version_mismatch,
"wrong protocol version must set version_mismatch"
);
assert!(!resp_ver.ok);
assert!(
resp_ver
.error
.as_deref()
.unwrap_or("")
.contains("protocol mismatch"),
"version mismatch error must include 'protocol mismatch'; got: {:?}",
resp_ver.error
);
assert!(
resp_ver
.error
.as_deref()
.unwrap_or("")
.contains("make local"),
"version mismatch error must tell operator what to do; got: {:?}",
resp_ver.error
);
assert_eq!(
resp_ver.daemon_protocol_version, PROTOCOL_VERSION,
"daemon must echo its own protocol version in the mismatch response"
);
handle.abort();
let _ = handle.await;
clear_daemon_env();
}
#[tokio::test]
#[serial]
async fn daemon_serves_per_request_identity_over_one_warm_registry() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid = dir.path().join("khived.pid");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid);
std::env::remove_var("KHIVE_NO_DAEMON");
let reference = make_comm_test_server(None);
let config_id = reference.config_id().to_string();
let daemon_server = reference.clone();
let handle = tokio::spawn(async move {
let _ = run_daemon(daemon_server).await;
});
let _ready = connect_when_ready(&sock).await;
drop(_ready);
let alice_frame = DaemonRequestFrame {
ops: "comm.send(to=\"bob\", content=\"hello from alice\")".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "alpha".to_string(),
actor_id: Some("alice".to_string()),
visible_namespaces: Vec::new(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let bob_frame = DaemonRequestFrame {
ops: "comm.send(to=\"alice\", content=\"hello from bob\")".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "beta".to_string(),
actor_id: Some("bob".to_string()),
visible_namespaces: Vec::new(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let resp_alice = exchange(&sock, &alice_frame).await;
assert!(
resp_alice.ok,
"alice's request must be served over the shared warm registry; error={:?}",
resp_alice.error
);
assert!(!resp_alice.namespace_mismatch);
assert!(!resp_alice.config_mismatch);
let body_alice: serde_json::Value =
serde_json::from_str(resp_alice.result.as_deref().expect("alice result body"))
.expect("decode alice result json");
assert_eq!(
body_alice["results"][0]["result"]["from"], "alice",
"write dispatched under alice's frame must stamp actor=alice, got: {body_alice}"
);
let resp_bob = exchange(&sock, &bob_frame).await;
assert!(
resp_bob.ok,
"bob's request must be served over the SAME shared warm registry; error={:?}",
resp_bob.error
);
assert!(!resp_bob.namespace_mismatch);
assert!(!resp_bob.config_mismatch);
let body_bob: serde_json::Value =
serde_json::from_str(resp_bob.result.as_deref().expect("bob result body"))
.expect("decode bob result json");
assert_eq!(
body_bob["results"][0]["result"]["from"], "bob",
"write dispatched under bob's frame must stamp actor=bob, NOT cross-\
contaminated with alice's actor; got: {body_bob}"
);
handle.abort();
let _ = handle.await;
clear_daemon_env();
}
#[tokio::test]
#[serial]
async fn daemon_config_id_ignores_actor_folded_visibility_but_frame_visibility_isolated() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid = dir.path().join("khived.pid");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid);
std::env::remove_var("KHIVE_NO_DAEMON");
let actor_a = "lambda:actor-a";
let actor_b = "lambda:actor-b";
let cfg_a = folded_actor_memory_config(actor_a);
let cfg_b = folded_actor_memory_config(actor_b);
let ns_a = Namespace::parse(actor_a).expect("actor a namespace");
let ns_b = Namespace::parse(actor_b).expect("actor b namespace");
assert_eq!(cfg_a.actor_id.as_deref(), Some(actor_a));
assert_eq!(cfg_b.actor_id.as_deref(), Some(actor_b));
assert!(
cfg_a.visible_namespaces.contains(&ns_a),
"actor.id must fold into client A visible_namespaces"
);
assert!(
cfg_b.visible_namespaces.contains(&ns_b),
"actor.id must fold into client B visible_namespaces"
);
assert_ne!(
cfg_a.visible_namespaces, cfg_b.visible_namespaces,
"precondition: clients must carry different folded visible sets"
);
let id_a = crate::server::compute_config_id(&cfg_a, None);
let id_b = crate::server::compute_config_id(&cfg_b, None);
assert_eq!(
id_a, id_b,
"actor-derived visible_namespaces must not affect daemon config_id"
);
let daemon_server = {
let runtime = KhiveRuntime::new(memory_runtime_config()).expect("in-memory runtime");
crate::server::KhiveMcpServer::new(runtime).expect("server builds with kg")
};
assert_eq!(
daemon_server.config_id(),
id_a,
"daemon and both clients must share the same engine-coherence key"
);
let handle = tokio::spawn(async move {
let _ = run_daemon(daemon_server).await;
});
let _ready = connect_when_ready(&sock).await;
drop(_ready);
let frame = |ops: &str, actor: &str, visible: &[Namespace]| DaemonRequestFrame {
ops: ops.to_string(),
presentation: Some("verbose".to_string()),
presentation_per_op: None,
namespace: "local".to_string(),
actor_id: Some(actor.to_string()),
visible_namespaces: visible.iter().map(|ns| ns.as_str().to_string()).collect(),
config_id: id_a.clone(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let seed_a = exchange(
&sock,
&frame(
r#"create(kind="concept", name="ActorAVisibleOnly", namespace="lambda:actor-a")"#,
actor_a,
&cfg_a.visible_namespaces,
),
)
.await;
assert!(seed_a.ok, "seed A must succeed: {:?}", seed_a.error);
let seed_b = exchange(
&sock,
&frame(
r#"create(kind="concept", name="ActorBVisibleOnly", namespace="lambda:actor-b")"#,
actor_b,
&cfg_b.visible_namespaces,
),
)
.await;
assert!(seed_b.ok, "seed B must succeed: {:?}", seed_b.error);
fn names_from_list_response(resp: &DaemonResponseFrame) -> Vec<String> {
assert!(resp.ok, "list response must be ok: {:?}", resp.error);
assert!(
!resp.config_mismatch,
"list response must not reject on config_id"
);
let body: serde_json::Value =
serde_json::from_str(resp.result.as_deref().expect("list result body"))
.expect("decode list result json");
let first = &body["results"][0];
assert_eq!(
first["ok"], true,
"list op must succeed inside daemon result: {first}"
);
let rows = first["result"]
.as_array()
.or_else(|| first["result"]["items"].as_array())
.expect("list result must be an array or object with items");
rows.iter()
.filter_map(|row| row.get("name").and_then(|v| v.as_str()).map(str::to_string))
.collect()
}
let list_a = exchange(
&sock,
&frame(r#"list(kind="entity")"#, actor_a, &cfg_a.visible_namespaces),
)
.await;
let names_a = names_from_list_response(&list_a);
assert!(
names_a.iter().any(|name| name == "ActorAVisibleOnly"),
"actor A frame must see actor A namespace rows; got {names_a:?}"
);
assert!(
!names_a.iter().any(|name| name == "ActorBVisibleOnly"),
"actor A frame must not see actor B namespace rows; got {names_a:?}"
);
let list_b = exchange(
&sock,
&frame(r#"list(kind="entity")"#, actor_b, &cfg_b.visible_namespaces),
)
.await;
let names_b = names_from_list_response(&list_b);
assert!(
names_b.iter().any(|name| name == "ActorBVisibleOnly"),
"actor B frame must see actor B namespace rows; got {names_b:?}"
);
assert!(
!names_b.iter().any(|name| name == "ActorAVisibleOnly"),
"actor B frame must not see actor A namespace rows; got {names_b:?}"
);
handle.abort();
let _ = handle.await;
clear_daemon_env();
}
#[tokio::test]
#[serial]
async fn local_dispatch_without_identity_context_uses_baked_actor() {
clear_daemon_env();
let server = make_comm_test_server(Some("baked-actor"));
let result = server
.dispatch_request_local(RequestParams {
ops: "comm.send(to=\"someone\", content=\"hello\")".to_string(),
presentation: None,
presentation_per_op: None,
save_to: None,
format: None,
format_per_op: None,
})
.await
.expect("local dispatch of comm.send must succeed");
let body: serde_json::Value =
serde_json::from_str(&result).expect("decode local dispatch result json");
assert_eq!(
body["results"][0]["result"]["from"], "baked-actor",
"local dispatch (no daemon, no identity context) must use the server's \
own baked actor_id, got: {body}"
);
}
#[tokio::test]
#[serial]
async fn daemon_round_trip_honors_from_wire_for_subhandlers() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid = dir.path().join("khived.pid");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid);
std::env::remove_var("KHIVE_NO_DAEMON");
let reference = make_subhandler_test_server();
let config_id = reference.config_id().to_string();
let daemon_server = reference.clone();
let handle = tokio::spawn(async move {
let _ = run_daemon(daemon_server).await;
});
let _ready = connect_when_ready(&sock).await;
drop(_ready);
let frame = |from_wire: bool| DaemonRequestFrame {
ops: "brain.state()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "braintest".to_string(),
actor_id: None,
visible_namespaces: Vec::new(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
metrics_only: false,
format: None,
format_per_op: None,
from_wire,
};
let resp_wire = exchange(&sock, &frame(true)).await;
assert!(
resp_wire.ok,
"dispatch itself must succeed (the op carries the gate error); error={:?}",
resp_wire.error
);
let body_wire: serde_json::Value =
serde_json::from_str(resp_wire.result.as_deref().expect("wire result body"))
.expect("decode wire result json");
let first_wire = &body_wire["results"][0];
assert_eq!(
first_wire["ok"], false,
"from_wire=true subhandler must be blocked through the daemon: {first_wire}"
);
let err_wire = first_wire["error"].as_str().unwrap_or("");
assert!(
err_wire.contains("permission denied") || err_wire.contains("subhandler"),
"daemon-forward wire path must surface the subhandler gate error; got: {err_wire}"
);
let resp_op = exchange(&sock, &frame(false)).await;
let body_op: serde_json::Value =
serde_json::from_str(resp_op.result.as_deref().expect("operator result body"))
.expect("decode operator result json");
let first_op = &body_op["results"][0];
let err_op = first_op["error"].as_str().unwrap_or("");
assert!(
!err_op.contains("permission denied") && !err_op.contains("subhandler"),
"operator frame must NOT gate the subhandler through the daemon: {first_op}"
);
handle.abort();
let _ = handle.await;
clear_daemon_env();
}
#[test]
fn wire_request_frame_sets_from_wire_true() {
let server = make_subhandler_test_server();
let params = RequestParams {
ops: "brain.state()".to_string(),
..Default::default()
};
let frame = server.wire_daemon_frame(¶ms);
assert!(
frame.from_wire,
"request tool must set from_wire=true on the daemon forward-frame"
);
assert_eq!(frame.ops, "brain.state()");
assert_eq!(frame.namespace, "braintest");
}
fn old_daemon_response(config_id: &str) -> DaemonResponseFrame {
DaemonResponseFrame {
ok: true,
result: Some("stale-result".to_string()),
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(config_id.to_string()),
version_mismatch: false,
daemon_protocol_version: 0,
metrics: None,
}
}
async fn serve_one_response(listener: tokio::net::UnixListener, response: DaemonResponseFrame) {
if let Ok((mut stream, _)) = listener.accept().await {
if read_frame(&mut stream).await.is_ok() {
if let Ok(payload) = serde_json::to_vec(&response) {
let _ = write_frame(&mut stream, &payload).await;
}
}
}
}
#[tokio::test]
#[serial]
async fn forward_or_spawn_rejects_old_daemon_and_returns_protocol_mismatch_error() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let listener = tokio::net::UnixListener::bind(&sock).expect("bind fake old-daemon socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let old_resp = old_daemon_response(config_id);
let fake_handle = tokio::spawn(serve_one_response(listener, old_resp));
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let result = forward_or_spawn(&frame).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), fake_handle).await;
match result {
Some(Err(McpError { message, .. })) => {
assert!(
message.contains("protocol mismatch"),
"error must name 'protocol mismatch'; got: {message}"
);
assert!(
message.contains("make local") || message.contains("rebuild"),
"error must tell the operator what to do; got: {message}"
);
}
Some(Ok(v)) => {
panic!("forward_or_spawn must NOT accept old-daemon response; got Ok({v:?})")
}
None => panic!(
"forward_or_spawn must return Some(Err(..)) for protocol mismatch, \
not None (which would cause silent fallback to local dispatch)"
),
}
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
async fn serve_crash_on_dispatch(listener: tokio::net::UnixListener) {
if let Ok((mut stream, _)) = listener.accept().await {
let _ = read_frame(&mut stream).await;
}
}
#[tokio::test]
#[serial]
async fn try_forward_inner_returns_parse_failure_when_daemon_closes_without_response() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let listener =
tokio::net::UnixListener::bind(&sock).expect("bind fake crash-daemon socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let fake_handle = tokio::spawn(serve_crash_on_dispatch(listener));
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let outcome = try_forward_inner(&frame).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), fake_handle).await;
assert!(
matches!(outcome, ForwardOutcome::ParseFailure),
"daemon crash (connection closed without response) must yield \
ParseFailure, not NoSocket — got a different variant"
);
clear_daemon_env();
}
#[test]
fn argv_daemon_true_bare() {
assert!(argv_is_khive_daemon("kkernel mcp --daemon"));
}
#[test]
fn argv_daemon_true_absolute_path() {
assert!(argv_is_khive_daemon(
"/Users/x/.cargo/bin/kkernel mcp --daemon"
));
}
#[test]
fn argv_daemon_false_editor_with_kkernel_in_filename() {
assert!(!argv_is_khive_daemon("vim kkernel-notes.md"));
}
#[test]
fn argv_daemon_false_less_with_kkernel_path() {
assert!(!argv_is_khive_daemon(
"less /Users/x/projects/kkernel/daemon.rs"
));
}
#[test]
fn argv_daemon_false_kkernel_no_daemon_flag() {
assert!(!argv_is_khive_daemon("kkernel exec 'something'"));
}
#[test]
fn argv_daemon_false_wrapper_argv0_not_kkernel() {
assert!(!argv_is_khive_daemon("some-wrapper kkernel mcp --daemon"));
}
#[test]
fn argv_daemon_false_empty_string() {
assert!(!argv_is_khive_daemon(""));
}
#[test]
fn argv_daemon_true_with_surrounding_and_inner_whitespace() {
assert!(argv_is_khive_daemon(
" /Users/x/.cargo/bin/kkernel mcp --daemon "
));
}
#[test]
fn argv_daemon_true_kkernel_bench_basename() {
assert!(argv_is_khive_daemon("kkernel-bench mcp --daemon"));
assert!(argv_is_khive_daemon(
"/Users/x/.cargo/bin/kkernel-bench mcp --daemon"
));
}
#[tokio::test]
#[serial]
async fn concurrent_recovery_second_client_skips_kill_when_daemon_alive() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let server = make_test_server();
let config_id = server.config_id().to_string();
let daemon_server = server.clone();
let handle = tokio::spawn(async move {
let _ = run_daemon(daemon_server).await;
});
let _ready = connect_when_ready(&sock).await;
drop(_ready);
let daemon_pid_str =
std::fs::read_to_string(&pid_file).expect("daemon must have written a pid file");
let daemon_pid: u32 = daemon_pid_str
.trim()
.parse()
.expect("daemon pid file must contain a u32");
FORCE_PID_IS_DAEMON.store(true, std::sync::atomic::Ordering::SeqCst);
reset_counters();
let outcome = kill_and_respawn(&config_id, "test").await;
assert!(
matches!(outcome, Ok(RecoveryOutcome::Skipped)),
"kill_and_respawn must return Ok(RecoveryOutcome::Skipped) when a live \
matching daemon exists under the lock"
);
assert_eq!(
KILL_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0,
"KILL_COUNT must be 0: the probe found the daemon alive so \
kill_stale_daemon_inner must NOT be called \
(this assertion fails if the probe-under-lock is removed)"
);
assert_eq!(
SPAWN_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0,
"SPAWN_COUNT must be 0: no respawn needed when the daemon is alive \
(this assertion fails if the probe-under-lock is removed)"
);
assert!(
pid_file.exists(),
"PID file must survive: kill_and_respawn must NOT unlink it"
);
assert!(
sock.exists(),
"socket must survive: kill_and_respawn must NOT unlink it"
);
let surviving_pid: u32 = std::fs::read_to_string(&pid_file)
.expect("pid file readable")
.trim()
.parse()
.expect("pid file is a u32");
assert_eq!(
surviving_pid, daemon_pid,
"PID in file must be the original daemon PID — no new daemon was spawned"
);
handle.abort();
let _ = handle.await;
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[derive(Clone)]
struct BigDispatch {
namespace: String,
config_id: String,
}
#[async_trait]
impl daemon::DaemonDispatch for BigDispatch {
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<khive_runtime::RequestIdentity>,
) -> Result<String, String> {
Ok("X".repeat(khive_runtime::daemon::MAX_FRAME_BYTES + 1))
}
async fn warm_all(&self) {}
fn namespace(&self) -> &str {
&self.namespace
}
fn config_id(&self) -> &str {
&self.config_id
}
}
#[tokio::test]
#[serial]
async fn oversized_daemon_response_sends_error_frame_not_kills_daemon() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "test-oversized-config";
let dispatcher = BigDispatch {
namespace: "test".to_string(),
config_id: config_id.to_string(),
};
let handle = tokio::spawn(async move {
let _ = run_daemon(dispatcher).await;
});
let _ready = connect_when_ready(&sock).await;
drop(_ready);
let daemon_pid: u32 = std::fs::read_to_string(&pid_file)
.expect("daemon must have written a pid file")
.trim()
.parse()
.expect("daemon pid must be a u32");
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let result = forward_or_spawn(&frame).await;
assert!(
pid_file.exists(),
"PID file must survive: oversized response is NOT a daemon crash"
);
assert!(
sock.exists(),
"socket must survive: oversized response is NOT a daemon crash"
);
let surviving_pid: u32 = std::fs::read_to_string(&pid_file)
.expect("pid file readable")
.trim()
.parse()
.expect("pid file is a u32");
assert_eq!(
surviving_pid, daemon_pid,
"daemon PID must not change — no kill+respawn occurred"
);
assert_eq!(
KILL_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0,
"KILL_COUNT must be 0 — oversized response must NOT trigger \
kill_stale_daemon_inner (fails if handle_conn oversized gate is removed)"
);
match result {
Some(Err(e)) => {
assert!(
e.message.contains("too large"),
"error must describe the oversized response; got: {}",
e.message
);
}
Some(Ok(_)) => panic!("oversized response must not produce Ok result"),
None => panic!(
"oversized response must produce Some(Err(..)) from the explicit \
error frame, not None (None would mean map_response fell back to \
local dispatch, hiding the server-side error)"
),
}
handle.abort();
let _ = handle.await;
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[derive(Clone)]
struct CountingDispatch {
namespace: String,
config_id: String,
}
#[async_trait]
impl daemon::DaemonDispatch for CountingDispatch {
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<khive_runtime::RequestIdentity>,
) -> Result<String, String> {
DAEMON_DISPATCH.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok("{\"ok\":true,\"counted\":true}".to_string())
}
async fn warm_all(&self) {}
fn namespace(&self) -> &str {
&self.namespace
}
fn config_id(&self) -> &str {
&self.config_id
}
}
#[tokio::test]
#[serial]
async fn recovery_path_dispatches_real_request_exactly_once() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let real_sock = dir.path().join("khived.sock");
let stale_sock = dir.path().join("stale.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let counting_dispatcher = CountingDispatch {
namespace: "test".to_string(),
config_id: config_id.to_string(),
};
std::env::set_var("KHIVE_SOCKET", &real_sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let counting_handle = tokio::spawn(async move {
let _ = run_daemon(counting_dispatcher).await;
});
let _ready = connect_when_ready(&real_sock).await;
drop(_ready);
let stale_listener =
tokio::net::UnixListener::bind(&stale_sock).expect("bind stale socket");
let stale_handle = tokio::spawn(serve_crash_on_dispatch(stale_listener));
let stale_pid_file = dir.path().join("stale.pid");
std::fs::write(&stale_pid_file, std::process::id().to_string()).expect("write stale pid");
std::env::set_var("KHIVE_SOCKET", &stale_sock);
std::env::set_var("KHIVE_PID", &stale_pid_file);
std::env::set_var("KHIVE_SOCKET", &real_sock);
std::env::set_var("KHIVE_PID", &pid_file);
let recovery = kill_and_respawn(config_id, "test").await;
assert!(
matches!(recovery, Ok(RecoveryOutcome::Skipped)),
"probe must find the live CountingDispatch daemon and return Skipped"
);
assert_eq!(
DAEMON_DISPATCH.load(std::sync::atomic::Ordering::SeqCst),
0,
"probe_only frame must NOT increment DAEMON_DISPATCH \
(fails if the real request is used as the probe)"
);
let real_frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let fwd = try_forward_inner(&real_frame).await;
assert!(
matches!(fwd, ForwardOutcome::Response(_)),
"real forward after Skipped must succeed; got non-Response outcome"
);
assert_eq!(
DAEMON_DISPATCH.load(std::sync::atomic::Ordering::SeqCst),
1,
"real request must be dispatched EXACTLY ONCE across the recovery path \
(assert fails with count==2 if the real frame is used as the probe \
AND re-forwarded at the call site — the double-dispatch bug)"
);
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), stale_handle).await;
counting_handle.abort();
let _ = counting_handle.await;
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
async fn serve_probe_ack_forever(listener: tokio::net::UnixListener, config_id: String) {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
return;
};
if read_frame(&mut stream).await.is_err() {
continue;
}
let response = DaemonResponseFrame {
ok: true,
result: None,
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(config_id.clone()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
};
if let Ok(payload) = serde_json::to_vec(&response) {
let _ = write_frame(&mut stream, &payload).await;
}
}
}
#[tokio::test]
#[serial]
async fn confirm_genuinely_dead_waits_for_peer_to_release_boot_guard() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let server = make_test_server();
let config_id = server.config_id().to_string();
let listener = tokio::net::UnixListener::bind(&sock).expect("bind fake daemon socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write fake pid file");
let serve_handle = tokio::spawn(serve_probe_ack_forever(listener, config_id.clone()));
let (acquired_tx, acquired_rx) = std::sync::mpsc::channel::<()>();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let boot_thread = std::thread::spawn(move || {
let guard = khive_runtime::daemon::acquire_daemon_boot_guard()
.expect("test boot holder must acquire the recovery lock");
acquired_tx.send(()).expect("signal lock acquired");
let _ = release_rx.recv();
drop(guard);
});
acquired_rx
.recv()
.expect("boot-holder thread must signal after acquiring the lock");
let confirm_fut = confirm_genuinely_dead(&config_id, "test");
tokio::pin!(confirm_fut);
let too_early =
tokio::time::timeout(std::time::Duration::from_millis(150), &mut confirm_fut).await;
assert!(
too_early.is_err(),
"confirm_genuinely_dead must not resolve while a peer holds the \
boot/recovery lock"
);
release_tx
.send(())
.expect("boot-holder thread still awaiting release");
boot_thread
.join()
.expect("boot-holder thread must not panic");
let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), confirm_fut)
.await
.expect("confirm_genuinely_dead must resolve promptly once the peer releases the lock");
assert!(
matches!(outcome, ProbeOutcome::Alive),
"confirm_genuinely_dead must observe the already-reachable daemon \
once the contended lock clears, not conclude Dead early"
);
serve_handle.abort();
let _ = serve_handle.await;
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[tokio::test]
#[serial]
async fn confirm_genuinely_dead_is_sticky_uncertain_after_earlier_contention() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main".to_string();
let guard = khive_runtime::daemon::acquire_daemon_boot_guard()
.expect("test lock holder must acquire the recovery lock");
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let boot_thread = std::thread::spawn(move || {
let _ = release_rx.recv();
drop(guard);
});
let confirm_config_id = config_id.clone();
let confirm_handle =
tokio::spawn(async move { confirm_genuinely_dead(&confirm_config_id, "test").await });
tokio::time::sleep(std::time::Duration::from_millis(650)).await;
release_tx
.send(())
.expect("boot-holder thread still awaiting release");
boot_thread
.join()
.expect("boot-holder thread must not panic");
let outcome = tokio::time::timeout(std::time::Duration::from_secs(5), confirm_handle)
.await
.expect("confirm_genuinely_dead must resolve once the lock is released")
.expect("confirm_genuinely_dead task must not panic");
assert!(
matches!(outcome, ProbeOutcome::LockContended),
"an earlier LockContended round must make the whole call \
LockContended (sticky), never overwritten by a later round's \
Dead reading; got {outcome:?}"
);
assert_eq!(
KILL_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0,
"confirm_genuinely_dead must never kill on its own"
);
assert_eq!(
SPAWN_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0,
"confirm_genuinely_dead must never spawn on its own"
);
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[tokio::test]
#[serial]
async fn concurrent_recoverers_spawn_exactly_one_replacement_daemon() {
clear_daemon_env();
reset_counters();
*RECOVERY_RACE_BARRIER
.lock()
.expect("barrier mutex poisoned") =
Some(std::sync::Arc::new(tokio::sync::Barrier::new(2)));
*SPAWN_COMMIT_BARRIER.lock().expect("barrier mutex poisoned") =
Some(std::sync::Arc::new(tokio::sync::Barrier::new(2)));
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
let recoverer_lock_file = dir.path().join("khived.recoverer.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::set_var("KHIVE_RECOVERER_LOCK", &recoverer_lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main".to_string();
let watcher_config_id = config_id.clone();
let watcher_sock = sock.clone();
let watcher_pid_file = pid_file.clone();
let watcher = tokio::spawn(async move {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
while SPAWN_COUNT.load(std::sync::atomic::Ordering::SeqCst) == 0 {
assert!(
tokio::time::Instant::now() < deadline,
"no recoverer reached spawn_daemon() within 5s"
);
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
let listener =
tokio::net::UnixListener::bind(&watcher_sock).expect("bind simulated-spawn socket");
std::fs::write(&watcher_pid_file, std::process::id().to_string())
.expect("write simulated-spawn pid file");
serve_probe_ack_forever(listener, watcher_config_id).await;
});
let (a, b) = tokio::join!(
kill_and_respawn(&config_id, "test"),
kill_and_respawn(&config_id, "test"),
);
let spawned_count = [&a, &b]
.iter()
.filter(|r| matches!(r, Ok(RecoveryOutcome::Spawned)))
.count();
assert_eq!(
spawned_count, 1,
"exactly one of two concurrent recoverers racing from a genuinely \
dead daemon must spawn a replacement; got a={a:?} b={b:?}"
);
assert_eq!(
SPAWN_COUNT.load(std::sync::atomic::Ordering::SeqCst),
1,
"spawn_daemon must be called exactly once across two concurrent \
recoverers racing the same dead-daemon state; got a={a:?} b={b:?}"
);
watcher.abort();
let _ = watcher.await;
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
std::env::remove_var("KHIVE_RECOVERER_LOCK");
}
fn pre_probe_daemon_response(config_id: &str) -> DaemonResponseFrame {
DaemonResponseFrame {
ok: false,
result: None,
error: Some("parse error: empty ops string".to_string()),
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(config_id.to_string()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
}
}
#[tokio::test]
#[serial]
async fn probe_classifier_dead_when_same_protocol_daemon_lacks_probe_support() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let listener = tokio::net::UnixListener::bind(&sock).expect("bind fake pre-probe socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let pre_probe_resp = pre_probe_daemon_response(config_id);
let fake_handle = tokio::spawn(serve_one_response(listener, pre_probe_resp));
FORCE_PID_IS_DAEMON.store(true, std::sync::atomic::Ordering::SeqCst);
reset_counters();
let outcome = kill_and_respawn(config_id, "test").await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), fake_handle).await;
assert!(
matches!(outcome, Ok(RecoveryOutcome::Spawned) | Err(_)),
"pre-probe same-protocol daemon must NOT be classified Alive; \
expected Spawned or spawn-error, got Skipped"
);
assert_eq!(
KILL_COUNT.load(std::sync::atomic::Ordering::SeqCst),
1,
"KILL_COUNT must be 1 — the pre-probe response must classify as Dead \
so kill_stale_daemon_inner is called \
(this fails if is_probe_ack check is removed and ok=false response \
is incorrectly classified as Alive)"
);
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[derive(Clone)]
struct FailDispatch {
namespace: String,
config_id: String,
}
#[async_trait]
impl daemon::DaemonDispatch for FailDispatch {
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<khive_runtime::RequestIdentity>,
) -> Result<String, String> {
Err("forced dispatch error: verb returned an error for testing".to_string())
}
async fn warm_all(&self) {}
fn namespace(&self) -> &str {
&self.namespace
}
fn config_id(&self) -> &str {
&self.config_id
}
}
#[tokio::test]
#[serial]
async fn dispatch_error_propagates_as_non_empty_client_message() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let dispatcher = FailDispatch {
namespace: "test".to_string(),
config_id: config_id.to_string(),
};
let handle = tokio::spawn(async move {
let _ = run_daemon(dispatcher).await;
});
let _ready = connect_when_ready(&sock).await;
drop(_ready);
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let result = forward_or_spawn(&frame).await;
match result {
Some(Err(McpError { message, .. })) => {
assert!(
!message.is_empty(),
"error message forwarded to client must not be empty"
);
assert!(
message.contains("forced dispatch error"),
"client must receive the daemon's error message verbatim; got: {message}"
);
}
Some(Ok(v)) => panic!("FailDispatch always errs; got Ok({v:?})"),
None => panic!(
"forward_or_spawn returned None (local fallback) instead of \
propagating the daemon's error — the error message was swallowed"
),
}
handle.abort();
let _ = handle.await;
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
fn explicit_version_mismatch_response(config_id: &str) -> DaemonResponseFrame {
DaemonResponseFrame {
ok: false,
result: None,
error: Some(format!(
"daemon protocol mismatch: client={} daemon=0 — \
rebuild/update the client binary (make local)",
PROTOCOL_VERSION
)),
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(config_id.to_string()),
version_mismatch: true,
daemon_protocol_version: 0,
metrics: None,
}
}
#[tokio::test]
#[serial]
async fn try_forward_inner_routes_explicit_version_mismatch_to_protocol_mismatch() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let listener =
tokio::net::UnixListener::bind(&sock).expect("bind explicit-mismatch socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let mismatch_resp = explicit_version_mismatch_response(config_id);
let fake_handle = tokio::spawn(serve_one_response(listener, mismatch_resp));
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let outcome = try_forward_inner(&frame).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), fake_handle).await;
assert!(
matches!(outcome, ForwardOutcome::ProtocolMismatch),
"explicit version_mismatch=true with daemon_protocol_version < PROTOCOL_VERSION \
must classify as ProtocolMismatch (triggers kill+respawn), not Response \
(which would surface a hard error and leave the stale daemon alive)"
);
clear_daemon_env();
}
fn newer_daemon_version_mismatch_response(config_id: &str) -> DaemonResponseFrame {
DaemonResponseFrame {
ok: false,
result: None,
error: Some(format!(
"daemon protocol mismatch: client={} daemon={} — \
rebuild/update the client binary (make local)",
PROTOCOL_VERSION,
PROTOCOL_VERSION + 1
)),
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(config_id.to_string()),
version_mismatch: true,
daemon_protocol_version: PROTOCOL_VERSION + 1,
metrics: None,
}
}
#[tokio::test]
#[serial]
async fn try_forward_inner_newer_daemon_mismatch_yields_response_not_recovery() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let listener = tokio::net::UnixListener::bind(&sock).expect("bind newer-daemon socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let mismatch_resp = newer_daemon_version_mismatch_response(config_id);
let fake_handle = tokio::spawn(serve_one_response(listener, mismatch_resp));
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let outcome = try_forward_inner(&frame).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), fake_handle).await;
assert!(
matches!(outcome, ForwardOutcome::Response(_)),
"version_mismatch=true with daemon_protocol_version > PROTOCOL_VERSION \
must yield Response (hard error via map_response), not ProtocolMismatch \
(kill+respawn cannot fix a stale client binary)"
);
clear_daemon_env();
}
#[tokio::test]
#[serial]
async fn ambiguous_write_never_retries_against_freshly_spawned_daemon() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let stale_listener = tokio::net::UnixListener::bind(&sock).expect("bind stale socket");
let stale_handle = tokio::spawn(serve_crash_on_dispatch(stale_listener));
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let connect_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let connect_count_srv = connect_count.clone();
let resp = frame_ok("stats-result");
let fresh_sock = sock.clone();
let fresh_handle = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let listener =
tokio::net::UnixListener::bind(&fresh_sock).expect("bind fresh daemon socket");
if let Ok((mut stream, _)) = listener.accept().await {
connect_count_srv.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if read_frame(&mut stream).await.is_ok() {
if let Ok(payload) = serde_json::to_vec(&resp) {
let _ = write_frame(&mut stream, &payload).await;
}
}
}
});
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let result = forward_or_spawn(&frame).await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), stale_handle).await;
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
fresh_handle.abort();
let _ = fresh_handle.await;
match result {
Some(Err(McpError { message, .. })) => {
assert!(
message.contains("not retrying") && message.contains("duplicate execution"),
"ambiguous post-write outcome must return the #644 hard-error \
message; got: {message}"
);
}
other => {
panic!("expected Some(Err(..)) for an ambiguous post-write outcome, got {other:?}")
}
}
assert_eq!(
connect_count.load(std::sync::atomic::Ordering::SeqCst),
0,
"forward_or_spawn must NOT contact any daemon (stale or freshly \
spawned) again once the real frame has been fully written — \
retrying risks a duplicate dispatch (#644)"
);
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[tokio::test]
#[serial]
async fn forward_or_spawn_dispatches_real_frame_exactly_once_end_to_end() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let listener = tokio::net::UnixListener::bind(&sock).expect("bind fake daemon socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let dispatch_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let dispatch_count_srv = dispatch_count.clone();
let fake_handle = tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
if read_frame(&mut stream).await.is_err() {
continue;
}
dispatch_count_srv.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
drop(stream);
}
});
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let result = forward_or_spawn(&frame).await;
match result {
Some(Err(_)) => {}
other => panic!(
"expected Some(Err(..)) — not None (silent local fallback) — for a \
dispatch-then-crash response, got {other:?}"
),
}
assert_eq!(
dispatch_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"the real request must be dispatched EXACTLY ONCE; a value of 2 means \
forward_or_spawn resent the frame after the write already completed"
);
fake_handle.abort();
let _ = fake_handle.await;
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[tokio::test]
#[serial]
async fn forward_or_spawn_dispatches_real_frame_exactly_once_on_success() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
let listener = tokio::net::UnixListener::bind(&sock).expect("bind fake daemon socket");
std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
let dispatch_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let dispatch_count_srv = dispatch_count.clone();
let cfg_for_srv = config_id.to_string();
let fake_handle = tokio::spawn(async move {
loop {
let Ok((mut stream, _)) = listener.accept().await else {
break;
};
if read_frame(&mut stream).await.is_err() {
continue;
}
dispatch_count_srv.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let resp = DaemonResponseFrame {
ok: true,
result: Some("daemon-handled-stats".to_string()),
error: None,
namespace_mismatch: false,
config_mismatch: false,
served_config_id: Some(cfg_for_srv.clone()),
version_mismatch: false,
daemon_protocol_version: PROTOCOL_VERSION,
metrics: None,
};
let payload = serde_json::to_vec(&resp).expect("serialize response frame");
let _ = write_frame(&mut stream, &payload).await;
}
});
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let result = forward_or_spawn(&frame).await;
match result {
Some(Ok(ref body)) => {
assert_eq!(
body, "daemon-handled-stats",
"request() must surface the daemon's response verbatim"
);
}
other => {
panic!("expected Some(Ok(_)) for a successful daemon round trip, got {other:?}")
}
}
assert_eq!(
dispatch_count.load(std::sync::atomic::Ordering::SeqCst),
1,
"a successful daemon round trip must dispatch the real request EXACTLY \
ONCE; a value other than 1 means forward_or_spawn retried or double-sent \
the frame around a successful response"
);
fake_handle.abort();
let _ = fake_handle.await;
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[tokio::test]
#[serial]
async fn forward_or_spawn_blocks_on_boot_quiescence_before_local_fallback() {
clear_daemon_env();
reset_counters();
let dir = tempfile::tempdir().expect("tempdir");
let sock = dir.path().join("khived.sock");
let pid_file = dir.path().join("khived.pid");
let lock_file = dir.path().join("khived.recovery.lock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::env::set_var("KHIVE_PID", &pid_file);
std::env::set_var("KHIVE_LOCK", &lock_file);
std::env::remove_var("KHIVE_NO_DAEMON");
let config_id = "packs=[kg];db=:memory:;embed=none;extra=[];backend=main";
const GUARD_HOLD: std::time::Duration = std::time::Duration::from_secs(6);
let boot_thread = std::thread::spawn(|| {
let wait_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
while SPAWN_COUNT.load(std::sync::atomic::Ordering::SeqCst) == 0 {
assert!(
std::time::Instant::now() < wait_deadline,
"kill_and_respawn never reached spawn_daemon() within 5s; \
the boot-holder thread has nothing to queue behind"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
let guard = khive_runtime::daemon::acquire_daemon_boot_guard()
.expect("test boot holder must acquire the recovery lock");
std::thread::sleep(GUARD_HOLD);
drop(guard);
});
let frame = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".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,
};
let started = std::time::Instant::now();
let result = forward_or_spawn(&frame).await;
let elapsed = started.elapsed();
boot_thread
.join()
.expect("boot-holder thread must not panic");
assert!(
result.is_none(),
"genuinely no daemon after boot quiescence must fall back to local \
dispatch (None), got {result:?}"
);
assert!(
elapsed >= GUARD_HOLD,
"forward_or_spawn must block until the cold-boot guard is released \
before deciding to fall back locally — returned after {elapsed:?}, \
faster than the {GUARD_HOLD:?} the boot guard was held, meaning it \
(or a reverted version of this fix) would local-dispatch while \
cold-boot schema init could still be in progress (#667)"
);
reset_counters();
clear_daemon_env();
std::env::remove_var("KHIVE_LOCK");
}
#[test]
fn daemon_log_path_from_home_none_when_home_unset() {
assert!(daemon_log_path_from_home(None).is_none());
}
#[test]
fn daemon_log_path_from_home_joins_dot_khive_logs() {
let home = std::ffi::OsStr::new("/home/example");
let path = daemon_log_path_from_home(Some(home)).expect("home present");
assert_eq!(
path,
std::path::PathBuf::from("/home/example/.khive/logs/khived.log")
);
}
#[test]
fn daemon_log_should_rotate_under_cap_is_false() {
assert!(!daemon_log_should_rotate(100, 1000));
}
#[test]
fn daemon_log_should_rotate_at_cap_is_true() {
assert!(daemon_log_should_rotate(1000, 1000));
}
#[test]
fn daemon_log_should_rotate_over_cap_is_true() {
assert!(daemon_log_should_rotate(1001, 1000));
}
#[test]
fn prepare_daemon_log_file_creates_dir_and_file_on_first_use() {
let dir = tempfile::tempdir().expect("tempdir");
let log_path = dir.path().join(".khive").join("logs").join("khived.log");
let file = prepare_daemon_log_file_with_cap(&log_path, DAEMON_LOG_MAX_BYTES);
assert!(file.is_some(), "must create dir + file on first use");
assert!(log_path.exists());
}
#[test]
fn prepare_daemon_log_file_leaves_existing_when_under_cap() {
let dir = tempfile::tempdir().expect("tempdir");
let log_dir = dir.path().join("logs");
std::fs::create_dir_all(&log_dir).expect("create log dir");
let log_path = log_dir.join("khived.log");
std::fs::write(&log_path, b"existing content\n").expect("seed existing log");
let file = prepare_daemon_log_file_with_cap(&log_path, 1_000_000);
assert!(file.is_some());
assert!(
!log_dir.join("khived.log.1").exists(),
"under-cap log must not be rotated"
);
let content = std::fs::read_to_string(&log_path).expect("read log");
assert_eq!(
content, "existing content\n",
"append-open must preserve existing content"
);
}
#[test]
fn prepare_daemon_log_file_rotates_when_over_cap() {
let dir = tempfile::tempdir().expect("tempdir");
let log_dir = dir.path().join("logs");
std::fs::create_dir_all(&log_dir).expect("create log dir");
let log_path = log_dir.join("khived.log");
std::fs::write(&log_path, vec![7u8; 20]).expect("seed oversized log");
let file = prepare_daemon_log_file_with_cap(&log_path, 10);
assert!(file.is_some());
let backup = log_dir.join("khived.log.1");
assert!(backup.exists(), "oversized log must rotate to .log.1");
assert_eq!(
std::fs::metadata(&backup).expect("backup metadata").len(),
20,
"backup must retain the original oversized content"
);
assert_eq!(
std::fs::metadata(&log_path).expect("log metadata").len(),
0,
"post-rotation log must start fresh"
);
}
#[test]
fn prepare_daemon_log_file_rotation_replaces_prior_backup() {
let dir = tempfile::tempdir().expect("tempdir");
let log_dir = dir.path().join("logs");
std::fs::create_dir_all(&log_dir).expect("create log dir");
let log_path = log_dir.join("khived.log");
let backup = log_dir.join("khived.log.1");
std::fs::write(&backup, b"stale backup").expect("seed stale backup");
std::fs::write(&log_path, vec![9u8; 20]).expect("seed oversized log");
let file = prepare_daemon_log_file_with_cap(&log_path, 10);
assert!(file.is_some());
let backup_content = std::fs::read(&backup).expect("read backup");
assert_eq!(
backup_content,
vec![9u8; 20],
"rotation must replace the prior .log.1, not merge with it"
);
}
#[test]
fn prepare_daemon_log_file_returns_none_when_dir_creation_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let blocker = dir.path().join("logs");
std::fs::write(&blocker, b"not a directory").expect("seed blocker file");
let log_path = blocker.join("khived.log");
assert!(prepare_daemon_log_file_with_cap(&log_path, DAEMON_LOG_MAX_BYTES).is_none());
}
#[test]
#[serial]
fn remove_daemon_paths_if_still_stale_removes_when_pid_unchanged() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = dir.path().join("khived.pid");
let sock = dir.path().join("khived.sock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::fs::write(&pid_file, "4242").expect("write pid file");
std::fs::write(&sock, "stale socket placeholder").expect("write stale sock placeholder");
remove_daemon_paths_if_still_stale(&pid_file, Some(4242));
assert!(!pid_file.exists(), "unchanged pid file must be removed");
assert!(!sock.exists(), "stale socket must be removed");
clear_daemon_env();
}
#[test]
#[serial]
fn remove_daemon_paths_if_still_stale_skips_when_pid_file_changed() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = dir.path().join("khived.pid");
let sock = dir.path().join("khived.sock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::fs::write(&pid_file, "5555").expect("write replacement pid file");
std::fs::write(&sock, "replacement socket placeholder").expect("write sock placeholder");
remove_daemon_paths_if_still_stale(&pid_file, Some(4242));
assert!(
pid_file.exists(),
"replacement daemon's pid file must survive when it no longer \
matches the expected (pre-SIGTERM) pid"
);
assert!(
sock.exists(),
"replacement daemon's socket must survive alongside its pid file"
);
clear_daemon_env();
}
#[test]
#[serial]
fn remove_daemon_paths_if_still_stale_skips_when_socket_has_a_live_listener() {
clear_daemon_env();
let dir = tempfile::tempdir().expect("tempdir");
let pid_file = dir.path().join("khived.pid");
let sock = dir.path().join("khived.sock");
std::env::set_var("KHIVE_SOCKET", &sock);
std::fs::write(&pid_file, "4242").expect("write pid file matching expected_pid");
let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind live socket");
remove_daemon_paths_if_still_stale(&pid_file, Some(4242));
assert!(
sock.exists(),
"a socket with a live listener must never be unlinked"
);
assert!(
pid_file.exists(),
"pid file must be left alone alongside the live socket"
);
clear_daemon_env();
}
#[test]
fn resumed_generation_from_args_absent_is_none() {
let argv = vec![
"kkernel".to_string(),
"mcp".to_string(),
"--daemon".to_string(),
];
assert_eq!(resumed_generation_from_args(argv.into_iter()), None);
}
#[test]
fn resumed_generation_from_args_present_parses_value() {
let argv = vec![
"kkernel".to_string(),
"mcp".to_string(),
"--resumed-generation=1".to_string(),
];
assert_eq!(resumed_generation_from_args(argv.into_iter()), Some(1));
}
#[test]
fn resumed_generation_from_args_malformed_value_is_none() {
let argv = vec![
"kkernel".to_string(),
"--resumed-generation=notanumber".to_string(),
];
assert_eq!(resumed_generation_from_args(argv.into_iter()), None);
}
#[test]
fn resumed_generation_from_args_takes_the_last_occurrence() {
let argv = vec![
"kkernel".to_string(),
"--resumed-generation=1".to_string(),
"--resumed-generation=2".to_string(),
];
assert_eq!(resumed_generation_from_args(argv.into_iter()), Some(2));
}
#[test]
fn resumed_generation_is_none_for_a_normal_test_process() {
assert_eq!(resumed_generation(), None);
}
#[test]
fn decide_mismatch_recovery_first_generation_schedules_reexec() {
assert_eq!(
decide_mismatch_recovery(None),
MismatchRecovery::ReexecScheduled
);
}
#[test]
fn decide_mismatch_recovery_resumed_generation_drains_and_exits() {
assert_eq!(
decide_mismatch_recovery(Some(1)),
MismatchRecovery::DrainAndExit
);
}
#[test]
#[serial]
fn schedule_reexec_on_mismatch_arms_without_firing() {
reset_self_heal_counters();
schedule_reexec_on_mismatch();
assert_eq!(
REEXEC_INVOKED_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0,
"arming must never exec synchronously or eagerly"
);
fire_pending_self_heal();
assert_eq!(
REEXEC_INVOKED_COUNT.load(std::sync::atomic::Ordering::SeqCst),
1,
"the armed action must fire exactly once it is taken"
);
}
#[test]
#[serial]
fn schedule_drain_and_exit_arms_without_firing() {
reset_self_heal_counters();
schedule_drain_and_exit();
assert_eq!(
DRAIN_EXIT_INVOKED_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0,
"arming must never exit synchronously or eagerly"
);
fire_pending_self_heal();
assert_eq!(
DRAIN_EXIT_INVOKED_COUNT.load(std::sync::atomic::Ordering::SeqCst),
1,
"the armed action must fire exactly once it is taken"
);
}
#[test]
#[serial]
fn fire_pending_self_heal_is_a_no_op_when_nothing_is_armed() {
reset_self_heal_counters();
fire_pending_self_heal();
assert_eq!(
REEXEC_INVOKED_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0
);
assert_eq!(
DRAIN_EXIT_INVOKED_COUNT.load(std::sync::atomic::Ordering::SeqCst),
0
);
}
#[test]
#[serial]
fn fire_pending_self_heal_takes_the_armed_action_exactly_once() {
reset_self_heal_counters();
schedule_reexec_on_mismatch();
fire_pending_self_heal();
fire_pending_self_heal();
assert_eq!(
REEXEC_INVOKED_COUNT.load(std::sync::atomic::Ordering::SeqCst),
1,
"must fire exactly once even if fire_pending_self_heal is called again"
);
}
}