#![cfg_attr(not(test), deny(clippy::unwrap_used))]
#![cfg_attr(not(test), deny(clippy::expect_used))]
#![cfg_attr(not(test), deny(clippy::panic))]
mod auth;
mod build_info;
mod config;
mod control;
mod daemon;
mod error;
mod generated;
mod lldb;
mod state;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, warn};
pub use config::{Config, TlsConfig};
pub use error::{Error, Result};
pub use generated::{
ClientState, DiscoverResponse, SleepResponse, SleepResponseStatus, StatusResponse,
WakeResponse, WakeResponseStatus,
};
use control::ControlServer;
const UNKNOWN_WORKSPACE_ROOT: &str = "/unknown";
use daemon::{DaemonClient, RegisterRequest};
use lldb::LldbManager;
use state::{get, is_initialized, set_initialized};
static INIT_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
static CONTROL_SERVER: std::sync::OnceLock<std::sync::Mutex<Option<ControlServer>>> =
std::sync::OnceLock::new();
static DAEMON_CLIENT: std::sync::OnceLock<std::sync::Mutex<Option<DaemonClient>>> =
std::sync::OnceLock::new();
static LLDB_MANAGER: std::sync::OnceLock<std::sync::Mutex<Option<LldbManager>>> =
std::sync::OnceLock::new();
pub fn init(config: Config) -> Result<()> {
let _init_guard = INIT_LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.map_err(|_| Error::ControlPlaneError("init lock poisoned".to_string()))?;
if is_initialized() {
return Err(Error::AlreadyInitialized);
}
let config = config.with_env_overrides();
let lldb_dap_path = lldb::find_lldb_dap(config.lldb_dap_path.as_deref())?;
debug!("Found lldb-dap at {:?}", lldb_dap_path);
{
let state = get();
let mut guard = state.write()?;
guard.name = config.connection_name();
guard.control_host = config.control_host.clone();
guard.advertise_host = config.advertise_host.clone();
guard.control_port = config.control_port;
guard.debug_port = config.debug_port;
guard.daemon_url = config.daemon_url.clone();
guard.lldb_dap_path = lldb_dap_path.to_string_lossy().to_string();
guard.detrix_home = config
.detrix_home_path()
.map(|p| p.to_string_lossy().to_string());
guard.workspace_root = config.workspace_root.clone();
guard.safe_mode = config.safe_mode;
guard.build_commit = config.build_commit.clone();
guard.build_tag = config.build_tag.clone();
guard.health_check_timeout_ms = config
.health_check_timeout
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
guard.register_timeout_ms = config
.register_timeout
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
guard.unregister_timeout_ms = config
.unregister_timeout
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
guard.lldb_start_timeout_ms = config
.lldb_start_timeout
.as_millis()
.try_into()
.unwrap_or(u64::MAX);
guard.state = ClientState::Sleeping;
}
let auth_token = auth::discover_token(config.detrix_home_path().as_deref());
let daemon_client = DaemonClient::new(None, auth_token.clone())?;
let dc_holder = DAEMON_CLIENT.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(mut guard) = dc_holder.lock() {
*guard = Some(daemon_client);
}
if let Ok(dc_guard) = dc_holder.lock() {
if let Some(ref dc) = *dc_guard {
if let Some(adv_url) =
dc.fetch_advertise_url(&config.daemon_url, Duration::from_secs(2))
{
debug!("Fetched daemon advertise URL at init: {}", adv_url);
let state = get();
if let Ok(mut guard) = state.write() {
guard.daemon_advertise_url = Some(adv_url);
}
}
}
}
let lldb_manager = LldbManager::new(lldb_dap_path.clone(), config.lldb_start_timeout);
let lm_holder = LLDB_MANAGER.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(mut guard) = lm_holder.lock() {
*guard = Some(lldb_manager);
}
let status_callback = Arc::new(status_provider);
let wake_callback =
Arc::new(|daemon_url: Option<String>| wake_handler(daemon_url).map_err(|e| e.to_string()));
let sleep_callback = Arc::new(|| sleep_handler().map_err(|e| e.to_string()));
let discover_callback = Arc::new(discover_provider);
let server = ControlServer::start(
&config.control_host,
config.control_port,
auth_token,
config.workspace_root.clone().unwrap_or_default(),
status_callback,
wake_callback,
sleep_callback,
discover_callback,
)?;
let actual_port = server.port();
let server_holder = CONTROL_SERVER.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(mut guard) = server_holder.lock() {
*guard = Some(server);
}
{
let state = get();
if let Ok(mut guard) = state.write() {
guard.actual_control_port = actual_port;
}
}
set_initialized(true);
info!(
"Detrix client initialized. Control plane: http://{}:{}",
config.control_host, actual_port
);
Ok(())
}
pub fn status() -> StatusResponse {
let state = get();
match state.read() {
Ok(guard) => guard.to_status_response(),
Err(_) => StatusResponse {
state: ClientState::Sleeping,
name: "unknown".to_string(),
control_host: "127.0.0.1".to_string(),
control_port: 0,
debug_port: 0,
debug_port_active: false,
daemon_url: "http://127.0.0.1:8090".to_string(),
connection_id: None,
},
}
}
pub fn wake() -> Result<WakeResponse> {
wake_with_url(None)
}
pub fn wake_with_url(daemon_url: impl Into<Option<String>>) -> Result<WakeResponse> {
wake_handler(daemon_url.into())
}
pub fn sleep() -> Result<SleepResponse> {
sleep_handler()
}
pub fn shutdown() -> Result<()> {
let _init_guard = INIT_LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.map_err(|_| Error::ControlPlaneError("init lock poisoned".to_string()))?;
if !is_initialized() {
return Ok(());
}
let _ = sleep();
let server_holder = CONTROL_SERVER.get_or_init(|| std::sync::Mutex::new(None));
if let Ok(mut guard) = server_holder.lock() {
if let Some(mut server) = guard.take() {
let _ = server.stop();
}
}
if let Some(holder) = DAEMON_CLIENT.get() {
if let Ok(mut guard) = holder.lock() {
*guard = None;
}
}
if let Some(holder) = LLDB_MANAGER.get() {
if let Ok(mut guard) = holder.lock() {
*guard = None;
}
}
state::reset();
info!("Detrix client shutdown complete");
Ok(())
}
fn status_provider() -> StatusResponse {
status()
}
fn discover_provider() -> DiscoverResponse {
let state = get();
let guard = state.read().unwrap_or_else(|e| e.into_inner());
let mut daemon_url = guard.daemon_advertise_url.clone();
let daemon_base_url = guard.daemon_url.clone();
let name = guard.name.clone();
let control_host = guard.control_host.clone();
let advertise_host = guard.advertise_host.clone();
let actual_control_port = guard.actual_control_port;
drop(guard);
if daemon_url.is_none() {
let fetched = DAEMON_CLIENT
.get()
.and_then(|dc_holder| dc_holder.lock().ok())
.and_then(|dc_guard| {
dc_guard
.as_ref()
.map(|dc| dc.fetch_advertise_url(&daemon_base_url, Duration::from_secs(2)))
})
.flatten();
if let Some(adv_url) = fetched {
debug!("Fetched daemon advertise URL on discover: {}", adv_url);
if let Ok(mut guard) = state.write() {
guard.daemon_advertise_url = Some(adv_url.clone());
}
daemon_url = Some(adv_url);
}
}
const BIND_ALL: &[&str] = &["0.0.0.0", "::", ""];
let cp_host = advertise_host.as_deref().or_else(|| {
if !BIND_ALL.contains(&control_host.as_str()) {
Some(control_host.as_str())
} else {
None
}
});
let control_plane_url = cp_host
.filter(|_| actual_control_port > 0)
.map(|h| format!("http://{}:{}", h, actual_control_port));
DiscoverResponse {
daemon_url: daemon_url.unwrap_or(daemon_base_url),
name,
control_plane_url,
}
}
fn wake_handler(daemon_url: Option<String>) -> Result<WakeResponse> {
if !is_initialized() {
return Err(Error::NotInitialized);
}
let _wake_guard = state::acquire_wake_lock()?;
let (
current_state,
target_daemon_url,
debug_host,
advertise_host,
debug_port,
name,
detrix_home,
workspace_root_override,
safe_mode,
build_commit_override,
build_tag_override,
health_timeout,
register_timeout,
) = {
let state = get();
let guard = state.read()?;
let target_url = daemon_url.unwrap_or_else(|| guard.daemon_url.clone());
(
guard.state,
target_url,
guard.control_host.clone(),
guard.advertise_host.clone(),
guard.debug_port,
guard.name.clone(),
guard.detrix_home.clone(),
guard.workspace_root.clone(),
guard.safe_mode,
guard.build_commit.clone(),
guard.build_tag.clone(),
Duration::from_millis(guard.health_check_timeout_ms),
Duration::from_millis(guard.register_timeout_ms),
)
};
if matches!(current_state, ClientState::Awake) {
let state = get();
let guard = state.read()?;
return Ok(WakeResponse {
status: WakeResponseStatus::AlreadyAwake,
debug_port: i32::from(guard.actual_debug_port),
connection_id: guard.connection_id.clone().unwrap_or_default(),
daemon_url: guard.daemon_advertise_url.clone(),
});
}
if matches!(current_state, ClientState::Waking) {
return Err(Error::WakeInProgress);
}
{
let state = get();
let mut guard = state.write()?;
guard.state = ClientState::Waking;
}
let revert_state = || {
let state = get();
if let Ok(mut guard) = state.write() {
guard.state = ClientState::Sleeping;
}
};
let dc_holder = DAEMON_CLIENT.get().ok_or(Error::NotInitialized)?;
let mut dc_guard = dc_holder
.lock()
.map_err(|_| Error::ControlPlaneError("daemon client lock poisoned".to_string()))?;
let fresh_token = auth::discover_token(detrix_home.as_ref().map(std::path::Path::new));
if let Some(ref mut dc) = *dc_guard {
dc.update_auth_token(fresh_token.clone());
}
if let Some(cs_holder) = CONTROL_SERVER.get() {
if let Ok(cs_guard) = cs_holder.lock() {
if let Some(ref cs) = *cs_guard {
cs.update_token(fresh_token);
}
}
}
let daemon_client = dc_guard.as_ref().ok_or(Error::NotInitialized)?;
let lm_holder = LLDB_MANAGER.get().ok_or(Error::NotInitialized)?;
let lm_guard = lm_holder
.lock()
.map_err(|_| Error::ControlPlaneError("lldb manager lock poisoned".to_string()))?;
let lldb_manager = lm_guard.as_ref().ok_or(Error::NotInitialized)?;
if let Err(e) = daemon_client.health_check(&target_daemon_url, health_timeout) {
revert_state();
return Err(e);
}
let lldb_process = match lldb_manager.spawn_and_attach(&debug_host, debug_port) {
Ok(p) => p,
Err(e) => {
revert_state();
return Err(e);
}
};
let actual_debug_port = lldb_process.port;
state::set_lldb_process(lldb_process);
let workspace_root = workspace_root_override.unwrap_or_else(|| {
std::env::current_dir()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| {
warn!("Failed to get current directory, using /unknown");
UNKNOWN_WORKSPACE_ROOT.to_string()
})
});
let hostname = hostname::get()
.ok()
.and_then(|h| h.into_string().ok())
.unwrap_or_else(|| {
warn!("Failed to get hostname, using unknown");
"unknown".to_string()
});
let build_commit = build_info::detect_build_commit(build_commit_override);
let build_tag = build_info::detect_build_tag(build_tag_override);
let registration_host = advertise_host.unwrap_or(debug_host);
let (connection_id, advertise_url) = match daemon_client.register(
&target_daemon_url,
RegisterRequest {
host: registration_host,
port: actual_debug_port,
language: "rust".to_string(),
name: name.clone(),
workspace_root,
hostname,
pid: Some(std::process::id()),
safe_mode,
build_commit,
build_tag,
},
register_timeout,
) {
Ok(result) => result,
Err(e) => {
if let Some(mut process) = state::take_lldb_process() {
let _ = lldb_manager.kill(&mut process);
}
revert_state();
return Err(e);
}
};
{
let state = get();
let mut guard = state.write()?;
guard.state = ClientState::Awake;
guard.actual_debug_port = actual_debug_port;
guard.debug_port_active = true;
guard.connection_id = Some(connection_id.clone());
guard.daemon_advertise_url = advertise_url.clone();
}
info!(
"Detrix client awake. Debug port: {}, Connection ID: {}",
actual_debug_port, connection_id
);
Ok(WakeResponse {
status: WakeResponseStatus::Awake,
debug_port: i32::from(actual_debug_port),
connection_id,
daemon_url: advertise_url,
})
}
fn sleep_handler() -> Result<SleepResponse> {
if !is_initialized() {
return Err(Error::NotInitialized);
}
let (current_state, daemon_url, connection_id, unregister_timeout) = {
let state = get();
let guard = state.read()?;
(
guard.state,
guard.daemon_url.clone(),
guard.connection_id.clone(),
Duration::from_millis(guard.unregister_timeout_ms),
)
};
if matches!(current_state, ClientState::Sleeping) {
return Ok(SleepResponse {
status: SleepResponseStatus::AlreadySleeping,
});
}
if matches!(current_state, ClientState::Waking) {
let _wake_guard = state::acquire_wake_lock()?;
let state = get();
if let Ok(guard) = state.read() {
if matches!(guard.state, ClientState::Sleeping) {
return Ok(SleepResponse {
status: SleepResponseStatus::AlreadySleeping,
});
}
}
}
if let Some(conn_id) = connection_id {
if let Some(holder) = DAEMON_CLIENT.get() {
if let Ok(guard) = holder.lock() {
if let Some(daemon_client) = guard.as_ref() {
daemon_client.unregister(&daemon_url, &conn_id, unregister_timeout);
}
}
}
}
if let Some(mut process) = state::take_lldb_process() {
if let Some(holder) = LLDB_MANAGER.get() {
if let Ok(guard) = holder.lock() {
if let Some(lldb_manager) = guard.as_ref() {
if let Err(e) = lldb_manager.kill(&mut process) {
warn!("Failed to kill lldb-dap: {}", e);
}
}
}
}
}
{
let state = get();
let mut guard = state.write()?;
guard.state = ClientState::Sleeping;
guard.connection_id = None;
guard.debug_port_active = false;
}
info!("Detrix client sleeping");
Ok(SleepResponse {
status: SleepResponseStatus::Sleeping,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = Config::default();
assert!(config.name.is_none());
assert_eq!(config.control_host, "127.0.0.1");
assert_eq!(config.control_port, 0);
}
#[test]
fn test_status_not_initialized() {
state::reset();
let status = status();
assert!(matches!(status.state, ClientState::Sleeping));
}
#[test]
fn test_init_lock_exists() {
let lock = INIT_LOCK.get_or_init(|| std::sync::Mutex::new(()));
let guard = lock.lock();
assert!(guard.is_ok(), "INIT_LOCK should be acquirable");
let second = lock.try_lock();
assert!(second.is_err(), "INIT_LOCK should not be re-entrant");
}
}