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) 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);
}
#[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,
) -> 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)
.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()
}
}
enum ForwardOutcome {
Response(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(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,
) -> 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 || resp.config_mismatch {
return None;
}
if resp.served_config_id.as_deref() != Some(expected_config_id) {
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)))
}
}
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())
.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();
if let Ok(contents) = std::fs::read_to_string(&pid_file) {
if let Ok(pid) = contents.trim().parse::<u32>() {
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"
);
}
}
}
let _ = std::fs::remove_file(&pid_file);
let _ = std::fs::remove_file(socket_path());
}
enum ProbeOutcome {
Alive,
Dead,
Timeout,
}
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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_only: true,
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,
}
}
enum RecoveryOutcome {
Skipped,
Spawned,
}
async fn kill_and_respawn(config_id: &str, namespace: &str) -> std::io::Result<RecoveryOutcome> {
let _lock = acquire_recovery_lock();
match probe_daemon_identity(config_id, namespace, 500).await {
ProbeOutcome::Alive | ProbeOutcome::Timeout => {
return Ok(RecoveryOutcome::Skipped);
}
ProbeOutcome::Dead => {}
}
kill_stale_daemon_inner();
spawn_daemon()?;
Ok(RecoveryOutcome::Spawned)
}
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),
ForwardOutcome::NoSocket => {
}
ForwardOutcome::ParseFailure => {
tracing::info!("killing stale daemon (undecodable response) and respawning");
match kill_and_respawn(&frame.config_id, &frame.namespace).await {
Err(e) => {
tracing::warn!(error = %e,
"kill_and_respawn failed during stale-daemon recovery; falling back to local dispatch");
return None;
}
Ok(RecoveryOutcome::Skipped) => {
return match try_forward_inner(frame).await {
ForwardOutcome::Response(resp) => map_response(resp, &frame.config_id),
_ => None,
};
}
Ok(RecoveryOutcome::Spawned) => {}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let sock = socket_path();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
while tokio::time::Instant::now() < deadline {
if UnixStream::connect(&sock).await.is_ok() {
return match try_forward_inner(frame).await {
ForwardOutcome::Response(resp) => map_response(resp, &frame.config_id),
ForwardOutcome::ParseFailure => {
tracing::warn!(
"freshly spawned daemon also returned an undecodable response; \
falling back to local dispatch"
);
None
}
ForwardOutcome::ProtocolMismatch => Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
run `make local` to rebuild the daemon binary"
),
None,
))),
ForwardOutcome::NoSocket => None,
};
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
return None;
}
ForwardOutcome::ProtocolMismatch => {
tracing::info!(
"killing stale daemon (protocol version mismatch, old daemon) and respawning"
);
match kill_and_respawn(&frame.config_id, &frame.namespace).await {
Err(_) => {
return Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
respawn failed — run `make local` to rebuild the daemon binary"
),
None,
)));
}
Ok(RecoveryOutcome::Skipped) => {
return match try_forward_inner(frame).await {
ForwardOutcome::Response(resp) => map_response(resp, &frame.config_id),
_ => Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
run `make local` to rebuild the daemon binary"
),
None,
))),
};
}
Ok(RecoveryOutcome::Spawned) => {}
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let sock = socket_path();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if tokio::time::Instant::now() >= deadline {
break;
}
if UnixStream::connect(&sock).await.is_ok() {
return match try_forward_inner(frame).await {
ForwardOutcome::Response(resp) => map_response(resp, &frame.config_id),
ForwardOutcome::ProtocolMismatch | ForwardOutcome::ParseFailure => {
Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
respawned daemon still reports wrong version — \
run `make local` to rebuild the daemon binary"
),
None,
)))
}
ForwardOutcome::NoSocket => Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
respawned daemon did not accept connections — \
run `make local` to rebuild the daemon binary"
),
None,
))),
};
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
return Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
respawned daemon did not become ready within 5s — \
run `make local` to rebuild the daemon binary"
),
None,
)));
}
}
if spawn_daemon().is_err() {
return None;
}
let sock = socket_path();
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
while tokio::time::Instant::now() < deadline {
if UnixStream::connect(&sock).await.is_ok() {
return match try_forward_inner(frame).await {
ForwardOutcome::Response(resp) => map_response(resp, &frame.config_id),
ForwardOutcome::ParseFailure => {
tracing::warn!(
"freshly spawned daemon also returned an undecodable response; \
falling back to local dispatch"
);
None
}
ForwardOutcome::ProtocolMismatch => Some(Err(McpError::internal_error(
format!(
"daemon protocol mismatch: expected version {PROTOCOL_VERSION}; \
run `make local` to rebuild the daemon binary"
),
None,
))),
ForwardOutcome::NoSocket => None,
};
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use khive_runtime::daemon::run_daemon;
use serial_test::serial;
use khive_runtime::{KhiveRuntime, Namespace, RuntimeConfig};
fn make_test_server() -> crate::server::KhiveMcpServer {
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "gtd".to_string()],
..RuntimeConfig::default()
};
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 config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("braintest").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "brain".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("in-memory runtime");
crate::server::KhiveMcpServer::new(runtime).expect("server builds with kg+brain")
}
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");
}
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";
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,
}
}
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,
}
}
#[test]
fn map_response_namespace_mismatch_yields_none() {
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,
};
assert!(map_response(resp, CFG).is_none());
}
#[test]
fn map_response_config_mismatch_yields_none() {
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,
};
assert!(map_response(resp, CFG).is_none());
}
#[test]
fn map_response_legacy_daemon_missing_echo_yields_none() {
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,
};
assert!(map_response(resp, CFG).is_none());
}
#[test]
fn map_response_echo_drift_yields_none() {
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,
};
assert!(map_response(resp, CFG).is_none());
}
#[test]
fn map_response_ok_with_result_yields_some_ok() {
match map_response(frame_ok("the-result"), CFG) {
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,
};
match map_response(resp, CFG) {
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) {
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) {
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,
};
match map_response(resp, CFG) {
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,
};
match map_response(resp, CFG) {
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:?}"),
}
}
#[tokio::test]
#[serial]
async fn forward_or_spawn_returns_none_when_no_daemon_set() {
clear_daemon_env();
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(),
config_id: "test".to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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());
clear_daemon_env();
}
#[tokio::test]
#[serial]
async fn daemon_round_trip_dispatches_and_enforces_namespace() {
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(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_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(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_only: false,
format: None,
format_per_op: None,
from_wire: false,
};
let resp_other = exchange(&sock, &other).await;
assert!(resp_other.namespace_mismatch);
assert!(!resp_other.ok);
let mismatched_config = DaemonRequestFrame {
ops: "stats()".to_string(),
presentation: None,
presentation_per_op: None,
namespace: "test".to_string(),
config_id: "packs=[kg];db=:memory:;embed=none;extra=[];backend=main".to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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(),
config_id: config_id.clone(),
protocol_version: 0,
probe_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_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(),
config_id: config_id.clone(),
protocol_version: PROTOCOL_VERSION,
probe_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,
}
}
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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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,
) -> 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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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,
) -> 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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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");
}
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,
}
}
#[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,
) -> 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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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,
}
}
#[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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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,
}
}
#[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(),
config_id: config_id.to_string(),
protocol_version: PROTOCOL_VERSION,
probe_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();
}
}