use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use hyperdb_mcp::daemon::discovery::{self, DaemonInfo, PortScan};
use hyperdb_mcp::daemon::health::{self, DaemonState, HealthListener};
use tempfile::TempDir;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn daemon_state_touch_resets_idle_duration() {
let state = DaemonState::new();
std::thread::sleep(Duration::from_millis(50));
assert!(state.idle_duration() >= Duration::from_millis(50));
state.touch();
assert!(state.idle_duration() < Duration::from_millis(30));
}
#[test]
fn daemon_state_shutdown_flag() {
let state = DaemonState::new();
assert!(!state.should_shutdown());
state.request_shutdown();
assert!(state.should_shutdown());
}
#[test]
fn daemon_state_default_is_equivalent_to_new() {
let default_state = DaemonState::default();
assert!(!default_state.should_shutdown());
assert!(default_state.idle_duration() < Duration::from_millis(100));
}
#[test]
fn daemon_state_default_initializes_restart_flag_false() {
let default_state = DaemonState::default();
let new_state = DaemonState::new();
assert!(!default_state.consume_restart_request());
assert!(!new_state.consume_restart_request());
}
#[test]
fn daemon_state_restart_request_consume_round_trip() {
let state = DaemonState::new();
assert!(!state.consume_restart_request(), "initially clear");
state.request_restart();
assert!(state.consume_restart_request(), "consume returns true once");
assert!(
!state.consume_restart_request(),
"second consume returns false"
);
state.request_restart();
state.request_restart();
state.request_restart();
assert!(
state.consume_restart_request(),
"three requests → one consume"
);
assert!(!state.consume_restart_request());
}
#[test]
fn restart_history_records_attempts_under_limit() {
use hyperdb_mcp::daemon::run::{try_record_restart_attempt, RestartAttempt};
let mut history: Vec<Instant> = Vec::new();
let t0 = Instant::now();
assert_eq!(
try_record_restart_attempt(&mut history, t0),
RestartAttempt::Recorded
);
assert_eq!(
try_record_restart_attempt(&mut history, t0 + Duration::from_secs(10)),
RestartAttempt::Recorded
);
assert_eq!(
try_record_restart_attempt(&mut history, t0 + Duration::from_secs(20)),
RestartAttempt::Recorded
);
assert_eq!(
history.len(),
3,
"first three attempts within window are recorded"
);
}
#[test]
fn restart_history_rejects_fourth_attempt_in_window() {
use hyperdb_mcp::daemon::run::{try_record_restart_attempt, RestartAttempt};
let mut history: Vec<Instant> = Vec::new();
let t0 = Instant::now();
try_record_restart_attempt(&mut history, t0);
try_record_restart_attempt(&mut history, t0 + Duration::from_secs(10));
try_record_restart_attempt(&mut history, t0 + Duration::from_secs(20));
assert_eq!(
try_record_restart_attempt(&mut history, t0 + Duration::from_secs(30)),
RestartAttempt::LimitExceeded,
"4th attempt within window must be rejected"
);
assert_eq!(history.len(), 3, "rejection must not push to history");
}
#[test]
fn restart_history_prunes_entries_older_than_window() {
use hyperdb_mcp::daemon::run::{try_record_restart_attempt, RestartAttempt};
let mut history: Vec<Instant> = Vec::new();
let t0 = Instant::now();
try_record_restart_attempt(&mut history, t0);
try_record_restart_attempt(&mut history, t0 + Duration::from_secs(5));
try_record_restart_attempt(&mut history, t0 + Duration::from_secs(10));
let later = t0 + Duration::from_secs(70);
assert_eq!(
try_record_restart_attempt(&mut history, later),
RestartAttempt::Recorded,
"after window expires, restarts are allowed again"
);
assert_eq!(
history.len(),
1,
"stale entries pruned, only 'later' remains"
);
}
#[test]
fn daemon_config_from_args_none_when_unset() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::remove("HYPERDB_DAEMON_IDLE_TIMEOUT");
let config = hyperdb_mcp::daemon::run::DaemonConfig::from_args(0, None);
assert!(
config.idle_timeout.is_none(),
"idle_timeout should be None when neither flag nor env is set"
);
}
#[test]
fn daemon_config_from_args_some_when_flag() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::remove("HYPERDB_DAEMON_IDLE_TIMEOUT");
let config = hyperdb_mcp::daemon::run::DaemonConfig::from_args(0, Some(120));
assert_eq!(
config.idle_timeout,
Some(Duration::from_secs(120)),
"idle_timeout should match the provided flag value"
);
}
#[test]
fn daemon_config_from_args_some_when_env() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::set("HYPERDB_DAEMON_IDLE_TIMEOUT", "90");
let config = hyperdb_mcp::daemon::run::DaemonConfig::from_args(0, None);
assert_eq!(
config.idle_timeout,
Some(Duration::from_secs(90)),
"idle_timeout should match the env var value when no flag is provided"
);
}
#[test]
fn daemon_config_from_args_flag_takes_precedence() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::set("HYPERDB_DAEMON_IDLE_TIMEOUT", "90");
let config = hyperdb_mcp::daemon::run::DaemonConfig::from_args(0, Some(120));
assert_eq!(
config.idle_timeout,
Some(Duration::from_secs(120)),
"flag value should take precedence over env var"
);
}
#[test]
fn health_listener_bind_succeeds_on_free_port() {
let listener = HealthListener::bind(0).unwrap();
assert_ne!(listener.port, 0);
}
#[test]
fn health_listener_second_bind_same_port_fails() {
let listener = HealthListener::bind(0).unwrap();
let port = listener.port;
let result = HealthListener::bind(port);
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::AddrInUse);
}
#[test]
fn health_protocol_ping_pong() {
let (port, _handle, _state) = start_health_listener();
let response = health::send_command(port, "PING").unwrap();
assert!(response.trim().starts_with("PONG hyperdb-mcp "));
}
#[test]
fn health_protocol_heartbeat_resets_idle() {
let (port, _handle, state) = start_health_listener();
std::thread::sleep(Duration::from_millis(50));
assert!(state.idle_duration() >= Duration::from_millis(50));
let response = health::send_command(port, "HEARTBEAT").unwrap();
assert_eq!(response.trim(), "OK");
assert!(state.idle_duration() < Duration::from_millis(30));
}
#[test]
fn health_protocol_stop_triggers_shutdown() {
let (port, handle, state) = start_health_listener();
assert!(!state.should_shutdown());
let response = health::send_command(port, "STOP").unwrap();
assert_eq!(response.trim(), "STOPPING");
assert!(state.should_shutdown());
handle.join().unwrap();
}
#[test]
fn health_protocol_status_returns_json() {
let (port, _handle, _state) = start_health_listener();
let response = health::send_command(port, "STATUS").unwrap();
let parsed: serde_json::Value = serde_json::from_str(response.trim()).unwrap();
assert_eq!(parsed["pid"], 12345);
assert_eq!(parsed["hyperd_endpoint"], "127.0.0.1:54321");
}
#[test]
fn health_protocol_unknown_command_returns_error() {
let (port, _handle, _state) = start_health_listener();
let response = health::send_command(port, "INVALID").unwrap();
assert!(response.contains("ERR"));
}
#[test]
fn health_protocol_report_hyperd_error_sets_flag() {
let (port, _handle, state) = start_health_listener();
assert!(!state.consume_restart_request(), "flag starts clear");
let response = health::send_command(port, "REPORT_HYPERD_ERROR").unwrap();
assert_eq!(response.trim(), "OK");
assert!(
state.consume_restart_request(),
"REPORT_HYPERD_ERROR must set the restart-requested flag"
);
}
#[test]
fn health_protocol_multi_command_session() {
let (port, _handle, _state) = start_health_listener();
let response1 = health::send_command(port, "PING").unwrap();
assert!(response1.trim().starts_with("PONG hyperdb-mcp "));
let response2 = health::send_command(port, "STATUS").unwrap();
let parsed: serde_json::Value = serde_json::from_str(response2.trim()).unwrap();
assert_eq!(parsed["health_port"], port);
let response3 = health::send_command(port, "HEARTBEAT").unwrap();
assert_eq!(response3.trim(), "OK");
}
#[test]
fn health_protocol_ping_identity_accept() {
let (port, _handle, _state) = start_health_listener();
let version =
health::ping_identified(port, Duration::from_millis(300), Duration::from_millis(300))
.expect("should return Some for a valid hyperdb-mcp daemon");
assert_eq!(version, hyperdb_mcp::version::MCP_VERSION);
}
#[test]
fn health_protocol_ping_identity_reject_foreign() {
use std::io::Write;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let _ = stream.write_all(b"PONG\n");
}
});
std::thread::sleep(Duration::from_millis(50));
let result =
health::ping_identified(port, Duration::from_millis(300), Duration::from_millis(300));
assert_eq!(result, None, "should reject foreign PONG without token");
}
#[test]
fn health_protocol_ping_identity_reject_token_lookalike() {
use std::io::Write;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let _ = stream.write_all(b"PONG hyperdb-mcpEVIL 9.9.9\n");
}
});
std::thread::sleep(Duration::from_millis(50));
let result =
health::ping_identified(port, Duration::from_millis(300), Duration::from_millis(300));
assert_eq!(
result, None,
"must reject a token that only shares a prefix"
);
}
#[test]
fn health_protocol_ping_identity_reject_refused() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
std::thread::sleep(Duration::from_millis(10));
let result =
health::ping_identified(port, Duration::from_millis(300), Duration::from_millis(300));
assert_eq!(result, None, "should return None for connection refused");
}
#[test]
fn resolve_port_scan_pins_when_env_set() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::set("HYPERDB_DAEMON_PORT", "9001");
assert_eq!(
discovery::resolve_port_scan(),
PortScan {
base: 9001,
span: 1
}
);
}
#[test]
fn resolve_port_scan_scans_when_env_unset() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::remove("HYPERDB_DAEMON_PORT");
let scan = discovery::resolve_port_scan();
assert_eq!(scan.base, hyperdb_mcp::daemon::DEFAULT_DAEMON_BASE_PORT);
assert_eq!(scan.span, hyperdb_mcp::daemon::DAEMON_PORT_SCAN_SPAN);
}
#[test]
fn daemon_idle_timeout_shuts_down_daemon() {
let state = Arc::new(DaemonState::new());
let idle_timeout = Duration::from_secs(2);
let monitor_state = Arc::clone(&state);
let monitor = std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(100));
if monitor_state.idle_duration() >= idle_timeout {
monitor_state.request_shutdown();
break;
}
if monitor_state.should_shutdown() {
break;
}
});
let start = Instant::now();
monitor.join().unwrap();
let elapsed = start.elapsed();
assert!(state.should_shutdown());
assert!(elapsed >= Duration::from_secs(2));
assert!(elapsed < Duration::from_secs(4));
}
#[test]
fn daemon_heartbeat_prevents_idle_shutdown() {
let state = Arc::new(DaemonState::new());
let idle_timeout = Duration::from_secs(1);
let monitor_state = Arc::clone(&state);
let heartbeat_state = Arc::clone(&state);
let heartbeat = std::thread::spawn(move || {
let start = Instant::now();
while start.elapsed() < Duration::from_millis(1500) {
heartbeat_state.touch();
std::thread::sleep(Duration::from_millis(200));
}
});
let monitor = std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_millis(100));
if monitor_state.idle_duration() >= idle_timeout {
monitor_state.request_shutdown();
break;
}
if monitor_state.should_shutdown() {
break;
}
});
heartbeat.join().unwrap();
let start = Instant::now();
monitor.join().unwrap();
let after_heartbeat_stop = start.elapsed();
assert!(state.should_shutdown());
assert!(
after_heartbeat_stop >= Duration::from_millis(500),
"daemon should have waited for idle timeout after heartbeats stopped, \
but only waited {after_heartbeat_stop:?}"
);
}
#[test]
fn discovery_file_write_and_read() {
let _lock = acquire_env_lock();
let tmp = TempDir::new().unwrap();
let _guard = EnvGuard::set("HYPERDB_STATE_DIR", tmp.path().to_str().unwrap());
let info = DaemonInfo {
pid: 12345,
hyperd_endpoint: "127.0.0.1:54321".to_string(),
health_port: 7484,
started_at: "2026-05-20T10:30:00Z".to_string(),
version: "0.1.3".to_string(),
};
discovery::write_discovery_file(&info).unwrap();
let path = tmp.path().join("daemon.json");
assert!(path.exists());
let contents = std::fs::read_to_string(&path).unwrap();
let read_back: DaemonInfo = serde_json::from_str(&contents).unwrap();
assert_eq!(read_back.pid, 12345);
assert_eq!(read_back.hyperd_endpoint, "127.0.0.1:54321");
assert_eq!(read_back.health_port, 7484);
assert_eq!(read_back.version, "0.1.3");
}
#[test]
fn discovery_file_overwrite_replaces_content() {
let _lock = acquire_env_lock();
let tmp = TempDir::new().unwrap();
let _guard = EnvGuard::set("HYPERDB_STATE_DIR", tmp.path().to_str().unwrap());
let info1 = DaemonInfo {
pid: 100,
hyperd_endpoint: "127.0.0.1:1111".to_string(),
health_port: 7484,
started_at: "2026-01-01T00:00:00Z".to_string(),
version: "0.1.0".to_string(),
};
discovery::write_discovery_file(&info1).unwrap();
let info2 = DaemonInfo {
pid: 200,
hyperd_endpoint: "127.0.0.1:2222".to_string(),
health_port: 7485,
started_at: "2026-02-02T00:00:00Z".to_string(),
version: "0.2.0".to_string(),
};
discovery::write_discovery_file(&info2).unwrap();
let path = tmp.path().join("daemon.json");
let contents = std::fs::read_to_string(&path).unwrap();
let read_back: DaemonInfo = serde_json::from_str(&contents).unwrap();
assert_eq!(read_back.pid, 200);
assert_eq!(read_back.hyperd_endpoint, "127.0.0.1:2222");
}
#[test]
fn remove_discovery_file_deletes_it() {
let _lock = acquire_env_lock();
let tmp = TempDir::new().unwrap();
let _guard = EnvGuard::set("HYPERDB_STATE_DIR", tmp.path().to_str().unwrap());
let info = DaemonInfo {
pid: 1,
hyperd_endpoint: "127.0.0.1:1".to_string(),
health_port: 7484,
started_at: "2026-01-01T00:00:00Z".to_string(),
version: "0.0.1".to_string(),
};
discovery::write_discovery_file(&info).unwrap();
let path = tmp.path().join("daemon.json");
assert!(path.exists());
discovery::remove_discovery_file();
assert!(!path.exists());
}
#[test]
fn discover_returns_none_when_no_file_exists() {
let _lock = acquire_env_lock();
let tmp = TempDir::new().unwrap();
let _guard = EnvGuard::set("HYPERDB_STATE_DIR", tmp.path().to_str().unwrap());
assert!(discovery::discover().is_none());
}
#[test]
fn discover_returns_none_for_stale_file() {
let _lock = acquire_env_lock();
let tmp = TempDir::new().unwrap();
let _guard = EnvGuard::set("HYPERDB_STATE_DIR", tmp.path().to_str().unwrap());
let info = DaemonInfo {
pid: 99999,
hyperd_endpoint: "127.0.0.1:1".to_string(),
health_port: 1,
started_at: "2026-01-01T00:00:00Z".to_string(),
version: "0.0.1".to_string(),
};
discovery::write_discovery_file(&info).unwrap();
assert!(discovery::discover().is_none());
let path = tmp.path().join("daemon.json");
assert!(!path.exists());
}
#[test]
fn resolve_port_uses_env_var() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::set("HYPERDB_DAEMON_PORT", "9999");
assert_eq!(discovery::resolve_port(), 9999);
}
#[test]
fn resolve_port_uses_default_when_env_unset() {
let _lock = acquire_env_lock();
let _guard = EnvGuard::remove("HYPERDB_DAEMON_PORT");
assert_eq!(
discovery::resolve_port(),
hyperdb_mcp::daemon::DEFAULT_DAEMON_BASE_PORT
);
}
#[test]
fn scan_finds_our_daemon_via_status() {
let _lock = acquire_env_lock();
let (port, _handle, _state) = start_health_listener();
let scan = PortScan {
base: port,
span: 1,
};
match discovery::scan_for_daemon(scan) {
discovery::ScanOutcome::Found(info) => {
assert_eq!(info.hyperd_endpoint, "127.0.0.1:54321");
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn scan_skips_camped_returns_free() {
let _lock = acquire_env_lock();
for _attempt in 0..5 {
let (camped_listener, base) = loop {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
if port < u16::MAX {
if let Ok(probe) = TcpListener::bind(("127.0.0.1", port + 1)) {
drop(probe);
break (listener, port);
}
}
drop(listener);
};
let expected_free = base + 1;
std::thread::spawn(move || loop {
if let Ok((mut stream, _)) = camped_listener.accept() {
use std::io::Write;
let _ = stream.write_all(b"NOPE\n");
}
});
std::thread::sleep(Duration::from_millis(50));
let scan = PortScan { base, span: 2 };
match discovery::scan_for_daemon(scan) {
discovery::ScanOutcome::FreePort(port) => {
assert_eq!(
port, expected_free,
"scan should skip the camped base port and return base+1"
);
return; }
discovery::ScanOutcome::Found(_) => {
}
other => panic!("expected FreePort, got {other:?}"),
}
}
panic!("scan_skips_camped_returns_free: failed after 5 attempts (port stolen each time)");
}
#[test]
fn scan_all_refused_returns_freeport_base() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = listener.local_addr().unwrap().port();
drop(listener);
std::thread::sleep(Duration::from_millis(10));
let scan = PortScan { base, span: 1 };
match discovery::scan_for_daemon(scan) {
discovery::ScanOutcome::FreePort(port) => {
assert_eq!(port, base, "should return the first free port (base)");
}
other => panic!("expected FreePort(base), got {other:?}"),
}
}
#[test]
fn probe_refused_when_closed() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
std::thread::sleep(Duration::from_millis(10));
let scan = PortScan {
base: port,
span: 1,
};
match discovery::scan_for_daemon(scan) {
discovery::ScanOutcome::FreePort(p) => {
assert_eq!(
p, port,
"probe_port should have returned Refused for closed port"
);
}
other => panic!("expected FreePort (Refused), got {other:?}"),
}
}
#[test]
fn discover_finds_live_daemon() {
let _lock = acquire_env_lock();
let tmp = TempDir::new().unwrap();
let _guard = EnvGuard::set("HYPERDB_STATE_DIR", tmp.path().to_str().unwrap());
let (port, _handle, _state) = start_health_listener();
let info = DaemonInfo {
pid: 12345,
hyperd_endpoint: "127.0.0.1:54321".to_string(),
health_port: port,
started_at: "2026-05-20T10:30:00Z".to_string(),
version: "0.1.3".to_string(),
};
discovery::write_discovery_file(&info).unwrap();
let discovered = discovery::discover().expect("should discover live daemon");
assert_eq!(discovered.pid, 12345);
assert_eq!(discovered.health_port, port);
}
#[test]
fn scan_for_daemon_prefers_lower_port_daemon() {
let (lower_port, _lower_handle, _lower_state) = start_health_listener();
let (higher_port, _higher_handle, _higher_state) = start_health_listener();
let (base, _top) = if lower_port < higher_port {
(lower_port, higher_port)
} else {
(higher_port, lower_port)
};
let scan = PortScan { base, span: 2 };
match discovery::scan_for_daemon(scan) {
discovery::ScanOutcome::Found(info) => {
assert_eq!(
info.health_port, base,
"scan should return the LOWEST-port daemon (the first one bound)"
);
}
other => panic!("expected Found, got {other:?}"),
}
}
#[test]
fn takeover_decision_newer_client_takes_over() {
assert!(
hyperdb_mcp::daemon::spawn::client_should_take_over("0.5.0", "0.4.0"),
"0.5.0 client should take over 0.4.0 daemon"
);
}
#[test]
fn takeover_decision_equal_version_reuses() {
assert!(
!hyperdb_mcp::daemon::spawn::client_should_take_over("0.4.0", "0.4.0"),
"equal versions should reuse daemon"
);
}
#[test]
fn takeover_decision_older_client_reuses() {
assert!(
!hyperdb_mcp::daemon::spawn::client_should_take_over("0.4.0", "0.5.0"),
"older client should reuse newer daemon"
);
}
#[test]
fn takeover_decision_client_unparseable_reuses() {
assert!(
!hyperdb_mcp::daemon::spawn::client_should_take_over("garbage", "0.4.0"),
"unparseable client version should reuse daemon"
);
}
#[test]
fn takeover_decision_daemon_unparseable_reuses() {
assert!(
!hyperdb_mcp::daemon::spawn::client_should_take_over("0.4.0", "garbage"),
"unparseable daemon version should reuse daemon"
);
}
#[test]
fn takeover_decision_both_unparseable_reuses() {
assert!(
!hyperdb_mcp::daemon::spawn::client_should_take_over("garbage", "junk"),
"both unparseable should reuse daemon"
);
}
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn daemon_mode_engine_connects_to_shared_hyperd() {
let _lock = acquire_env_lock();
let daemon = TestDaemon::start();
let tmp = TempDir::new().unwrap();
let workspace_path = tmp.path().join("test.hyper");
let engine =
hyperdb_mcp::engine::Engine::new(Some(workspace_path.to_str().unwrap().to_string()))
.expect("engine should connect to daemon");
assert!(engine.is_running());
let endpoint = engine.hyperd_endpoint().unwrap();
assert_eq!(endpoint, daemon.info.hyperd_endpoint);
}
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn daemon_mode_two_engines_share_same_hyperd() {
let _lock = acquire_env_lock();
let daemon = TestDaemon::start();
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let path1 = tmp1.path().join("db1.hyper");
let path2 = tmp2.path().join("db2.hyper");
let engine1 =
hyperdb_mcp::engine::Engine::new(Some(path1.to_str().unwrap().to_string())).unwrap();
let engine2 =
hyperdb_mcp::engine::Engine::new(Some(path2.to_str().unwrap().to_string())).unwrap();
let ep1 = engine1.hyperd_endpoint().unwrap();
let ep2 = engine2.hyperd_endpoint().unwrap();
assert!(
!ep1.is_empty() && !ep2.is_empty(),
"both engines must report a daemon endpoint"
);
let status = health::send_command(daemon.info.health_port, "PING").unwrap();
assert!(status.trim().starts_with("PONG hyperdb-mcp "));
engine1.execute_command("CREATE TABLE foo (x INT)").unwrap();
engine1
.execute_command("INSERT INTO foo VALUES (42)")
.unwrap();
let tables = engine2.describe_tables().unwrap();
assert!(
tables.iter().all(|t| t["name"] != "foo"),
"engine2 should not see engine1's table"
);
}
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn daemon_mode_persistent_database_file_survives_engine_drop() {
let _lock = acquire_env_lock();
let _daemon = TestDaemon::start();
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("persistent.hyper");
let path_str = path.to_str().unwrap().to_string();
{
let engine = hyperdb_mcp::engine::Engine::new(Some(path_str.clone())).unwrap();
engine
.execute_command("CREATE TABLE survive (val TEXT)")
.unwrap();
engine
.execute_command("INSERT INTO survive VALUES ('hello')")
.unwrap();
}
assert!(
path.exists(),
"persistent .hyper file should survive engine drop"
);
}
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn daemon_mode_persistent_engine_data_is_queryable() {
let _lock = acquire_env_lock();
let daemon = TestDaemon::start();
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("queryable.hyper");
let path_str = path.to_str().unwrap().to_string();
let engine = hyperdb_mcp::engine::Engine::new(Some(path_str)).unwrap();
engine
.execute_command("CREATE TABLE items (id INT, name TEXT)")
.unwrap();
engine
.execute_command("INSERT INTO items VALUES (1, 'alpha'), (2, 'beta')")
.unwrap();
let rows = engine
.execute_query_to_json("SELECT * FROM items ORDER BY id")
.unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["name"], "alpha");
assert_eq!(rows[1]["name"], "beta");
let resp = health::send_command(daemon.info.health_port, "PING").unwrap();
assert!(resp.trim().starts_with("PONG hyperdb-mcp "));
}
#[cfg(unix)]
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn hyperd_monitor_detects_killed_hyperd_and_restarts() {
let _lock = acquire_env_lock();
let daemon = TestDaemon::start();
let pid_before = find_hyperd_pid_for_endpoint(&daemon.info.hyperd_endpoint)
.expect("should find hyperd pid for endpoint before kill");
kill_pid(pid_before);
let new_endpoint = wait_for_endpoint_change_or_recovery(daemon.info.health_port, 12)
.expect("daemon should restart hyperd within 12s");
let probe = std::net::TcpStream::connect_timeout(
&new_endpoint.parse().expect("valid endpoint"),
Duration::from_secs(2),
);
assert!(probe.is_ok(), "new hyperd endpoint should be reachable");
}
#[cfg(unix)]
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn client_report_triggers_restart_after_kill() {
let _lock = acquire_env_lock();
let daemon = TestDaemon::start();
let pid_before = find_hyperd_pid_for_endpoint(&daemon.info.hyperd_endpoint)
.expect("should find hyperd pid before kill");
kill_pid(pid_before);
let response = health::send_command(daemon.info.health_port, "REPORT_HYPERD_ERROR").unwrap();
assert_eq!(response.trim(), "OK");
let new_endpoint = wait_for_endpoint_change_or_recovery(daemon.info.health_port, 12)
.expect("daemon should restart hyperd within 12s after report");
let probe = std::net::TcpStream::connect_timeout(
&new_endpoint.parse().expect("valid endpoint"),
Duration::from_secs(2),
);
assert!(probe.is_ok(), "new hyperd endpoint should be reachable");
}
#[cfg(unix)]
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn engine_recovers_after_hyperd_killed() {
let _lock = acquire_env_lock();
let daemon = TestDaemon::start();
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("recover.hyper");
let path_str = path.to_str().unwrap().to_string();
{
let engine = hyperdb_mcp::engine::Engine::new(Some(path_str.clone())).unwrap();
engine
.execute_command("CREATE TABLE \"persistent\".\"public\".\"keepers\" (n INT)")
.unwrap();
engine
.execute_command(
"INSERT INTO \"persistent\".\"public\".\"keepers\" VALUES (1), (2), (3)",
)
.unwrap();
}
let pid =
find_hyperd_pid_for_endpoint(&daemon.info.hyperd_endpoint).expect("should find hyperd pid");
kill_pid(pid);
wait_for_endpoint_change_or_recovery(daemon.info.health_port, 12)
.expect("daemon should restart hyperd within 12s");
let engine = hyperdb_mcp::engine::Engine::new(Some(path_str)).unwrap();
let rows = engine
.execute_query_to_json("SELECT n FROM \"persistent\".\"public\".\"keepers\" ORDER BY n")
.unwrap();
assert_eq!(rows.len(), 3, "data persisted across hyperd restart");
}
#[test]
#[ignore = "flaky on macOS CI — daemon startup exceeds 150s timeout"]
fn daemon_mode_ephemeral_database_cleaned_up_on_drop() {
let _lock = acquire_env_lock();
let _daemon = TestDaemon::start();
let engine = hyperdb_mcp::engine::Engine::new(None).unwrap();
let ephemeral_path = engine.ephemeral_path().to_path_buf();
assert!(ephemeral_path.exists());
engine
.execute_command("CREATE TABLE ephemeral_test (id INT)")
.unwrap();
drop(engine);
assert!(
!ephemeral_path.exists(),
"ephemeral .hyper file should be deleted after engine drop"
);
}
fn start_health_listener() -> (u16, std::thread::JoinHandle<()>, Arc<DaemonState>) {
let listener = HealthListener::bind(0).unwrap();
let port = listener.port;
let state = Arc::new(DaemonState::new());
let run_state = Arc::clone(&state);
let info = Arc::new(Mutex::new(DaemonInfo {
pid: 12345,
hyperd_endpoint: "127.0.0.1:54321".to_string(),
health_port: port,
started_at: "2026-05-20T10:30:00Z".to_string(),
version: "0.1.3".to_string(),
}));
let handle = std::thread::spawn(move || {
listener.run(run_state, info);
});
std::thread::sleep(Duration::from_millis(50));
(port, handle, state)
}
struct TestDaemon {
info: DaemonInfo,
_state_dir_guard: EnvGuard,
_port_guard: EnvGuard,
}
impl TestDaemon {
fn start() -> Self {
let tmp = TempDir::new().unwrap();
let tmp = Box::leak(Box::new(tmp));
let state_dir_guard = EnvGuard::set("HYPERDB_STATE_DIR", tmp.path().to_str().unwrap());
let port_guard = EnvGuard::set("HYPERDB_DAEMON_PORT", "0");
let daemon_handle = std::thread::spawn(move || -> Result<(), String> {
let rt = tokio::runtime::Runtime::new().map_err(|e| format!("rt: {e}"))?;
rt.block_on(async {
let config = hyperdb_mcp::daemon::run::DaemonConfig {
port: 0,
idle_timeout: Some(Duration::from_secs(300)),
};
hyperdb_mcp::daemon::run::run_daemon(config)
.await
.map_err(|e| format!("run_daemon: {e}"))
})
});
let start = Instant::now();
loop {
if let Some(info) = discovery::discover() {
return Self {
info,
_state_dir_guard: state_dir_guard,
_port_guard: port_guard,
};
}
if daemon_handle.is_finished() {
let msg = match daemon_handle.join() {
Ok(Ok(())) => "daemon thread exited cleanly without writing discovery".into(),
Ok(Err(e)) => format!("daemon thread errored: {e}"),
Err(_) => "daemon thread panicked".into(),
};
panic!("TestDaemon failed to start: {msg}");
}
assert!(
start.elapsed() <= Duration::from_secs(150),
"TestDaemon did not start within 150s (daemon thread still running, no discovery file written)"
);
std::thread::sleep(Duration::from_millis(200));
}
}
}
impl Drop for TestDaemon {
fn drop(&mut self) {
let _ = health::send_command(self.info.health_port, "STOP");
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], self.info.health_port));
if std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_err() {
return;
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
#[cfg(unix)]
fn find_hyperd_pid_for_endpoint(endpoint: &str) -> Option<u32> {
use std::process::Command;
let port = endpoint.rsplit(':').next()?.parse::<u16>().ok()?;
let output = Command::new("lsof")
.args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
output
.stdout
.split(|b| *b == b'\n')
.filter_map(|line| std::str::from_utf8(line).ok())
.map(str::trim)
.find(|s| !s.is_empty())
.and_then(|s| s.parse::<u32>().ok())
}
#[cfg(unix)]
fn kill_pid(pid: u32) {
let status = std::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.status()
.expect("kill -9 should run");
assert!(status.success(), "kill -9 {pid} failed");
}
#[cfg(unix)]
fn wait_for_endpoint_change_or_recovery(health_port: u16, timeout_secs: u64) -> Option<String> {
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
while Instant::now() < deadline {
if let Ok(response) = health::send_command(health_port, "STATUS") {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(response.trim()) {
if let Some(endpoint) = parsed["hyperd_endpoint"].as_str() {
if let Ok(addr) = endpoint.parse::<std::net::SocketAddr>() {
if std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(500))
.is_ok()
{
return Some(endpoint.to_string());
}
}
}
}
}
std::thread::sleep(Duration::from_millis(250));
}
None
}
struct EnvGuard {
key: String,
previous: Option<String>,
}
impl EnvGuard {
fn set(key: &str, value: &str) -> Self {
let previous = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
Self {
key: key.to_string(),
previous,
}
}
fn remove(key: &str) -> Self {
let previous = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
Self {
key: key.to_string(),
previous,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.previous {
Some(val) => unsafe { std::env::set_var(&self.key, val) },
None => unsafe { std::env::remove_var(&self.key) },
}
}
}