use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
const DAEMON_PROBE_TIMEOUT: Duration = Duration::from_millis(750);
pub const DAEMON_POLL_INTERVAL: Duration = Duration::from_millis(500);
pub const DAEMON_START_TIMEOUT: Duration = Duration::from_secs(30);
pub struct DaemonBridgeConfig {
pub service_name: String,
pub spawn_args: Vec<String>,
pub health_path: String,
pub base_url_fn: Box<dyn Fn() -> String + Send + Sync>,
pub startup_timeout: Option<Duration>,
pub poll_interval: Option<Duration>,
pub no_spawn: bool,
}
impl DaemonBridgeConfig {
pub fn health_url(&self) -> String {
format!("{}{}", (self.base_url_fn)(), self.health_path)
}
}
pub(crate) async fn probe_health_once(health_url: &str) -> bool {
let client = match reqwest::Client::builder()
.timeout(DAEMON_PROBE_TIMEOUT)
.connect_timeout(DAEMON_PROBE_TIMEOUT)
.build()
{
Ok(c) => c,
Err(_) => return false,
};
matches!(
client.get(health_url).send().await,
Ok(resp) if resp.status().is_success()
)
}
pub async fn ensure_daemon_up(config: &DaemonBridgeConfig) -> Result<String> {
let startup_timeout = config.startup_timeout.unwrap_or(DAEMON_START_TIMEOUT);
let poll_interval = config.poll_interval.unwrap_or(DAEMON_POLL_INTERVAL);
let initial_url = (config.base_url_fn)();
if probe_health_once(&config.health_url()).await {
return Ok(initial_url);
}
if config.no_spawn {
let addr = (config.base_url_fn)();
return Err(anyhow!(
"{} daemon is not reachable at {} — \
start it with `{} start` (launchd-managed) before using the MCP bridge. \
If already installed via `{} setup`, run `launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/io.trusty.{}.plist` or `{} start`.",
config.service_name,
addr,
config.service_name,
config.service_name,
config.service_name,
config.service_name,
));
}
eprintln!("\u{25cf} Starting {} daemon\u{2026}", config.service_name);
let exe = std::env::current_exe().map_err(|e| anyhow!("could not resolve current_exe: {e}"))?;
let stable_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
std::process::Command::new(&exe)
.args(&config.spawn_args)
.current_dir(&stable_dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| {
anyhow!(
"could not spawn `{} {}`: {e}",
exe.display(),
config.spawn_args.join(" "),
)
})?;
let deadline = Instant::now() + startup_timeout;
loop {
tokio::time::sleep(poll_interval).await;
let current_url = (config.base_url_fn)();
let health_url = format!("{current_url}{}", config.health_path);
if probe_health_once(&health_url).await {
eprintln!("\u{2713} {} daemon ready.", config.service_name);
return Ok(current_url);
}
if Instant::now() >= deadline {
return Err(anyhow!(
"{} daemon did not become ready within {}s. \
Check `{} doctor` for details. \
The MCP stdio bridge cannot operate without a running daemon.",
config.service_name,
startup_timeout.as_secs(),
config.service_name,
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn make_config(base_url: &'static str, health_path: &str) -> DaemonBridgeConfig {
DaemonBridgeConfig {
service_name: "test-svc".to_string(),
spawn_args: vec!["serve".to_string(), "--foreground".to_string()],
health_path: health_path.to_string(),
base_url_fn: Box::new(move || base_url.to_string()),
startup_timeout: Some(Duration::from_millis(100)), poll_interval: Some(Duration::from_millis(20)),
no_spawn: false,
}
}
#[test]
fn daemon_bridge_config_health_url() {
let cfg = make_config("http://127.0.0.1:9999", "/health");
assert_eq!(cfg.health_url(), "http://127.0.0.1:9999/health");
let cfg2 = make_config("http://127.0.0.1:9999", "/api/v1/health");
assert_eq!(cfg2.health_url(), "http://127.0.0.1:9999/api/v1/health");
}
#[tokio::test]
async fn probe_health_once_returns_false_on_refused() {
let started = std::time::Instant::now();
let result = probe_health_once("http://127.0.0.1:65534/health").await;
assert!(!result, "probe must fail against an unbound port");
assert!(
started.elapsed() < Duration::from_secs(6),
"probe took too long: {:?}",
started.elapsed()
);
}
#[tokio::test]
async fn ensure_daemon_up_returns_ok_when_already_healthy() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
loop {
if let Ok((mut stream, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::AsyncWriteExt;
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
.await;
});
}
}
});
tokio::time::sleep(Duration::from_millis(20)).await;
let base = format!("http://127.0.0.1:{port}");
let cfg = DaemonBridgeConfig {
service_name: "test-svc".to_string(),
spawn_args: vec![],
health_path: "/health".to_string(),
base_url_fn: Box::new(move || base.clone()),
startup_timeout: Some(Duration::from_secs(5)),
poll_interval: Some(Duration::from_millis(50)),
no_spawn: false,
};
let result = ensure_daemon_up(&cfg).await;
assert!(
result.is_ok(),
"must succeed when daemon is healthy: {result:?}"
);
}
#[tokio::test]
async fn ensure_daemon_up_errors_on_timeout() {
let cfg = make_config("http://127.0.0.1:1", "/health");
let result = ensure_daemon_up(&cfg).await;
assert!(
result.is_err(),
"must fail when the daemon never becomes ready"
);
}
#[tokio::test]
async fn no_spawn_returns_err_without_spawning() {
let cfg = DaemonBridgeConfig {
service_name: "trusty-memory".to_string(),
spawn_args: vec!["serve".to_string(), "--foreground".to_string()],
health_path: "/health".to_string(),
base_url_fn: Box::new(|| "http://127.0.0.1:65534".to_string()),
startup_timeout: Some(Duration::from_millis(100)),
poll_interval: Some(Duration::from_millis(20)),
no_spawn: true,
};
let result = ensure_daemon_up(&cfg).await;
assert!(result.is_err(), "no_spawn must return Err when probe fails");
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("trusty-memory"),
"error must name the service; got: {msg}"
);
assert!(
msg.contains("start"),
"error must mention how to start the daemon; got: {msg}"
);
}
}