#![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 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, SleepResponse, SleepResponseStatus, StatusResponse, WakeResponse,
WakeResponseStatus,
};
use control::ControlServer;
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<DaemonClient> = std::sync::OnceLock::new();
static LLDB_MANAGER: std::sync::OnceLock<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.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.safe_mode = config.safe_mode;
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 daemon_client = DaemonClient::new(None)?;
let _ = DAEMON_CLIENT.set(daemon_client);
let _ = LLDB_MANAGER
.get_or_init(|| LldbManager::new(lldb_dap_path.clone(), config.lldb_start_timeout));
let auth_token = auth::discover_token(config.detrix_home_path().as_deref());
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 server = ControlServer::start(
&config.control_host,
config.control_port,
auth_token,
status_callback,
wake_callback,
sleep_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();
}
}
state::reset();
info!("Detrix client shutdown complete");
Ok(())
}
fn status_provider() -> StatusResponse {
status()
}
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,
debug_port,
name,
detrix_home,
safe_mode,
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.debug_port,
guard.name.clone(),
guard.detrix_home.clone(),
guard.safe_mode,
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(),
});
}
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 daemon_client = DAEMON_CLIENT.get().ok_or(Error::NotInitialized)?;
if let Err(e) = daemon_client.health_check(&target_daemon_url, health_timeout) {
revert_state();
return Err(e);
}
let lldb_manager = LLDB_MANAGER.get().ok_or(Error::NotInitialized)?;
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 token = auth::discover_token(detrix_home.as_ref().map(std::path::Path::new));
let workspace_root = 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".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 connection_id = match daemon_client.register(
&target_daemon_url,
RegisterRequest {
host: debug_host,
port: actual_debug_port,
language: "rust".to_string(),
name: name.clone(),
workspace_root,
hostname,
pid: Some(std::process::id()),
token,
safe_mode,
},
register_timeout,
) {
Ok(id) => id,
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());
}
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,
})
}
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(daemon_client) = DAEMON_CLIENT.get() {
daemon_client.unregister(&daemon_url, &conn_id, unregister_timeout);
}
}
if let Some(mut process) = state::take_lldb_process() {
if let Some(lldb_manager) = LLDB_MANAGER.get() {
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");
}
}