Skip to main content

ipfrs_cli/
connectivity.rs

1//! Fast offline detection for IPFRS CLI
2//!
3//! Determines whether the IPFRS daemon is running and reachable within a
4//! bounded timeout (≤ 2 s).  The checks are deliberately cheap:
5//!
6//! 1. Look for the PID file on disk — if it is missing the daemon is
7//!    definitely not running.
8//! 2. Send signal 0 to the recorded PID to verify the process still exists
9//!    (POSIX `kill(pid, 0)` semantic, implemented via `std::process::Command`).
10//!
11//! A separate helper [`with_network_timeout`] wraps any `Future` with a
12//! configurable deadline so network-bound commands never block indefinitely.
13
14use std::path::Path;
15use std::time::Duration;
16
17/// Maximum time budget for all daemon-reachability checks.
18pub const DAEMON_CHECK_TIMEOUT: Duration = Duration::from_secs(2);
19
20/// Check whether the IPFRS daemon is reachable.
21///
22/// Returns `true` if a running process with the recorded PID can be found,
23/// `false` in every other case (missing PID file, stale PID, parse error …).
24///
25/// The whole operation is designed to complete in well under
26/// [`DAEMON_CHECK_TIMEOUT`].
27///
28/// # Arguments
29///
30/// * `data_dir` – Path to the IPFRS data directory that contains
31///   `daemon.pid` (e.g. `.ipfrs`).
32pub async fn check_daemon_reachable(data_dir: &str) -> bool {
33    let pid_file = Path::new(data_dir).join("daemon.pid");
34
35    if !pid_file.exists() {
36        return false;
37    }
38
39    // Read PID – keep errors silent; we just report "not reachable".
40    let raw = match tokio::fs::read_to_string(&pid_file).await {
41        Ok(s) => s,
42        Err(_) => return false,
43    };
44
45    match raw.trim().parse::<u32>() {
46        Ok(pid) => is_process_alive(pid),
47        Err(_) => false,
48    }
49}
50
51/// Test whether a process with the given PID is alive.
52///
53/// On POSIX systems this sends signal 0 (`kill -0 <pid>`).  On Windows the
54/// approach is approximated by checking whether the process exists via
55/// `tasklist`; on non-POSIX Unix platforms we fall back to `false`.
56fn is_process_alive(pid: u32) -> bool {
57    #[cfg(unix)]
58    {
59        // SAFETY: kill(2) with signal 0 is purely a permission/existence check;
60        // it does not deliver any signal.
61        let output = std::process::Command::new("kill")
62            .args(["-0", &pid.to_string()])
63            .output();
64
65        match output {
66            Ok(out) => out.status.success(),
67            Err(_) => false,
68        }
69    }
70
71    #[cfg(windows)]
72    {
73        // On Windows approximate with tasklist.
74        let output = std::process::Command::new("tasklist")
75            .args(["/FI", &format!("PID eq {}", pid), "/NH"])
76            .output();
77
78        match output {
79            Ok(out) => {
80                let stdout = String::from_utf8_lossy(&out.stdout);
81                stdout.contains(&pid.to_string())
82            }
83            Err(_) => false,
84        }
85    }
86
87    #[cfg(not(any(unix, windows)))]
88    {
89        let _ = pid;
90        false
91    }
92}
93
94/// Build a user-friendly error message for the case where the IPFRS daemon
95/// is not running or not reachable.
96///
97/// # Arguments
98///
99/// * `data_dir` – Path to the IPFRS data directory (e.g. `.ipfrs`).
100pub fn offline_error_message(data_dir: &str) -> String {
101    format!(
102        "IPFRS daemon is not running.\n\
103         Start it with:       ipfrs daemon start --data-dir {data_dir}\n\
104         Or run foreground:   ipfrs daemon run   --data-dir {data_dir}\n\
105         Check status with:   ipfrs daemon status"
106    )
107}
108
109/// Wrap a future with a wall-clock timeout.
110///
111/// Returns `Some(value)` if the future completes before `timeout` elapses,
112/// or `None` if it times out.
113///
114/// # Examples
115///
116/// ```rust,no_run
117/// use ipfrs_cli::connectivity::{with_network_timeout, DAEMON_CHECK_TIMEOUT};
118///
119/// async fn example() {
120///     let result = with_network_timeout(
121///         async { 42_u32 },
122///         DAEMON_CHECK_TIMEOUT,
123///     ).await;
124///     assert_eq!(result, Some(42));
125/// }
126/// ```
127pub async fn with_network_timeout<F, T>(fut: F, timeout: Duration) -> Option<T>
128where
129    F: std::future::Future<Output = T>,
130{
131    tokio::time::timeout(timeout, fut).await.ok()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use std::env::temp_dir;
138
139    #[tokio::test]
140    async fn test_missing_data_dir_returns_false() {
141        let result = check_daemon_reachable("/nonexistent/ipfrs/data/dir").await;
142        assert!(!result, "should be false when data dir does not exist");
143    }
144
145    #[tokio::test]
146    async fn test_missing_pid_file_returns_false() {
147        // Create a temp dir without a pid file inside.
148        let dir = temp_dir().join("ipfrs_test_connectivity_no_pid");
149        let _ = std::fs::create_dir_all(&dir);
150        let result = check_daemon_reachable(&dir.to_string_lossy()).await;
151        assert!(!result, "should be false when pid file is absent");
152        let _ = std::fs::remove_dir_all(&dir);
153    }
154
155    #[tokio::test]
156    async fn test_stale_pid_returns_false() {
157        // PID 99999999 is virtually guaranteed not to exist.
158        let dir = temp_dir().join("ipfrs_test_connectivity_stale");
159        let _ = std::fs::create_dir_all(&dir);
160        let pid_path = dir.join("daemon.pid");
161        std::fs::write(&pid_path, "99999999").expect("write pid file");
162        let result = check_daemon_reachable(&dir.to_string_lossy()).await;
163        assert!(!result, "should be false for a stale PID");
164        let _ = std::fs::remove_dir_all(&dir);
165    }
166
167    #[tokio::test]
168    async fn test_with_network_timeout_completes() {
169        let result = with_network_timeout(async { 7_u32 }, Duration::from_secs(1)).await;
170        assert_eq!(result, Some(7));
171    }
172
173    #[tokio::test]
174    async fn test_with_network_timeout_expires() {
175        let result = with_network_timeout(
176            async {
177                tokio::time::sleep(Duration::from_secs(10)).await;
178                42_u32
179            },
180            Duration::from_millis(50),
181        )
182        .await;
183        assert!(result.is_none(), "should time out");
184    }
185
186    #[test]
187    fn test_offline_error_message_contains_data_dir() {
188        let msg = offline_error_message("/tmp/test-ipfrs");
189        assert!(
190            msg.contains("daemon is not running"),
191            "should mention daemon not running"
192        );
193        assert!(
194            msg.contains("/tmp/test-ipfrs"),
195            "should include the data_dir path"
196        );
197    }
198
199    #[test]
200    fn test_offline_error_message_contains_start_command() {
201        let msg = offline_error_message(".ipfrs");
202        assert!(
203            msg.contains("daemon start"),
204            "should suggest 'daemon start'"
205        );
206        assert!(msg.contains("daemon run"), "should suggest 'daemon run'");
207    }
208}