1#![cfg_attr(not(test), deny(clippy::unwrap_used))]
39#![cfg_attr(not(test), deny(clippy::expect_used))]
40#![cfg_attr(not(test), deny(clippy::panic))]
41
42mod auth;
43mod build_info;
44mod config;
45mod control;
46mod daemon;
47mod error;
48mod generated;
49mod lldb;
50mod state;
51
52use std::sync::Arc;
53use std::time::Duration;
54
55use tracing::{debug, info, warn};
56
57pub use config::{Config, TlsConfig};
58pub use error::{Error, Result};
59pub use generated::{
60 ClientState, DiscoverResponse, SleepResponse, SleepResponseStatus, StatusResponse,
61 WakeResponse, WakeResponseStatus,
62};
63
64use control::ControlServer;
65
66const UNKNOWN_WORKSPACE_ROOT: &str = "/unknown";
68use daemon::{DaemonClient, RegisterRequest};
69use lldb::LldbManager;
70use state::{get, is_initialized, set_initialized};
71
72static INIT_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
74
75static CONTROL_SERVER: std::sync::OnceLock<std::sync::Mutex<Option<ControlServer>>> =
77 std::sync::OnceLock::new();
78
79static DAEMON_CLIENT: std::sync::OnceLock<std::sync::Mutex<Option<DaemonClient>>> =
81 std::sync::OnceLock::new();
82
83static LLDB_MANAGER: std::sync::OnceLock<std::sync::Mutex<Option<LldbManager>>> =
85 std::sync::OnceLock::new();
86
87pub fn init(config: Config) -> Result<()> {
125 let _init_guard = INIT_LOCK
126 .get_or_init(|| std::sync::Mutex::new(()))
127 .lock()
128 .map_err(|_| Error::ControlPlaneError("init lock poisoned".to_string()))?;
129
130 if is_initialized() {
131 return Err(Error::AlreadyInitialized);
132 }
133
134 let config = config.with_env_overrides();
136
137 let lldb_dap_path = lldb::find_lldb_dap(config.lldb_dap_path.as_deref())?;
139 debug!("Found lldb-dap at {:?}", lldb_dap_path);
140
141 {
143 let state = get();
144 let mut guard = state.write()?;
145
146 guard.name = config.connection_name();
147 guard.control_host = config.control_host.clone();
148 guard.advertise_host = config.advertise_host.clone();
149 guard.control_port = config.control_port;
150 guard.debug_port = config.debug_port;
151 guard.daemon_url = config.daemon_url.clone();
152 guard.lldb_dap_path = lldb_dap_path.to_string_lossy().to_string();
153 guard.detrix_home = config
154 .detrix_home_path()
155 .map(|p| p.to_string_lossy().to_string());
156 guard.workspace_root = config.workspace_root.clone();
157 guard.safe_mode = config.safe_mode;
158 guard.build_commit = config.build_commit.clone();
159 guard.build_tag = config.build_tag.clone();
160 guard.health_check_timeout_ms = config
161 .health_check_timeout
162 .as_millis()
163 .try_into()
164 .unwrap_or(u64::MAX);
165 guard.register_timeout_ms = config
166 .register_timeout
167 .as_millis()
168 .try_into()
169 .unwrap_or(u64::MAX);
170 guard.unregister_timeout_ms = config
171 .unregister_timeout
172 .as_millis()
173 .try_into()
174 .unwrap_or(u64::MAX);
175 guard.lldb_start_timeout_ms = config
176 .lldb_start_timeout
177 .as_millis()
178 .try_into()
179 .unwrap_or(u64::MAX);
180 guard.state = ClientState::Sleeping;
181 }
182
183 let auth_token = auth::discover_token(config.detrix_home_path().as_deref());
185
186 let daemon_client = DaemonClient::new(None, auth_token.clone())?;
188 let dc_holder = DAEMON_CLIENT.get_or_init(|| std::sync::Mutex::new(None));
189 if let Ok(mut guard) = dc_holder.lock() {
190 *guard = Some(daemon_client);
191 }
192
193 if let Ok(dc_guard) = dc_holder.lock() {
195 if let Some(ref dc) = *dc_guard {
196 if let Some(adv_url) =
197 dc.fetch_advertise_url(&config.daemon_url, Duration::from_secs(2))
198 {
199 debug!("Fetched daemon advertise URL at init: {}", adv_url);
200 let state = get();
201 if let Ok(mut guard) = state.write() {
202 guard.daemon_advertise_url = Some(adv_url);
203 }
204 }
205 }
206 }
207
208 let lldb_manager = LldbManager::new(lldb_dap_path.clone(), config.lldb_start_timeout);
210 let lm_holder = LLDB_MANAGER.get_or_init(|| std::sync::Mutex::new(None));
211 if let Ok(mut guard) = lm_holder.lock() {
212 *guard = Some(lldb_manager);
213 }
214
215 let status_callback = Arc::new(status_provider);
217 let wake_callback =
218 Arc::new(|daemon_url: Option<String>| wake_handler(daemon_url).map_err(|e| e.to_string()));
219 let sleep_callback = Arc::new(|| sleep_handler().map_err(|e| e.to_string()));
220 let discover_callback = Arc::new(discover_provider);
221
222 let server = ControlServer::start(
224 &config.control_host,
225 config.control_port,
226 auth_token,
227 config.workspace_root.clone().unwrap_or_default(),
228 status_callback,
229 wake_callback,
230 sleep_callback,
231 discover_callback,
232 )?;
233
234 let actual_port = server.port();
235
236 let server_holder = CONTROL_SERVER.get_or_init(|| std::sync::Mutex::new(None));
238 if let Ok(mut guard) = server_holder.lock() {
239 *guard = Some(server);
240 }
241
242 {
244 let state = get();
245 if let Ok(mut guard) = state.write() {
246 guard.actual_control_port = actual_port;
247 }
248 }
249
250 set_initialized(true);
251
252 info!(
253 "Detrix client initialized. Control plane: http://{}:{}",
254 config.control_host, actual_port
255 );
256
257 Ok(())
258}
259
260pub fn status() -> StatusResponse {
274 let state = get();
275 match state.read() {
276 Ok(guard) => guard.to_status_response(),
277 Err(_) => StatusResponse {
278 state: ClientState::Sleeping,
279 name: "unknown".to_string(),
280 control_host: "127.0.0.1".to_string(),
281 control_port: 0,
282 debug_port: 0,
283 debug_port_active: false,
284 daemon_url: "http://127.0.0.1:8090".to_string(),
285 connection_id: None,
286 },
287 }
288}
289
290pub fn wake() -> Result<WakeResponse> {
315 wake_with_url(None)
316}
317
318pub fn wake_with_url(daemon_url: impl Into<Option<String>>) -> Result<WakeResponse> {
322 wake_handler(daemon_url.into())
323}
324
325pub fn sleep() -> Result<SleepResponse> {
344 sleep_handler()
345}
346
347pub fn shutdown() -> Result<()> {
358 let _init_guard = INIT_LOCK
359 .get_or_init(|| std::sync::Mutex::new(()))
360 .lock()
361 .map_err(|_| Error::ControlPlaneError("init lock poisoned".to_string()))?;
362
363 if !is_initialized() {
364 return Ok(());
365 }
366
367 let _ = sleep();
369
370 let server_holder = CONTROL_SERVER.get_or_init(|| std::sync::Mutex::new(None));
372 if let Ok(mut guard) = server_holder.lock() {
373 if let Some(mut server) = guard.take() {
374 let _ = server.stop();
375 }
376 }
377
378 if let Some(holder) = DAEMON_CLIENT.get() {
380 if let Ok(mut guard) = holder.lock() {
381 *guard = None;
382 }
383 }
384 if let Some(holder) = LLDB_MANAGER.get() {
385 if let Ok(mut guard) = holder.lock() {
386 *guard = None;
387 }
388 }
389
390 state::reset();
392
393 info!("Detrix client shutdown complete");
394 Ok(())
395}
396
397fn status_provider() -> StatusResponse {
402 status()
403}
404
405fn discover_provider() -> DiscoverResponse {
406 let state = get();
407 let guard = state.read().unwrap_or_else(|e| e.into_inner());
408 let mut daemon_url = guard.daemon_advertise_url.clone();
409 let daemon_base_url = guard.daemon_url.clone();
410 let name = guard.name.clone();
411 let control_host = guard.control_host.clone();
412 let advertise_host = guard.advertise_host.clone();
413 let actual_control_port = guard.actual_control_port;
414 drop(guard);
415
416 if daemon_url.is_none() {
418 let fetched = DAEMON_CLIENT
419 .get()
420 .and_then(|dc_holder| dc_holder.lock().ok())
421 .and_then(|dc_guard| {
422 dc_guard
423 .as_ref()
424 .map(|dc| dc.fetch_advertise_url(&daemon_base_url, Duration::from_secs(2)))
425 })
426 .flatten();
427 if let Some(adv_url) = fetched {
428 debug!("Fetched daemon advertise URL on discover: {}", adv_url);
429 if let Ok(mut guard) = state.write() {
430 guard.daemon_advertise_url = Some(adv_url.clone());
431 }
432 daemon_url = Some(adv_url);
433 }
434 }
435
436 const BIND_ALL: &[&str] = &["0.0.0.0", "::", ""];
441 let cp_host = advertise_host.as_deref().or_else(|| {
442 if !BIND_ALL.contains(&control_host.as_str()) {
443 Some(control_host.as_str())
444 } else {
445 None
446 }
447 });
448 let control_plane_url = cp_host
449 .filter(|_| actual_control_port > 0)
450 .map(|h| format!("http://{}:{}", h, actual_control_port));
451
452 DiscoverResponse {
453 daemon_url: daemon_url.unwrap_or(daemon_base_url),
454 name,
455 control_plane_url,
456 }
457}
458
459fn wake_handler(daemon_url: Option<String>) -> Result<WakeResponse> {
460 if !is_initialized() {
461 return Err(Error::NotInitialized);
462 }
463
464 let _wake_guard = state::acquire_wake_lock()?;
466
467 let (
469 current_state,
470 target_daemon_url,
471 debug_host,
472 advertise_host,
473 debug_port,
474 name,
475 detrix_home,
476 workspace_root_override,
477 safe_mode,
478 build_commit_override,
479 build_tag_override,
480 health_timeout,
481 register_timeout,
482 ) = {
483 let state = get();
484 let guard = state.read()?;
485
486 let target_url = daemon_url.unwrap_or_else(|| guard.daemon_url.clone());
487 (
488 guard.state,
489 target_url,
490 guard.control_host.clone(),
491 guard.advertise_host.clone(),
492 guard.debug_port,
493 guard.name.clone(),
494 guard.detrix_home.clone(),
495 guard.workspace_root.clone(),
496 guard.safe_mode,
497 guard.build_commit.clone(),
498 guard.build_tag.clone(),
499 Duration::from_millis(guard.health_check_timeout_ms),
500 Duration::from_millis(guard.register_timeout_ms),
501 )
502 };
503
504 if matches!(current_state, ClientState::Awake) {
506 let state = get();
507 let guard = state.read()?;
508 return Ok(WakeResponse {
509 status: WakeResponseStatus::AlreadyAwake,
510 debug_port: i32::from(guard.actual_debug_port),
511 connection_id: guard.connection_id.clone().unwrap_or_default(),
512 daemon_url: guard.daemon_advertise_url.clone(),
513 });
514 }
515
516 if matches!(current_state, ClientState::Waking) {
518 return Err(Error::WakeInProgress);
519 }
520
521 {
523 let state = get();
524 let mut guard = state.write()?;
525 guard.state = ClientState::Waking;
526 }
527
528 let revert_state = || {
530 let state = get();
531 if let Ok(mut guard) = state.write() {
532 guard.state = ClientState::Sleeping;
533 }
534 };
535
536 let dc_holder = DAEMON_CLIENT.get().ok_or(Error::NotInitialized)?;
539 let mut dc_guard = dc_holder
540 .lock()
541 .map_err(|_| Error::ControlPlaneError("daemon client lock poisoned".to_string()))?;
542
543 let fresh_token = auth::discover_token(detrix_home.as_ref().map(std::path::Path::new));
545 if let Some(ref mut dc) = *dc_guard {
546 dc.update_auth_token(fresh_token.clone());
547 }
548
549 if let Some(cs_holder) = CONTROL_SERVER.get() {
551 if let Ok(cs_guard) = cs_holder.lock() {
552 if let Some(ref cs) = *cs_guard {
553 cs.update_token(fresh_token);
554 }
555 }
556 }
557 let daemon_client = dc_guard.as_ref().ok_or(Error::NotInitialized)?;
558
559 let lm_holder = LLDB_MANAGER.get().ok_or(Error::NotInitialized)?;
560 let lm_guard = lm_holder
561 .lock()
562 .map_err(|_| Error::ControlPlaneError("lldb manager lock poisoned".to_string()))?;
563 let lldb_manager = lm_guard.as_ref().ok_or(Error::NotInitialized)?;
564
565 if let Err(e) = daemon_client.health_check(&target_daemon_url, health_timeout) {
567 revert_state();
568 return Err(e);
569 }
570
571 let lldb_process = match lldb_manager.spawn_and_attach(&debug_host, debug_port) {
573 Ok(p) => p,
574 Err(e) => {
575 revert_state();
576 return Err(e);
577 }
578 };
579
580 let actual_debug_port = lldb_process.port;
581
582 state::set_lldb_process(lldb_process);
584
585 let workspace_root = workspace_root_override.unwrap_or_else(|| {
587 std::env::current_dir()
588 .ok()
589 .and_then(|p| p.to_str().map(String::from))
590 .unwrap_or_else(|| {
591 warn!("Failed to get current directory, using /unknown");
592 UNKNOWN_WORKSPACE_ROOT.to_string()
593 })
594 });
595
596 let hostname = hostname::get()
597 .ok()
598 .and_then(|h| h.into_string().ok())
599 .unwrap_or_else(|| {
600 warn!("Failed to get hostname, using unknown");
601 "unknown".to_string()
602 });
603
604 let build_commit = build_info::detect_build_commit(build_commit_override);
606 let build_tag = build_info::detect_build_tag(build_tag_override);
607
608 let registration_host = advertise_host.unwrap_or(debug_host);
612 let (connection_id, advertise_url) = match daemon_client.register(
613 &target_daemon_url,
614 RegisterRequest {
615 host: registration_host,
616 port: actual_debug_port,
617 language: "rust".to_string(),
618 name: name.clone(),
619 workspace_root,
620 hostname,
621 pid: Some(std::process::id()),
622 safe_mode,
623 build_commit,
624 build_tag,
625 },
626 register_timeout,
627 ) {
628 Ok(result) => result,
629 Err(e) => {
630 if let Some(mut process) = state::take_lldb_process() {
632 let _ = lldb_manager.kill(&mut process);
633 }
634 revert_state();
635 return Err(e);
636 }
637 };
638
639 {
641 let state = get();
642 let mut guard = state.write()?;
643 guard.state = ClientState::Awake;
644 guard.actual_debug_port = actual_debug_port;
645 guard.debug_port_active = true;
646 guard.connection_id = Some(connection_id.clone());
647 guard.daemon_advertise_url = advertise_url.clone();
648 }
649
650 info!(
651 "Detrix client awake. Debug port: {}, Connection ID: {}",
652 actual_debug_port, connection_id
653 );
654
655 Ok(WakeResponse {
656 status: WakeResponseStatus::Awake,
657 debug_port: i32::from(actual_debug_port),
658 connection_id,
659 daemon_url: advertise_url,
660 })
661}
662
663fn sleep_handler() -> Result<SleepResponse> {
664 if !is_initialized() {
665 return Err(Error::NotInitialized);
666 }
667
668 let (current_state, daemon_url, connection_id, unregister_timeout) = {
670 let state = get();
671 let guard = state.read()?;
672
673 (
674 guard.state,
675 guard.daemon_url.clone(),
676 guard.connection_id.clone(),
677 Duration::from_millis(guard.unregister_timeout_ms),
678 )
679 };
680
681 if matches!(current_state, ClientState::Sleeping) {
683 return Ok(SleepResponse {
684 status: SleepResponseStatus::AlreadySleeping,
685 });
686 }
687
688 if matches!(current_state, ClientState::Waking) {
690 let _wake_guard = state::acquire_wake_lock()?;
691 let state = get();
693 if let Ok(guard) = state.read() {
694 if matches!(guard.state, ClientState::Sleeping) {
695 return Ok(SleepResponse {
696 status: SleepResponseStatus::AlreadySleeping,
697 });
698 }
699 }
700 }
701
702 if let Some(conn_id) = connection_id {
704 if let Some(holder) = DAEMON_CLIENT.get() {
705 if let Ok(guard) = holder.lock() {
706 if let Some(daemon_client) = guard.as_ref() {
707 daemon_client.unregister(&daemon_url, &conn_id, unregister_timeout);
708 }
709 }
710 }
711 }
712
713 if let Some(mut process) = state::take_lldb_process() {
715 if let Some(holder) = LLDB_MANAGER.get() {
716 if let Ok(guard) = holder.lock() {
717 if let Some(lldb_manager) = guard.as_ref() {
718 if let Err(e) = lldb_manager.kill(&mut process) {
719 warn!("Failed to kill lldb-dap: {}", e);
720 }
721 }
722 }
723 }
724 }
725
726 {
728 let state = get();
729 let mut guard = state.write()?;
730 guard.state = ClientState::Sleeping;
731 guard.connection_id = None;
732 guard.debug_port_active = false;
733 }
734
735 info!("Detrix client sleeping");
736
737 Ok(SleepResponse {
738 status: SleepResponseStatus::Sleeping,
739 })
740}
741
742#[cfg(test)]
743mod tests {
744 use super::*;
745
746 #[test]
750 fn test_config_default() {
751 let config = Config::default();
752 assert!(config.name.is_none());
753 assert_eq!(config.control_host, "127.0.0.1");
754 assert_eq!(config.control_port, 0);
755 }
756
757 #[test]
758 fn test_status_not_initialized() {
759 state::reset();
761
762 let status = status();
763 assert!(matches!(status.state, ClientState::Sleeping));
764 }
765
766 #[test]
767 fn test_init_lock_exists() {
768 let lock = INIT_LOCK.get_or_init(|| std::sync::Mutex::new(()));
770 let guard = lock.lock();
771 assert!(guard.is_ok(), "INIT_LOCK should be acquirable");
772 let second = lock.try_lock();
774 assert!(second.is_err(), "INIT_LOCK should not be re-entrant");
775 }
776}