use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use bitrouter_sdk::App;
use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::language_model::RoutingPrefs;
#[async_trait::async_trait]
pub trait DaemonReloader: Send + Sync {
async fn reload(&self) -> anyhow::Result<()>;
}
pub struct NoopReloader;
#[async_trait::async_trait]
impl DaemonReloader for NoopReloader {
async fn reload(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd", rename_all = "snake_case")]
pub enum DaemonCommand {
Stop,
Reload {
#[serde(default)]
env: Vec<(String, String)>,
},
Status,
Route {
model: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteHop {
pub provider: String,
pub service_id: String,
pub api_protocol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "resp", rename_all = "snake_case")]
pub enum DaemonResponse {
Ok,
Status {
pid: u32,
listen: String,
models: usize,
},
Route {
chain: Vec<RouteHop>,
},
Error {
message: String,
},
}
pub const DEFAULT_CONTROL_SOCKET: &str = "./bitrouter.sock";
pub fn resolve_socket_path(config_path: &Path, cfg_socket: &str) -> PathBuf {
let raw = Path::new(cfg_socket);
if raw.is_absolute() {
return raw.to_path_buf();
}
let stripped = raw.strip_prefix("./").unwrap_or(raw);
let parent = config_path.parent().filter(|p| !p.as_os_str().is_empty());
match parent {
Some(dir) => dir.join(stripped),
None => stripped.to_path_buf(),
}
}
pub fn is_not_reachable(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.map(|io| {
matches!(
io.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
)
})
.unwrap_or(false)
})
}
pub async fn run_control_socket(
socket_path: PathBuf,
app: Arc<App>,
listen: String,
reloader: Arc<dyn DaemonReloader>,
) -> Result<()> {
let _ = tokio::fs::remove_file(&socket_path).await;
let listener = UnixListener::bind(&socket_path)
.with_context(|| format!("binding control socket {}", socket_path.display()))?;
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))
.await
.with_context(|| format!("chmod 0600 {}", socket_path.display()))?;
tracing::info!(socket = %socket_path.display(), "control socket listening (mode 0600)");
let result = accept_loop(&listener, &app, &listen, &reloader).await;
let _ = tokio::fs::remove_file(&socket_path).await;
result
}
async fn accept_loop(
listener: &UnixListener,
app: &Arc<App>,
listen: &str,
reloader: &Arc<dyn DaemonReloader>,
) -> Result<()> {
loop {
let (stream, _addr) = listener
.accept()
.await
.context("accepting control-socket connection")?;
if handle_connection(stream, app, listen, reloader).await? {
tracing::info!("stop command received — shutting down");
return Ok(());
}
}
}
async fn handle_connection(
stream: UnixStream,
app: &Arc<App>,
listen: &str,
reloader: &Arc<dyn DaemonReloader>,
) -> Result<bool> {
let mut reader = BufReader::new(stream);
let mut line = String::new();
if reader.read_line(&mut line).await? == 0 {
return Ok(false); }
let command: DaemonCommand = match serde_json::from_str(line.trim()) {
Ok(c) => c,
Err(e) => {
write_response(
reader.get_mut(),
&DaemonResponse::Error {
message: format!("invalid command: {e}"),
},
)
.await?;
return Ok(false);
}
};
let is_stop = matches!(command, DaemonCommand::Stop);
let response = dispatch(command, app, listen, reloader).await;
write_response(reader.get_mut(), &response).await?;
Ok(is_stop)
}
async fn dispatch(
command: DaemonCommand,
app: &Arc<App>,
listen: &str,
reloader: &Arc<dyn DaemonReloader>,
) -> DaemonResponse {
match command {
DaemonCommand::Stop => DaemonResponse::Ok,
DaemonCommand::Reload { env } => {
if !env.is_empty() {
let map: std::collections::HashMap<String, String> = env.into_iter().collect();
bitrouter_sdk::config::set_env_overrides(map);
tracing::info!("env override map updated by reload");
}
match reloader.reload().await {
Ok(()) => {
tracing::info!("reload succeeded");
DaemonResponse::Ok
}
Err(e) => DaemonResponse::Error {
message: format!("reload failed: {e}"),
},
}
}
DaemonCommand::Status => {
let models = app
.language_model()
.map(|p| p.routing_table().list_models().len())
.unwrap_or(0);
DaemonResponse::Status {
pid: std::process::id(),
listen: listen.to_string(),
models,
}
}
DaemonCommand::Route { model } => {
let Some(pipeline) = app.language_model() else {
return DaemonResponse::Error {
message: "no language_model pipeline configured".to_string(),
};
};
match pipeline
.routing_table()
.route_chain(&model, &RoutingPrefs::default(), &CallerContext::local())
.await
{
Ok(chain) => DaemonResponse::Route {
chain: chain
.into_iter()
.map(|t| RouteHop {
provider: t.provider_name,
service_id: t.service_id,
api_protocol: format!("{:?}", t.api_protocol).to_lowercase(),
})
.collect(),
},
Err(e) => DaemonResponse::Error {
message: e.to_string(),
},
}
}
}
}
async fn write_response(stream: &mut UnixStream, response: &DaemonResponse) -> Result<()> {
let mut json = serde_json::to_string(response).context("serialising daemon response")?;
json.push('\n');
stream.write_all(json.as_bytes()).await?;
stream.flush().await?;
Ok(())
}
pub async fn send_command(socket_path: &Path, command: &DaemonCommand) -> Result<DaemonResponse> {
let stream = UnixStream::connect(socket_path).await.with_context(|| {
format!(
"connecting to {} — is the daemon running? (`bitrouter start`)",
socket_path.display()
)
})?;
let mut reader = BufReader::new(stream);
let mut json = serde_json::to_string(command).context("serialising command")?;
json.push('\n');
reader.get_mut().write_all(json.as_bytes()).await?;
reader.get_mut().flush().await?;
let mut line = String::new();
reader
.read_line(&mut line)
.await
.context("reading daemon response")?;
if line.trim().is_empty() {
anyhow::bail!("daemon closed the connection without responding");
}
serde_json::from_str(line.trim()).context("parsing daemon response")
}
pub async fn write_pid_file(path: &Path) -> Result<()> {
tokio::fs::write(path, std::process::id().to_string())
.await
.with_context(|| format!("writing pid file {}", path.display()))
}
pub async fn read_pid_file(path: &Path) -> Option<u32> {
let raw = tokio::fs::read_to_string(path).await.ok()?;
raw.trim().parse().ok()
}
pub async fn remove_pid_file(path: &Path) {
let _ = tokio::fs::remove_file(path).await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_round_trip_as_json() {
for cmd in [
DaemonCommand::Stop,
DaemonCommand::Reload { env: Vec::new() },
DaemonCommand::Status,
DaemonCommand::Route {
model: "gpt-5".to_string(),
},
] {
let json = serde_json::to_string(&cmd).unwrap();
let back: DaemonCommand = serde_json::from_str(&json).unwrap();
assert_eq!(std::mem::discriminant(&cmd), std::mem::discriminant(&back));
}
}
#[test]
fn legacy_unit_reload_command_still_deserialises() {
let back: DaemonCommand = serde_json::from_str(r#"{"cmd":"reload"}"#).unwrap();
match back {
DaemonCommand::Reload { env } => assert!(env.is_empty()),
other => panic!("expected Reload, got {other:?}"),
}
}
#[test]
fn reload_command_carries_env_overrides() {
let cmd = DaemonCommand::Reload {
env: vec![("OPENAI_API_KEY".to_string(), "sk-test".to_string())],
};
let json = serde_json::to_string(&cmd).unwrap();
let back: DaemonCommand = serde_json::from_str(&json).unwrap();
match back {
DaemonCommand::Reload { env } => {
assert_eq!(env.len(), 1);
assert_eq!(
env[0],
("OPENAI_API_KEY".to_string(), "sk-test".to_string())
);
}
other => panic!("expected Reload, got {other:?}"),
}
}
#[test]
fn responses_round_trip_as_json() {
let resp = DaemonResponse::Status {
pid: 42,
listen: "0.0.0.0:4356".to_string(),
models: 3,
};
let json = serde_json::to_string(&resp).unwrap();
let back: DaemonResponse = serde_json::from_str(&json).unwrap();
match back {
DaemonResponse::Status { pid, models, .. } => {
assert_eq!(pid, 42);
assert_eq!(models, 3);
}
other => panic!("expected Status, got {other:?}"),
}
}
#[test]
fn resolve_socket_joins_relative_paths_onto_config_directory() {
let resolved = resolve_socket_path(
Path::new("/Users/x/.bitrouter/bitrouter.yaml"),
"./bitrouter.sock",
);
assert_eq!(
resolved,
PathBuf::from("/Users/x/.bitrouter/bitrouter.sock")
);
}
#[test]
fn resolve_socket_handles_relative_without_dot_prefix() {
let resolved = resolve_socket_path(
Path::new("/Users/x/.bitrouter/bitrouter.yaml"),
"bitrouter.sock",
);
assert_eq!(
resolved,
PathBuf::from("/Users/x/.bitrouter/bitrouter.sock")
);
}
#[test]
fn resolve_socket_joins_nested_relative_path() {
let resolved = resolve_socket_path(
Path::new("/Users/x/.bitrouter/bitrouter.yaml"),
"sub/bitrouter.sock",
);
assert_eq!(
resolved,
PathBuf::from("/Users/x/.bitrouter/sub/bitrouter.sock")
);
}
#[test]
fn resolve_socket_passes_absolute_paths_through() {
let resolved = resolve_socket_path(
Path::new("/Users/x/.bitrouter/bitrouter.yaml"),
"/var/run/bitrouter.sock",
);
assert_eq!(resolved, PathBuf::from("/var/run/bitrouter.sock"));
}
#[test]
fn resolve_socket_handles_config_paths_without_parent() {
let resolved = resolve_socket_path(Path::new("bitrouter.yaml"), "./bitrouter.sock");
assert_eq!(resolved, PathBuf::from("bitrouter.sock"));
}
#[test]
fn is_not_reachable_detects_socket_not_found() {
let io_err = std::io::Error::from(std::io::ErrorKind::NotFound);
let err: anyhow::Error =
anyhow::Error::new(io_err).context("connecting to /tmp/bitrouter.sock");
assert!(is_not_reachable(&err));
}
#[test]
fn is_not_reachable_detects_connection_refused() {
let io_err = std::io::Error::from(std::io::ErrorKind::ConnectionRefused);
let err: anyhow::Error =
anyhow::Error::new(io_err).context("connecting to /tmp/bitrouter.sock");
assert!(is_not_reachable(&err));
}
#[test]
fn is_not_reachable_ignores_unrelated_io_errors() {
let io_err = std::io::Error::from(std::io::ErrorKind::PermissionDenied);
let err: anyhow::Error =
anyhow::Error::new(io_err).context("connecting to /tmp/bitrouter.sock");
assert!(!is_not_reachable(&err));
}
#[test]
fn is_not_reachable_false_for_plain_messages() {
let err = anyhow::anyhow!("daemon closed the connection without responding");
assert!(!is_not_reachable(&err));
}
}