use std::sync::Arc;
use std::time::Duration;
use bitrouter::build_app_with_path;
use bitrouter::daemon::{self, DaemonCommand, DaemonResponse, NoopObserveStatus, NoopReloader};
use bitrouter_sdk::App;
struct RoutingTableReloader(Arc<App>);
#[async_trait::async_trait]
impl daemon::DaemonReloader for RoutingTableReloader {
async fn reload(&self) -> anyhow::Result<()> {
if let Some(pipeline) = self.0.language_model() {
pipeline.routing_table().reload().await?;
}
Ok(())
}
}
use bitrouter_sdk::config;
fn tiny_config_yaml(db_url: &str) -> String {
format!(
r#"
server:
listen: "127.0.0.1:0"
skip_auth: true
database:
url: "{db_url}"
providers:
openai:
api_base: https://api.openai.com/v1
api_key: k1
models: [{{ id: gpt-5 }}, {{ id: shared }}]
anthropic:
api_base: https://api.anthropic.com/v1
api_key: k2
models: [{{ id: shared }}]
"#
)
}
async fn write_config(dir: &std::path::Path, db_url: &str) -> std::path::PathBuf {
tokio::fs::create_dir_all(dir).await.unwrap();
let path = dir.join("bitrouter.yaml");
tokio::fs::write(&path, tiny_config_yaml(db_url))
.await
.unwrap();
path
}
fn tempdir(tag: &str) -> std::path::PathBuf {
#[cfg(unix)]
let base = std::path::PathBuf::from("/tmp");
#[cfg(not(unix))]
let base = std::env::temp_dir();
base.join(format!(
"brd-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
async fn wait_until_ready(socket: &std::path::Path) {
for _ in 0..100 {
if daemon::send_command(socket, &DaemonCommand::Status)
.await
.is_ok()
{
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn status_route_and_stop_roundtrip_over_the_control_socket() {
let dir = tempdir("status");
let cfg_path = write_config(&dir, "sqlite::memory:").await;
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
let app = Arc::new(assembled.app);
let socket = dir.join("bitrouter.sock");
let server = tokio::spawn(daemon::run_control_socket(
socket.clone(),
app.clone(),
"127.0.0.1:1234".to_string(),
Arc::new(NoopReloader),
Arc::new(NoopObserveStatus { compiled_in: false }),
));
wait_until_ready(&socket).await;
let status = daemon::send_command(&socket, &DaemonCommand::Status)
.await
.unwrap();
match status {
DaemonResponse::Status { listen, models, .. } => {
assert_eq!(listen, "127.0.0.1:1234");
assert_eq!(models, 2, "gpt-5 + shared");
}
other => panic!("expected Status, got {other:?}"),
}
let route = daemon::send_command(
&socket,
&DaemonCommand::Route {
model: "shared".to_string(),
},
)
.await
.unwrap();
match route {
DaemonResponse::Route { chain } => {
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].provider, "anthropic");
assert_eq!(chain[1].provider, "openai");
}
other => panic!("expected Route, got {other:?}"),
}
let observe = daemon::send_command(&socket, &DaemonCommand::ObserveStatus)
.await
.unwrap();
match observe {
DaemonResponse::ObserveStatus { payload } => {
assert!(!payload.compiled_in, "test wired compiled_in: false");
assert!(!payload.exporter_wired);
assert!(payload.endpoint.is_none());
}
other => panic!("expected ObserveStatus, got {other:?}"),
}
let stop = daemon::send_command(&socket, &DaemonCommand::Stop)
.await
.unwrap();
assert!(matches!(stop, DaemonResponse::Ok));
server.await.unwrap().unwrap();
assert!(
!daemon::endpoint_in_use(&socket),
"control endpoint should be released on shutdown"
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn reload_re_reads_the_config_file() {
let dir = tempdir("reload");
let cfg_path = write_config(&dir, "sqlite::memory:").await;
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
let app = Arc::new(assembled.app);
let socket = dir.join("bitrouter.sock");
let server = tokio::spawn(daemon::run_control_socket(
socket.clone(),
app.clone(),
"127.0.0.1:0".to_string(),
Arc::new(RoutingTableReloader(app.clone())),
Arc::new(NoopObserveStatus { compiled_in: false }),
));
wait_until_ready(&socket).await;
let new_yaml = r#"
server:
listen: "127.0.0.1:0"
skip_auth: true
database:
url: "sqlite::memory:"
providers:
openai:
api_base: https://api.openai.com/v1
api_key: k1
models: [{ id: gpt-5 }, { id: shared }]
"#;
tokio::fs::write(&cfg_path, new_yaml).await.unwrap();
let resp = daemon::send_command(&socket, &DaemonCommand::Reload { env: Vec::new() })
.await
.unwrap();
assert!(matches!(resp, DaemonResponse::Ok));
let route = daemon::send_command(
&socket,
&DaemonCommand::Route {
model: "shared".to_string(),
},
)
.await
.unwrap();
match route {
DaemonResponse::Route { chain } => {
assert_eq!(chain.len(), 1, "anthropic should be gone after reload");
assert_eq!(chain[0].provider, "openai");
}
other => panic!("expected Route, got {other:?}"),
}
let _ = daemon::send_command(&socket, &DaemonCommand::Stop).await;
let _ = server.await;
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn reload_re_applies_builtin_provider_catalog() {
use bitrouter::daemon::DaemonReloader;
use bitrouter::reload::{AppReloader, ReloadSource};
let dir = tempdir("reload-builtin");
tokio::fs::create_dir_all(&dir).await.unwrap();
let cfg_path = dir.join("bitrouter.yaml");
let yaml = r#"
server:
listen: "127.0.0.1:0"
skip_auth: true
database:
url: "sqlite::memory:"
inherit_defaults: true
providers:
openai:
api_key: k1
models: [{ id: gpt-5 }]
"#;
tokio::fs::write(&cfg_path, yaml).await.unwrap();
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
assert_eq!(
assembled.routing_table.snapshot_config().providers["openai"].api_base,
"https://api.openai.com/v1",
);
let reloader = AppReloader::new(
assembled.policy_store.clone(),
assembled.routing_table.clone(),
ReloadSource::File(cfg_path.clone()),
);
reloader.reload().await.expect("reload succeeds");
let after = assembled.routing_table.snapshot_config();
let openai = after.providers.get("openai").expect("openai still present");
assert_eq!(
openai.api_base, "https://api.openai.com/v1",
"built-in `api_base` must survive a file reload",
);
assert!(
!openai.api_protocol.is_empty(),
"built-in `api_protocol` must survive a file reload",
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn route_for_unknown_model_returns_a_clean_error() {
let dir = tempdir("noroute");
let cfg_path = write_config(&dir, "sqlite::memory:").await;
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
let app = Arc::new(assembled.app);
let socket = dir.join("bitrouter.sock");
let server = tokio::spawn(daemon::run_control_socket(
socket.clone(),
app.clone(),
"127.0.0.1:0".to_string(),
Arc::new(NoopReloader),
Arc::new(NoopObserveStatus { compiled_in: false }),
));
wait_until_ready(&socket).await;
let resp = daemon::send_command(
&socket,
&DaemonCommand::Route {
model: "no-such-model".to_string(),
},
)
.await
.unwrap();
match resp {
DaemonResponse::Error { message } => {
assert!(message.contains("no-such-model") || message.to_lowercase().contains("model"));
}
other => panic!("expected Error, got {other:?}"),
}
let _ = daemon::send_command(&socket, &DaemonCommand::Stop).await;
let _ = server.await;
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn concurrent_clients_are_all_served() {
let dir = tempdir("concurrent");
let cfg_path = write_config(&dir, "sqlite::memory:").await;
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
let app = Arc::new(assembled.app);
let socket = dir.join("bitrouter.sock");
let server = tokio::spawn(daemon::run_control_socket(
socket.clone(),
app.clone(),
"127.0.0.1:0".to_string(),
Arc::new(NoopReloader),
Arc::new(NoopObserveStatus { compiled_in: false }),
));
wait_until_ready(&socket).await;
let s1 = socket.clone();
let s2 = socket.clone();
let a = tokio::spawn(async move { daemon::send_command(&s1, &DaemonCommand::Status).await });
let b = tokio::spawn(async move {
daemon::send_command(
&s2,
&DaemonCommand::Route {
model: "shared".to_string(),
},
)
.await
});
let r1 = a.await.unwrap().unwrap();
let r2 = b.await.unwrap().unwrap();
assert!(matches!(r1, DaemonResponse::Status { .. }));
assert!(matches!(r2, DaemonResponse::Route { .. }));
let _ = daemon::send_command(&socket, &DaemonCommand::Stop).await;
let _ = server.await;
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn malformed_input_does_not_take_the_server_down() {
let dir = tempdir("malformed");
let cfg_path = write_config(&dir, "sqlite::memory:").await;
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
let app = Arc::new(assembled.app);
let socket = dir.join("bitrouter.sock");
let server = tokio::spawn(daemon::run_control_socket(
socket.clone(),
app.clone(),
"127.0.0.1:0".to_string(),
Arc::new(NoopReloader),
Arc::new(NoopObserveStatus { compiled_in: false }),
));
wait_until_ready(&socket).await;
{
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
let stream = daemon::connect_control(&socket).await.unwrap();
let mut s = BufReader::new(stream);
s.get_mut().write_all(b"not-json-at-all\n").await.unwrap();
s.get_mut().flush().await.unwrap();
let mut line = String::new();
s.read_line(&mut line).await.unwrap();
assert!(
line.contains("error"),
"expected an Error response, got: {line}"
);
assert!(
line.contains("invalid command"),
"should explain the parse failure"
);
}
let resp = daemon::send_command(&socket, &DaemonCommand::Status)
.await
.unwrap();
assert!(matches!(resp, DaemonResponse::Status { .. }));
let _ = daemon::send_command(&socket, &DaemonCommand::Stop).await;
let _ = server.await;
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn reload_returns_error_when_the_config_is_broken() {
let dir = tempdir("badyaml");
let cfg_path = write_config(&dir, "sqlite::memory:").await;
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
let app = Arc::new(assembled.app);
let socket = dir.join("bitrouter.sock");
let server = tokio::spawn(daemon::run_control_socket(
socket.clone(),
app.clone(),
"127.0.0.1:0".to_string(),
Arc::new(RoutingTableReloader(app.clone())),
Arc::new(NoopObserveStatus { compiled_in: false }),
));
wait_until_ready(&socket).await;
tokio::fs::write(&cfg_path, "this: is: not: valid: yaml: [{")
.await
.unwrap();
let resp = daemon::send_command(&socket, &DaemonCommand::Reload { env: Vec::new() })
.await
.unwrap();
match resp {
DaemonResponse::Error { message } => {
assert!(
message.to_lowercase().contains("reload failed"),
"expected 'reload failed' prefix, got: {message}"
);
}
other => panic!("expected Error, got {other:?}"),
}
let resp = daemon::send_command(&socket, &DaemonCommand::Status)
.await
.unwrap();
assert!(matches!(resp, DaemonResponse::Status { .. }));
let _ = daemon::send_command(&socket, &DaemonCommand::Stop).await;
let _ = server.await;
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[cfg(unix)]
#[tokio::test]
async fn socket_file_has_owner_only_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir("perms");
let cfg_path = write_config(&dir, "sqlite::memory:").await;
let cfg = config::load(&cfg_path).await.unwrap();
let assembled = build_app_with_path(&cfg, Some(&cfg_path)).await.unwrap();
let app = Arc::new(assembled.app);
let socket = dir.join("bitrouter.sock");
let server = tokio::spawn(daemon::run_control_socket(
socket.clone(),
app.clone(),
"127.0.0.1:0".to_string(),
Arc::new(NoopReloader),
Arc::new(NoopObserveStatus { compiled_in: false }),
));
wait_until_ready(&socket).await;
let meta = tokio::fs::metadata(&socket).await.unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "control socket must be 0600, got {mode:o}");
let _ = daemon::send_command(&socket, &DaemonCommand::Stop).await;
let _ = server.await;
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn client_fails_clearly_when_no_daemon_is_listening() {
let bogus = std::env::temp_dir().join(format!(
"no-bitrouter-{}.sock",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let err = daemon::send_command(&bogus, &DaemonCommand::Status)
.await
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("daemon running") || msg.contains("connecting to"),
"expected a helpful error, got: {msg}"
);
}