Skip to main content

detrix_rs/
lib.rs

1//! Detrix Rust Client Library
2//!
3//! Debug-on-demand observability for Rust applications. This client enables
4//! AI-powered debugging of running Rust processes without code modifications
5//! or restarts.
6//!
7//! # Quick Start
8//!
9//! ```no_run
10//! use detrix_rs::Config;
11//!
12//! fn main() -> Result<(), Box<dyn std::error::Error>> {
13//!     // Initialize client (starts control plane, stays SLEEPING)
14//!     detrix_rs::init(Config::default())?;
15//!
16//!     // Your application code runs normally...
17//!
18//!     // When debugging is needed, call /detrix/wake on the control plane
19//!     // Or programmatically:
20//!     // detrix_rs::wake()?;
21//!
22//!     detrix_rs::shutdown()?;
23//!     Ok(())
24//! }
25//! ```
26//!
27//! # Architecture
28//!
29//! The client follows the same pattern as the Python and Go clients:
30//!
31//! 1. **SLEEPING**: Zero overhead. Only control plane HTTP server running.
32//! 2. **WAKING**: Transitional state during wake operation.
33//! 3. **AWAKE**: lldb-dap attached, registered with daemon, ready for metrics.
34//!
35//! Unlike Python's debugpy, lldb-dap can be fully stopped on sleep,
36//! providing cleaner resource management.
37
38#![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
66/// Fallback workspace root used when the current directory cannot be determined.
67const UNKNOWN_WORKSPACE_ROOT: &str = "/unknown";
68use daemon::{DaemonClient, RegisterRequest};
69use lldb::LldbManager;
70use state::{get, is_initialized, set_initialized};
71
72/// Init/shutdown lock to prevent concurrent initialization races.
73static INIT_LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
74
75/// Global control server instance.
76static CONTROL_SERVER: std::sync::OnceLock<std::sync::Mutex<Option<ControlServer>>> =
77    std::sync::OnceLock::new();
78
79/// Global daemon client instance (resettable on shutdown for re-initialization).
80static DAEMON_CLIENT: std::sync::OnceLock<std::sync::Mutex<Option<DaemonClient>>> =
81    std::sync::OnceLock::new();
82
83/// Global LLDB manager instance (resettable on shutdown for re-initialization).
84static LLDB_MANAGER: std::sync::OnceLock<std::sync::Mutex<Option<LldbManager>>> =
85    std::sync::OnceLock::new();
86
87/// Initialize the Detrix client.
88///
89/// This starts the control plane HTTP server but does NOT contact the daemon.
90/// The client starts in SLEEPING state with zero overhead.
91///
92/// # Configuration
93///
94/// Configuration can be provided via the `Config` struct or environment variables:
95/// - `DETRIX_NAME` - Connection name
96/// - `DETRIX_DAEMON_URL` - Daemon URL
97/// - `DETRIX_CONTROL_HOST` - Control plane bind host
98/// - `DETRIX_CONTROL_PORT` - Control plane port
99/// - `DETRIX_DEBUG_PORT` - Debug adapter port
100/// - `DETRIX_LLDB_DAP_PATH` - Path to lldb-dap binary
101/// - `DETRIX_HOME` - Detrix home directory
102/// - `DETRIX_HEALTH_CHECK_TIMEOUT` - Health check timeout (seconds, e.g. "2.0")
103/// - `DETRIX_REGISTER_TIMEOUT` - Registration timeout (seconds, e.g. "5.0")
104/// - `DETRIX_UNREGISTER_TIMEOUT` - Unregistration timeout (seconds, e.g. "2.0")
105///
106/// # Errors
107///
108/// Returns an error if:
109/// - Client is already initialized
110/// - lldb-dap binary not found
111/// - Failed to start control plane
112///
113/// # Example
114///
115/// ```no_run
116/// use detrix_rs::{self, Config};
117///
118/// detrix_rs::init(Config {
119///     name: Some("my-service".to_string()),
120///     ..Config::default()
121/// })?;
122/// # Ok::<(), detrix_rs::Error>(())
123/// ```
124pub 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    // Apply environment variable overrides
135    let config = config.with_env_overrides();
136
137    // Find lldb-dap binary
138    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    // Initialize global state
142    {
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    // Discover auth token before creating daemon client
184    let auth_token = auth::discover_token(config.detrix_home_path().as_deref());
185
186    // Initialize daemon client (resettable via Mutex<Option<T>>)
187    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    // Best-effort: fetch advertise_url from daemon (daemon may not be up yet)
194    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    // Initialize LLDB manager (resettable via Mutex<Option<T>>)
209    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    // Create callbacks for control server
216    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    // Start control server
223    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    // Store the server
237    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    // Update state with actual port
243    {
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
260/// Get the current client status.
261///
262/// This never blocks on network I/O - it returns immediately with the
263/// current state snapshot.
264///
265/// # Returns
266///
267/// A `StatusResponse` containing:
268/// - `state`: Current state (sleeping, waking, or awake)
269/// - `name`: Connection name
270/// - `control_port`: Actual control plane port
271/// - `debug_port`: Debug adapter port (0 if never started)
272/// - `connection_id`: Daemon connection ID (None if not registered)
273pub 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
290/// Wake the client: start debugger and register with daemon.
291///
292/// This spawns an lldb-dap process to attach to the current process,
293/// then registers the connection with the Detrix daemon.
294///
295/// # Errors
296///
297/// Returns an error if:
298/// - Client not initialized
299/// - Wake already in progress
300/// - Daemon not reachable
301/// - Failed to start lldb-dap
302/// - Failed to register with daemon
303///
304/// # Example
305///
306/// ```no_run
307/// use detrix_rs;
308///
309/// let response = detrix_rs::wake()?;
310/// println!("Debug port: {}", response.debug_port);
311/// println!("Connection ID: {}", response.connection_id);
312/// # Ok::<(), detrix_rs::Error>(())
313/// ```
314pub fn wake() -> Result<WakeResponse> {
315    wake_with_url(None)
316}
317
318/// Wake the client with a daemon URL override.
319///
320/// Same as `wake()` but allows overriding the daemon URL for this operation.
321pub fn wake_with_url(daemon_url: impl Into<Option<String>>) -> Result<WakeResponse> {
322    wake_handler(daemon_url.into())
323}
324
325/// Put the client to sleep: stop debugger and unregister from daemon.
326///
327/// Unlike Python's debugpy, lldb-dap can be fully stopped, providing
328/// cleaner resource management.
329///
330/// # Errors
331///
332/// Returns an error if the client is not initialized.
333///
334/// # Example
335///
336/// ```no_run
337/// use detrix_rs;
338///
339/// let response = detrix_rs::sleep()?;
340/// assert_eq!(response.status, detrix_rs::SleepResponseStatus::Sleeping);
341/// # Ok::<(), detrix_rs::Error>(())
342/// ```
343pub fn sleep() -> Result<SleepResponse> {
344    sleep_handler()
345}
346
347/// Shutdown the client and clean up resources.
348///
349/// This will:
350/// 1. Sleep if currently awake
351/// 2. Stop the control plane server
352/// 3. Reset global state
353///
354/// # Errors
355///
356/// Returns Ok(()) even if not initialized.
357pub 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    // Sleep first to unregister and stop lldb-dap
368    let _ = sleep();
369
370    // Stop control server
371    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    // Clear daemon client and lldb manager for re-initialization
379    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    // Reset state
391    state::reset();
392
393    info!("Detrix client shutdown complete");
394    Ok(())
395}
396
397// ============================================================================
398// Internal handlers
399// ============================================================================
400
401fn 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    // Lazy-fetch: if advertise URL unknown, ask daemon now
417    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    // Build control_plane_url: the daemon-visible URL of this app's control plane.
437    // Use advertise_host (DETRIX_HOST) if set; otherwise use control_host unless
438    // it's a bind-all address. This lets the MCP bridge route wake requests
439    // correctly in Docker/cloud where localhost:PORT != container:PORT.
440    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    // Acquire wake lock
465    let _wake_guard = state::acquire_wake_lock()?;
466
467    // Read current state
468    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    // Check if already awake
505    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    // Check if wake in progress
517    if matches!(current_state, ClientState::Waking) {
518        return Err(Error::WakeInProgress);
519    }
520
521    // Transition to WAKING
522    {
523        let state = get();
524        let mut guard = state.write()?;
525        guard.state = ClientState::Waking;
526    }
527
528    // Helper to revert state on error
529    let revert_state = || {
530        let state = get();
531        if let Ok(mut guard) = state.write() {
532            guard.state = ClientState::Sleeping;
533        }
534    };
535
536    // Acquire daemon client and lldb manager locks for the operation.
537    // Safe to hold for the duration because wake_lock already serializes access.
538    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    // Re-discover token (daemon may have restarted with a new one)
544    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    // Update control server token too (so it accepts requests with the new token)
550    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    // Check daemon health
566    if let Err(e) = daemon_client.health_check(&target_daemon_url, health_timeout) {
567        revert_state();
568        return Err(e);
569    }
570
571    // Spawn lldb-dap and attach
572    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    // Store lldb process
583    state::set_lldb_process(lldb_process);
584
585    // Get workspace root and hostname for identity
586    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    // Detect build information
605    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    // Register with daemon
609    // Use advertise_host if set (for Docker/cloud), otherwise use control_host
610    // Pass our PID so the daemon can use AttachPid mode with lldb-dap
611    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            // Kill lldb and revert state
631            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    // Update state to AWAKE
640    {
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    // Read current state
669    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    // Check if already sleeping
682    if matches!(current_state, ClientState::Sleeping) {
683        return Ok(SleepResponse {
684            status: SleepResponseStatus::AlreadySleeping,
685        });
686    }
687
688    // If waking, wait for it to complete
689    if matches!(current_state, ClientState::Waking) {
690        let _wake_guard = state::acquire_wake_lock()?;
691        // Re-check state after acquiring lock
692        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    // Unregister from daemon (best effort)
703    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    // Kill lldb-dap process
714    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    // Update state to SLEEPING
727    {
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    // Note: Most tests require external resources (lldb-dap, daemon)
747    // and are better suited for integration tests.
748
749    #[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        // Reset any previous state
760        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        // Verify that INIT_LOCK can be acquired and prevents concurrent access
769        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        // While held, try_lock should fail
773        let second = lock.try_lock();
774        assert!(second.is_err(), "INIT_LOCK should not be re-entrant");
775    }
776}