use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
use bitrouter_sdk::App;
use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::language_model::RoutingPrefs;
use chrono::{DateTime, Utc};
use crate::acp_runtime::AcpRuntime;
use crate::actions::administration::{
Administration, AgentsReport, ObserveReport, PolicyInput, PolicyReport, ProvidersReport,
};
use crate::metering::{MeteringStore, TimeWindow};
use crate::reload::{ReloadAdmissionError, ReloadReport, ReloadReservation, ReloadState};
#[async_trait::async_trait]
pub trait DaemonReloader: Send + Sync {
async fn reload(&self) -> anyhow::Result<()>;
async fn reload_with_env(&self, env: Vec<(String, String)>) -> anyhow::Result<()> {
if env.is_empty() {
self.reload().await
} else {
Err(anyhow::anyhow!(
"this daemon reloader does not support coordinated environment overrides"
))
}
}
fn reload_state(&self) -> Option<ReloadState> {
None
}
fn reserve_remote(
&self,
_expected_instance: &str,
_expected_generation: u64,
) -> Result<ReloadReservation, ReloadAdmissionError> {
Err(ReloadAdmissionError::Unsupported)
}
async fn reload_reserved(&self, _reservation: ReloadReservation) -> ReloadReport {
ReloadReport::unsupported()
}
}
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)>,
},
ReloadState,
Status,
Models,
Route {
model: String,
},
ObserveStatus,
Inspect { inspection: DaemonInspection },
Evolution {
operation: crate::evolution::operator::EvolutionOperation,
},
AcpRecordingRegister {
connection_id: String,
api_principal: String,
controller_instance_id: String,
},
AcpControllerCleanup {
api_principal: String,
controller_instance_id: String,
},
AcpRouteList {
api_principal: String,
controller_instance_id: String,
session_id: String,
},
AcpRouteSet {
api_principal: String,
controller_instance_id: String,
session_id: String,
route: String,
},
AcpSessionSpend {
api_principal: String,
controller_instance_id: String,
session_id: String,
},
AcpRouteReset {
api_principal: String,
controller_instance_id: String,
session_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "inspection", rename_all = "snake_case")]
pub enum DaemonInspection {
Providers,
Agents,
Observe,
Policy { input: PolicyInput },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "report", rename_all = "snake_case")]
pub enum DaemonInspectionReport {
Providers(ProvidersReport),
Agents(AgentsReport),
Observe(ObserveReport),
Policy(PolicyReport),
}
#[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,
#[serde(default)]
providers: Vec<String>,
},
Models {
models: Vec<bitrouter_sdk::language_model::routing::ModelInfo>,
},
Route {
chain: Vec<RouteHop>,
},
ObserveStatus {
payload: ObserveStatusPayload,
},
ReloadState { state: ReloadState },
Inspection { report: DaemonInspectionReport },
Evolution {
report: Box<crate::evolution::operator::EvolutionReport>,
},
AcpRecordingRegistered {
registration: crate::evolution::inventory::CoverageRegistration,
},
AcpRouteState {
available: Vec<String>,
current: Option<String>,
scope: String,
},
AcpSessionSpend {
spend_micro_usd: u64,
requests: u64,
unpriced: u64,
},
Error {
message: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserveStatusPayload {
pub compiled_in: bool,
pub exporter_wired: bool,
pub endpoint: Option<String>,
pub header_count: usize,
pub service_name: Option<String>,
pub resource_attribute_count: usize,
pub sampler: Option<String>,
pub sampler_arg: Option<f64>,
pub metrics_enabled: bool,
pub api_key_count: usize,
pub api_key_cap: usize,
pub user_id_count: usize,
pub user_id_cap: usize,
pub active_spans: usize,
}
impl ObserveStatusPayload {
pub fn unwired(compiled_in: bool) -> Self {
Self {
compiled_in,
exporter_wired: false,
endpoint: None,
header_count: 0,
service_name: None,
resource_attribute_count: 0,
sampler: None,
sampler_arg: None,
metrics_enabled: false,
api_key_count: 0,
api_key_cap: 0,
user_id_count: 0,
user_id_cap: 0,
active_spans: 0,
}
}
}
#[async_trait::async_trait]
pub trait ObserveStatusProvider: Send + Sync {
fn status(&self) -> ObserveStatusPayload;
async fn shutdown(&self) {}
}
pub struct NoopObserveStatus {
pub compiled_in: bool,
}
#[async_trait::async_trait]
impl ObserveStatusProvider for NoopObserveStatus {
fn status(&self) -> ObserveStatusPayload {
ObserveStatusPayload::unwired(self.compiled_in)
}
}
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)
})
}
#[derive(Debug, Clone)]
pub struct ReadyInfo {
pub pid: u32,
pub listen: String,
pub models: usize,
}
pub fn socket_path_for(
source: &crate::paths::ConfigSource,
cfg: &bitrouter_sdk::config::Config,
) -> PathBuf {
match source {
crate::paths::ConfigSource::File(path) => {
resolve_socket_path(path, &cfg.server.control_socket)
}
crate::paths::ConfigSource::Default { home } => home.join("bitrouter.sock"),
}
}
pub async fn probe_status(socket: &Path) -> Result<Option<ReadyInfo>> {
match send_command(socket, &DaemonCommand::Status).await {
Ok(DaemonResponse::Status {
pid,
listen,
models,
..
}) => Ok(Some(ReadyInfo {
pid,
listen,
models,
})),
Ok(DaemonResponse::Error { message }) => Err(anyhow::anyhow!(message)),
Ok(other) => Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) if is_not_reachable(&e) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn run_control_socket(
socket_path: PathBuf,
app: Arc<App>,
listen: String,
reloader: Arc<dyn DaemonReloader>,
observe: Arc<dyn ObserveStatusProvider>,
metering: MeteringStore,
) -> Result<()> {
run_control_socket_with_acp_runtime(
socket_path,
app,
listen,
reloader,
observe,
AcpControlPlane {
runtime: Arc::new(AcpRuntime::new()),
metering,
inventory: None,
evolution: None,
},
)
.await
}
#[derive(Clone)]
pub struct AcpControlPlane {
pub runtime: Arc<AcpRuntime>,
pub metering: MeteringStore,
pub inventory: Option<crate::evolution::inventory::GatewayInventory>,
pub evolution: Option<crate::evolution::runtime::EvolutionRuntime>,
}
pub async fn run_control_socket_with_acp_runtime(
socket_path: PathBuf,
app: Arc<App>,
listen: String,
reloader: Arc<dyn DaemonReloader>,
observe: Arc<dyn ObserveStatusProvider>,
acp: AcpControlPlane,
) -> Result<()> {
run_control_socket_with_acp_runtime_and_administration(
socket_path,
app,
listen,
reloader,
observe,
acp,
None,
)
.await
}
pub async fn run_control_socket_with_acp_runtime_and_administration(
socket_path: PathBuf,
app: Arc<App>,
listen: String,
reloader: Arc<dyn DaemonReloader>,
observe: Arc<dyn ObserveStatusProvider>,
acp: AcpControlPlane,
administration: Option<Administration>,
) -> Result<()> {
let mut listener = transport::bind(&socket_path).await?;
let result = accept_loop(
&mut listener,
&app,
&listen,
&reloader,
&observe,
&acp,
&administration,
)
.await;
listener.cleanup().await;
result
}
async fn accept_loop(
listener: &mut transport::ControlListener,
app: &Arc<App>,
listen: &str,
reloader: &Arc<dyn DaemonReloader>,
observe: &Arc<dyn ObserveStatusProvider>,
acp: &AcpControlPlane,
administration: &Option<Administration>,
) -> Result<()> {
loop {
let stream = listener.accept().await?;
if handle_connection(stream, app, listen, reloader, observe, acp, administration).await? {
tracing::info!("stop command received — shutting down");
return Ok(());
}
}
}
async fn handle_connection<S>(
stream: S,
app: &Arc<App>,
listen: &str,
reloader: &Arc<dyn DaemonReloader>,
observe: &Arc<dyn ObserveStatusProvider>,
acp: &AcpControlPlane,
administration: &Option<Administration>,
) -> Result<bool>
where
S: AsyncRead + AsyncWrite + Unpin,
{
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, observe, acp, administration).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>,
observe: &Arc<dyn ObserveStatusProvider>,
acp: &AcpControlPlane,
administration: &Option<Administration>,
) -> DaemonResponse {
match command {
DaemonCommand::Stop => DaemonResponse::Ok,
DaemonCommand::Evolution { operation } => {
let Some(runtime) = &acp.evolution else {
return DaemonResponse::Error {
message: "checkpoint evolution is unavailable on this daemon".into(),
};
};
match runtime.operate("local", operation).await {
Ok(report) => DaemonResponse::Evolution {
report: Box::new(report),
},
Err(error) => DaemonResponse::Error {
message: error.to_string(),
},
}
}
DaemonCommand::AcpRecordingRegister {
connection_id,
api_principal,
controller_instance_id,
} => {
let Some(inventory) = &acp.inventory else {
return DaemonResponse::Error {
message: "gateway request inventory is unavailable on this daemon".into(),
};
};
match inventory
.register_capture(&connection_id, &api_principal, &controller_instance_id)
.await
{
Ok(registration) => DaemonResponse::AcpRecordingRegistered { registration },
Err(error) => DaemonResponse::Error {
message: error.to_string(),
},
}
}
DaemonCommand::Reload { env } => match reloader.reload_with_env(env).await {
Ok(()) => {
tracing::info!("reload succeeded");
DaemonResponse::Ok
}
Err(e) => DaemonResponse::Error {
message: format!("reload failed: {e}"),
},
},
DaemonCommand::ReloadState => match reloader.reload_state() {
Some(state) => DaemonResponse::ReloadState { state },
None => DaemonResponse::Error {
message: "reload state is unavailable on this daemon".to_string(),
},
},
DaemonCommand::Status => {
let routable = app
.language_model()
.map(|p| p.routing_table().list_models())
.unwrap_or_default();
let providers: Vec<String> = routable
.iter()
.flat_map(|m| m.providers.iter().cloned())
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
DaemonResponse::Status {
pid: std::process::id(),
listen: listen.to_string(),
models: routable.len(),
providers,
}
}
DaemonCommand::Models => DaemonResponse::Models {
models: app
.language_model()
.map(|p| p.routing_table().list_models())
.unwrap_or_default(),
},
DaemonCommand::AcpSessionSpend {
api_principal,
controller_instance_id,
session_id,
} => {
if api_principal.trim().is_empty()
|| controller_instance_id.trim().is_empty()
|| session_id.trim().is_empty()
{
return DaemonResponse::Error {
message: "principal, controller and session must not be empty".to_string(),
};
}
let window = TimeWindow::Custom {
start: DateTime::<Utc>::UNIX_EPOCH,
end: Utc::now(),
};
match acp
.metering
.spend_summary_for_acp_session(
&api_principal,
&controller_instance_id,
&session_id,
window,
)
.await
{
Ok(summary) => DaemonResponse::AcpSessionSpend {
spend_micro_usd: summary.spend_micro_usd,
requests: summary.requests,
unpriced: summary.unpriced,
},
Err(error) => DaemonResponse::Error {
message: format!("session spend is unavailable: {error}"),
},
}
}
DaemonCommand::AcpControllerCleanup {
api_principal,
controller_instance_id,
} => {
acp.runtime
.remove_controller(&api_principal, &controller_instance_id);
DaemonResponse::Ok
}
DaemonCommand::AcpRouteList {
api_principal,
controller_instance_id,
session_id,
} => {
if api_principal.trim().is_empty()
|| controller_instance_id.trim().is_empty()
|| session_id.trim().is_empty()
{
DaemonResponse::Error {
message: "API principal, controller id, and session id must not be empty"
.to_string(),
}
} else {
let current = acp
.runtime
.current_route(&api_principal, &controller_instance_id, &session_id)
.map(|lease| lease.route().to_string());
DaemonResponse::AcpRouteState {
available: route_suggestions(app),
scope: if current.is_some() {
"session"
} else {
"default"
}
.to_string(),
current,
}
}
}
DaemonCommand::AcpRouteSet {
api_principal,
controller_instance_id,
session_id,
route,
} => {
let Some(pipeline) = app.language_model() else {
return DaemonResponse::Error {
message: "no language_model pipeline configured".to_string(),
};
};
if api_principal.trim().is_empty()
|| controller_instance_id.trim().is_empty()
|| session_id.trim().is_empty()
|| route.trim().is_empty()
{
return DaemonResponse::Error {
message:
"API principal, controller id, session id, and route must not be empty"
.to_string(),
};
}
if let Err(error) = pipeline
.routing_table()
.route_chain(&route, &RoutingPrefs::default(), &CallerContext::local())
.await
{
return DaemonResponse::Error {
message: format!("route is not available: {error}"),
};
}
match acp.runtime.set_route(
&api_principal,
&controller_instance_id,
&session_id,
&route,
) {
Ok(lease) => DaemonResponse::AcpRouteState {
available: route_suggestions(app),
current: Some(lease.route().to_string()),
scope: "session".to_string(),
},
Err(message) => DaemonResponse::Error { message },
}
}
DaemonCommand::AcpRouteReset {
api_principal,
controller_instance_id,
session_id,
} => {
if api_principal.trim().is_empty()
|| controller_instance_id.trim().is_empty()
|| session_id.trim().is_empty()
{
DaemonResponse::Error {
message: "API principal, controller id, and session id must not be empty"
.to_string(),
}
} else {
acp.runtime
.reset_route(&api_principal, &controller_instance_id, &session_id);
DaemonResponse::AcpRouteState {
available: route_suggestions(app),
current: None,
scope: "default".to_string(),
}
}
}
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(),
},
}
}
DaemonCommand::ObserveStatus => DaemonResponse::ObserveStatus {
payload: observe.status(),
},
DaemonCommand::Inspect { inspection } => {
inspection_response(inspection, administration).await
}
}
}
async fn inspection_response(
inspection: DaemonInspection,
administration: &Option<Administration>,
) -> DaemonResponse {
let Some(administration) = administration else {
return DaemonResponse::Error {
message: "live administration reads are unavailable on this daemon".to_string(),
};
};
match inspection {
DaemonInspection::Providers => DaemonResponse::Inspection {
report: DaemonInspectionReport::Providers(administration.providers()),
},
DaemonInspection::Agents => DaemonResponse::Inspection {
report: DaemonInspectionReport::Agents(administration.agents()),
},
DaemonInspection::Observe => DaemonResponse::Inspection {
report: DaemonInspectionReport::Observe(administration.observe()),
},
DaemonInspection::Policy { input } => match administration.policy(input).await {
Ok(report) => DaemonResponse::Inspection {
report: DaemonInspectionReport::Policy(report),
},
Err(error) => DaemonResponse::Error {
message: format!("policy inspection failed: {error}"),
},
},
}
}
fn route_suggestions(app: &Arc<App>) -> Vec<String> {
app.language_model()
.map(|pipeline| {
pipeline
.routing_table()
.list_models()
.into_iter()
.map(|model| model.id)
.collect()
})
.unwrap_or_default()
}
async fn write_response<S>(stream: &mut S, response: &DaemonResponse) -> Result<()>
where
S: AsyncWrite + Unpin,
{
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 connect_control(socket_path: &Path) -> Result<impl AsyncRead + AsyncWrite + Unpin> {
transport::connect(socket_path).await
}
pub fn endpoint_in_use(path: &Path) -> bool {
transport::endpoint_in_use(path)
}
pub async fn send_command(socket_path: &Path, command: &DaemonCommand) -> Result<DaemonResponse> {
let stream = transport::connect(socket_path).await?;
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")
}
#[cfg(unix)]
mod transport {
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tokio::net::{UnixListener, UnixStream};
pub struct ControlListener {
listener: UnixListener,
path: PathBuf,
}
pub async fn bind(path: &Path) -> Result<ControlListener> {
let _ = tokio::fs::remove_file(path).await;
let listener = UnixListener::bind(path)
.with_context(|| format!("binding control socket {}", path.display()))?;
use std::os::unix::fs::PermissionsExt;
tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
.await
.with_context(|| format!("chmod 0600 {}", path.display()))?;
tracing::info!(socket = %path.display(), "control socket listening (mode 0600)");
Ok(ControlListener {
listener,
path: path.to_path_buf(),
})
}
impl ControlListener {
pub async fn accept(&mut self) -> Result<UnixStream> {
let (stream, _addr) = self
.listener
.accept()
.await
.context("accepting control-socket connection")?;
Ok(stream)
}
pub async fn cleanup(self) {
let _ = tokio::fs::remove_file(&self.path).await;
}
}
pub async fn connect(path: &Path) -> Result<UnixStream> {
UnixStream::connect(path).await.with_context(|| {
format!(
"connecting to {} — is the daemon running? (`{cli} start`)",
path.display(),
cli = bitrouter_sdk::invocation::name()
)
})
}
pub fn endpoint_in_use(path: &Path) -> bool {
path.exists()
}
}
#[cfg(windows)]
mod transport {
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::net::windows::named_pipe::{
ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions,
};
const ERROR_PIPE_BUSY: i32 = 231;
pub fn pipe_name(path: &Path) -> String {
let raw = path.to_string_lossy();
let lower = raw.to_ascii_lowercase();
if lower.starts_with(r"\\.\pipe\") || lower.starts_with(r"\\?\pipe\") {
return raw.into_owned();
}
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(lower.as_bytes());
let digest = hasher.finalize();
format!(r"\\.\pipe\bitrouter-{}", hex::encode(&digest[..16]))
}
pub struct ControlListener {
name: String,
server: NamedPipeServer,
}
pub async fn bind(path: &Path) -> Result<ControlListener> {
let name = pipe_name(path);
let server = ServerOptions::new()
.first_pipe_instance(true)
.create(&name)
.with_context(|| format!("creating control pipe {name}"))?;
tracing::info!(pipe = %name, "control pipe listening");
Ok(ControlListener { name, server })
}
impl ControlListener {
pub async fn accept(&mut self) -> Result<NamedPipeServer> {
self.server
.connect()
.await
.context("waiting for control-pipe client")?;
let next = ServerOptions::new()
.create(&self.name)
.with_context(|| format!("re-arming control pipe {}", self.name))?;
Ok(std::mem::replace(&mut self.server, next))
}
pub async fn cleanup(self) {}
}
pub async fn connect(path: &Path) -> Result<NamedPipeClient> {
let name = pipe_name(path);
let mut last_busy: Option<std::io::Error> = None;
for _ in 0..50 {
match ClientOptions::new().open(&name) {
Ok(client) => return Ok(client),
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY) => {
last_busy = Some(e);
tokio::time::sleep(Duration::from_millis(20)).await;
}
Err(e) => {
return Err(e).with_context(|| {
format!(
"connecting to {name} — is the daemon running? (`{cli} start`)",
cli = bitrouter_sdk::invocation::name()
)
});
}
}
}
let e = last_busy.unwrap_or_else(|| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "control pipe busy")
});
Err(e).with_context(|| format!("connecting to {name} — daemon stayed busy"))
}
pub fn endpoint_in_use(path: &Path) -> bool {
let name = pipe_name(path);
match ClientOptions::new().open(&name) {
Ok(_client) => true,
Err(e) => e.raw_os_error() == Some(ERROR_PIPE_BUSY),
}
}
#[cfg(test)]
mod tests {
use super::pipe_name;
use std::path::PathBuf;
#[test]
fn explicit_pipe_path_is_returned_verbatim() {
let p = PathBuf::from(r"\\.\pipe\bitrouter-custom");
assert_eq!(pipe_name(&p), r"\\.\pipe\bitrouter-custom");
}
#[test]
fn nt_style_pipe_path_is_returned_verbatim() {
let p = PathBuf::from(r"\\?\pipe\bitrouter-custom");
assert_eq!(pipe_name(&p), r"\\?\pipe\bitrouter-custom");
}
#[test]
fn filesystem_path_maps_to_deterministic_pipe_name() {
let a = pipe_name(&PathBuf::from(r"C:\Users\alice\.bitrouter\bitrouter.sock"));
let b = pipe_name(&PathBuf::from(r"C:\Users\alice\.bitrouter\bitrouter.sock"));
assert_eq!(a, b);
assert!(
a.starts_with(r"\\.\pipe\bitrouter-"),
"unexpected pipe name {a}"
);
}
#[test]
fn case_differences_collapse_to_one_name() {
let lower = pipe_name(&PathBuf::from(r"c:\users\alice\.bitrouter\bitrouter.sock"));
let upper = pipe_name(&PathBuf::from(r"C:\Users\Alice\.BitRouter\bitrouter.sock"));
assert_eq!(lower, upper);
}
#[test]
fn different_paths_map_to_different_pipe_names() {
let a = pipe_name(&PathBuf::from(r"C:\Users\alice\.bitrouter\bitrouter.sock"));
let b = pipe_name(&PathBuf::from(r"C:\Users\bob\.bitrouter\bitrouter.sock"));
assert_ne!(a, b);
}
}
}
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;
}
pub const DAEMON_READY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
const READY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
#[derive(Debug)]
pub enum DaemonStartOutcome {
Ready(ReadyInfo),
NotReadyInTime {
pid: u32,
},
Exited {
status: String,
log_tail: String,
},
}
fn spawn_detached_serve(
source: &crate::paths::ConfigSource,
log_path: &Path,
) -> Result<(std::process::Child, u64)> {
let exe = std::env::current_exe().context("locating current bitrouter binary")?;
let log_size_before = std::fs::metadata(log_path).map(|m| m.len()).unwrap_or(0);
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.with_context(|| format!("opening daemon log {}", log_path.display()))?;
let log_err = log
.try_clone()
.context("duplicating log handle for stderr")?;
let mut cmd = std::process::Command::new(&exe);
cmd.arg("serve");
if let crate::paths::ConfigSource::File(path) = source {
cmd.arg("--config").arg(path);
}
cmd.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_err))
.stdin(std::process::Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
let child = cmd.spawn().with_context(|| {
format!(
"spawning detached `{} serve`",
bitrouter_sdk::invocation::name()
)
})?;
Ok((child, log_size_before))
}
async fn read_log_tail(path: &Path, offset: u64) -> String {
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(_) => return String::new(),
};
let start = (offset as usize).min(bytes.len());
String::from_utf8_lossy(&bytes[start..]).into_owned()
}
pub fn eprint_failure_log(log_path: &Path, content: &str) {
let trimmed = content.trim_end();
if trimmed.is_empty() {
return;
}
let p = crate::style::Palette::for_stderr();
eprintln!(
"{dim}daemon log ({path}):{reset}",
dim = p.dim,
path = log_path.display(),
reset = p.reset,
);
for line in trimmed.lines() {
eprintln!(" {line}");
}
eprintln!();
}
pub async fn start_and_wait(
source: &crate::paths::ConfigSource,
log_path: &Path,
socket: Option<&Path>,
timeout: std::time::Duration,
) -> Result<DaemonStartOutcome> {
crate::paths::ensure_home_directory(source.home())?;
let (mut child, log_size_before) = spawn_detached_serve(source, log_path)?;
let deadline = std::time::Instant::now() + timeout;
loop {
if let Ok(Some(status)) = child.try_wait() {
let log_tail = read_log_tail(log_path, log_size_before).await;
return Ok(DaemonStartOutcome::Exited {
status: status.to_string(),
log_tail,
});
}
if let Some(socket) = socket {
match probe_status(socket).await {
Ok(Some(info)) => return Ok(DaemonStartOutcome::Ready(info)),
Ok(None) => {}
Err(e) => tracing::debug!(error = %e, "readiness probe failed; retrying"),
}
}
if std::time::Instant::now() >= deadline {
return Ok(DaemonStartOutcome::NotReadyInTime { pid: child.id() });
}
tokio::time::sleep(READY_POLL_INTERVAL).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::ReloadState,
DaemonCommand::Status,
DaemonCommand::Route {
model: "gpt-5".to_string(),
},
DaemonCommand::ObserveStatus,
DaemonCommand::Inspect {
inspection: DaemonInspection::Policy {
input: PolicyInput::default(),
},
},
] {
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 inspection_response_round_trips_as_json() -> anyhow::Result<()> {
let response = DaemonResponse::Inspection {
report: DaemonInspectionReport::Providers(ProvidersReport {
resolved_via: "live".to_string(),
providers: Vec::new(),
}),
};
let json = serde_json::to_string(&response)?;
let decoded: DaemonResponse = serde_json::from_str(&json)?;
match decoded {
DaemonResponse::Inspection {
report: DaemonInspectionReport::Providers(report),
} => assert_eq!(report.resolved_via, "live"),
other => anyhow::bail!("expected provider inspection, got {other:?}"),
}
Ok(())
}
#[test]
fn observe_status_payload_round_trips() {
let payload = ObserveStatusPayload {
compiled_in: true,
exporter_wired: true,
endpoint: Some("http://collector:4318".to_string()),
header_count: 2,
service_name: Some("bitrouter".to_string()),
resource_attribute_count: 1,
sampler: Some("parentbased_always_on".to_string()),
sampler_arg: None,
metrics_enabled: true,
api_key_count: 42,
api_key_cap: 1024,
user_id_count: 7,
user_id_cap: 256,
active_spans: 3,
};
let json = serde_json::to_string(&DaemonResponse::ObserveStatus {
payload: payload.clone(),
})
.unwrap();
let back: DaemonResponse = serde_json::from_str(&json).unwrap();
match back {
DaemonResponse::ObserveStatus { payload: p } => {
assert_eq!(p.endpoint, payload.endpoint);
assert_eq!(p.api_key_count, 42);
assert_eq!(p.active_spans, 3);
}
other => panic!("expected ObserveStatus, got {other:?}"),
}
}
#[test]
fn noop_observe_status_reports_unwired() {
let p = NoopObserveStatus { compiled_in: true }.status();
assert!(p.compiled_in);
assert!(!p.exporter_wired);
assert!(p.endpoint.is_none());
assert_eq!(p.api_key_count, 0);
}
#[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,
providers: vec!["openai".to_string()],
};
let json = serde_json::to_string(&resp).unwrap();
let back: DaemonResponse = serde_json::from_str(&json).unwrap();
match back {
DaemonResponse::Status {
pid,
models,
providers,
..
} => {
assert_eq!(pid, 42);
assert_eq!(models, 3);
assert_eq!(providers, ["openai"]);
}
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));
}
}