#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::panic_in_result_fn,
clippy::indexing_slicing
)]
#![allow(dead_code)]
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use choreo_daemon::accounts::{AccountConfig, AccountManager};
use choreo_daemon::broadcast::LagLimits;
use choreo_daemon::server::acl::SharedAcl;
use choreo_daemon::{DaemonState, run_server};
use choreo_keystore::ServiceCredential;
use std::collections::{HashMap, HashSet};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
pub fn test_db() -> redb::Database {
let dir = tempfile::tempdir().unwrap();
redb::Database::create(dir.path().join("state.redb")).unwrap()
}
pub fn test_daemon_state() -> DaemonState {
test_daemon_state_with_limits(LagLimits::default())
}
pub fn test_daemon_state_with_limits(limits: LagLimits) -> DaemonState {
let (daemon_tx, _daemon_rx) = mpsc::channel();
let dir = tempfile::tempdir().expect("tempdir");
let db =
Arc::new(redb::Database::create(dir.path().join("state.redb")).expect("test database"));
let tool_registry = choreo_daemon::tools::ToolRegistry::new().build();
let config_dir = tempfile::tempdir().expect("tempdir for config");
let accounts_path = config_dir.path().join("accounts.toml");
std::mem::forget(config_dir);
DaemonState {
next_session_id: 1,
max_turns: 10,
active_sessions: HashMap::new(),
session_metadata: HashMap::new(),
deleted_sessions: std::collections::HashSet::new(),
children: HashMap::new(),
accounts: AccountManager::load(&accounts_path).unwrap(),
daemon_registry: choreo_ai_protocols::SocketRegistry::default(),
session_registries: HashMap::new(),
credentials: HashMap::new(),
x_credentials: None,
locked: true,
db,
tool_registry,
daemon_tx,
summary_subscribers: HashMap::new(),
client_writers: HashMap::new(),
activity_subscribers: HashMap::new(),
client_subscribed_sessions: HashMap::new(),
global_lag: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
lag_limits: limits,
model_cache: HashMap::new(),
model_prefetch_in_flight: HashSet::new(),
mcp_manager: choreo_daemon::mcp::McpManager::empty(),
maintenance_tx: None,
acl: None,
catalog_paths: choreo_daemon::catalog::CatalogPaths::default(),
}
}
pub fn seed_mock_account(state: &mut DaemonState, name: &str, base_url: String, models: &[&str]) {
let mut config = AccountConfig::simple(name, "openai");
config.base_url = Some(base_url);
config.streaming = Some(true);
config.retry_max_attempts = Some(1);
config.connect_timeout_secs = Some(5);
config.request_timeout_secs = Some(30);
config.total_timeout_secs = Some(60);
state.accounts.add(config).expect("seed mock account");
state.credentials.insert(
name.to_string(),
ServiceCredential::ApiKey {
key: "test-key".to_string(),
},
);
if !models.is_empty() {
state.model_cache.insert(
name.to_string(),
(
models.iter().map(|m| m.to_string()).collect::<Vec<_>>(),
std::time::Instant::now(),
),
);
}
}
fn wait_until(what: &str, mut cond: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(5);
while !cond() {
assert!(
Instant::now() < deadline,
"timed out after 5s waiting for {what}"
);
thread::sleep(Duration::from_millis(10));
}
}
pub struct SpawnedDaemon {
pub socket_path: std::path::PathBuf,
pub tcp_addr: SocketAddr, pub server_pk: [u8; 32], pub acl_path: std::path::PathBuf,
handle: Option<thread::JoinHandle<std::io::Result<()>>>,
_tmp: tempfile::TempDir, _keepalive: Vec<Box<dyn std::any::Any + Send>>,
}
impl SpawnedDaemon {
pub fn start(authorized_pks: &[[u8; 32]]) -> Self {
Self::start_with_state(|| (test_daemon_state(), Vec::new()), authorized_pks)
}
pub fn start_with_state(
build: impl Fn() -> (DaemonState, Vec<Box<dyn std::any::Any + Send>>) + Send + 'static,
authorized_pks: &[[u8; 32]],
) -> Self {
const START_ATTEMPTS: usize = 5;
for attempt in 1..=START_ATTEMPTS {
let (state, keepalive) = build();
if let Some(daemon) = Self::try_start(state, keepalive, authorized_pks, attempt) {
return daemon;
}
}
panic!("failed to start test daemon after {START_ATTEMPTS} attempts");
}
fn try_start(
state: DaemonState,
keepalive: Vec<Box<dyn std::any::Any + Send>>,
authorized_pks: &[[u8; 32]],
attempt: usize,
) -> Option<Self> {
let server_sk = x25519_dalek::StaticSecret::random_from_rng(&mut rand::rng());
let server_pk = x25519_dalek::PublicKey::from(&server_sk).to_bytes();
let transport_sk = choreo_transport::key::TransportSecretKey::new(server_sk.to_bytes());
let tmp = tempfile::tempdir().expect("tempdir for daemon test");
let socket_path = tmp.path().join("daemon.sock");
let socket_str = socket_path
.to_str()
.expect("socket path must be valid UTF-8")
.to_string();
let acl_path = tmp.path().join("authorized_clients.toml");
let mut acl_toml = String::new();
for pk in authorized_pks {
acl_toml.push_str(&format!("[[client]]\npubkey = \"{}\"\n", BASE64.encode(pk)));
}
std::fs::write(&acl_path, acl_toml).expect("write ACL file");
let acl = SharedAcl::load(&acl_path);
let tcp_addr: SocketAddr = {
let probe = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
let port = probe.local_addr().expect("local_addr").port();
SocketAddr::from(([127, 0, 0, 1], port))
};
let tcp_addr_str = tcp_addr.to_string();
let mut handle: Option<thread::JoinHandle<std::io::Result<()>>> =
Some(thread::spawn(move || {
run_server(
&socket_str,
state,
None,
Some(tcp_addr_str),
transport_sk,
acl,
false,
)
}));
wait_until("daemon Unix socket", || socket_path.exists());
let deadline = Instant::now() + Duration::from_secs(5);
let ready = loop {
if handle.as_ref().is_some_and(|h| h.is_finished()) {
let result = handle
.take()
.expect("server thread panicked")
.join()
.expect("server thread panicked");
tracing::warn!(
attempt,
?result,
"daemon exited early while waiting for readiness — retrying start"
);
break false;
}
if TcpStream::connect(tcp_addr).is_ok() {
break true;
}
assert!(
Instant::now() < deadline,
"timed out after 5s waiting for daemon TCP listener"
);
thread::sleep(Duration::from_millis(10));
};
if ready {
return Some(SpawnedDaemon {
socket_path,
tcp_addr,
server_pk,
acl_path: acl_path.clone(),
handle,
_tmp: tmp,
_keepalive: keepalive,
});
}
None
}
pub fn shutdown(&mut self) {
let handle = match self.handle.take() {
Some(h) => h,
None => return, };
if handle.is_finished() {
let result = handle.join().expect("server thread panicked");
tracing::debug!(?result, "daemon server thread already finished");
return;
}
let _ =
rustix::process::kill_process(rustix::process::getpid(), rustix::process::Signal::INT);
let result = handle.join().expect("server thread panicked");
if let Err(e) = result {
panic!("run_server exited with an error during shutdown: {e}");
}
}
pub fn take_handle(&mut self) -> Option<thread::JoinHandle<std::io::Result<()>>> {
self.handle.take()
}
pub fn socket_str(&self) -> String {
self.socket_path
.to_str()
.expect("socket path must be valid UTF-8")
.to_string()
}
}
impl Drop for SpawnedDaemon {
fn drop(&mut self) {
self.shutdown();
}
}