Skip to main content

nap_core/server/
process.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Lore server process management
4//!
5//! Cross-platform detached process launch and lifecycle management for Lore server.
6
7use anyhow::{Context, Result};
8use std::path::Path;
9use std::process::{Child, Command, Stdio};
10use std::time::Duration;
11
12/// Lore server process manager
13pub struct LoreProcessManager {
14    #[allow(dead_code)]
15    nap_home: std::path::PathBuf,
16    log_path: std::path::PathBuf,
17    config_path: std::path::PathBuf,
18}
19
20impl LoreProcessManager {
21    /// Create a new Lore process manager
22    pub fn new(nap_home: &Path) -> Self {
23        let config_path = nap_home.join("lore").join("config").join("local.toml");
24        let log_path = nap_home.join("lore").join("logs").join("loreserver.log");
25
26        Self {
27            nap_home: nap_home.to_path_buf(),
28            config_path,
29            log_path,
30        }
31    }
32
33    /// Start Lore server as a detached background process
34    pub fn start(&self) -> Result<Child> {
35        // Ensure log directory exists
36        if let Some(parent) = self.log_path.parent() {
37            std::fs::create_dir_all(parent).context("Failed to create log directory")?;
38        }
39
40        // Verify configuration exists
41        if !self.config_path.exists() {
42            anyhow::bail!(
43                "Lore configuration not found at '{}'. \
44                 Run 'nap init' or ensure configuration generation has been completed \
45                 before starting the Lore server.",
46                self.config_path.display()
47            );
48        }
49
50        // Open log file for output
51        let log_file =
52            std::fs::File::create(&self.log_path).context("Failed to create log file")?;
53
54        tracing::info!(
55            config = %self.config_path.display(),
56            log = %self.log_path.display(),
57            "Starting Lore server"
58        );
59
60        // Launch Lore server with configuration
61        let child = Command::new("loreserver")
62            .arg("--config")
63            .arg(&self.config_path)
64            .stdout(Stdio::from(log_file.try_clone()?))
65            .stderr(Stdio::from(log_file))
66            .spawn()
67            .context("Failed to start Lore server. Is loreserver installed and on PATH?")?;
68
69        tracing::info!(pid = child.id(), "Lore server started successfully");
70
71        Ok(child)
72    }
73
74    /// Stop Lore server by PID
75    pub fn stop(pid: u32) -> Result<()> {
76        tracing::info!(pid, "Stopping Lore server");
77
78        #[cfg(unix)]
79        {
80            use nix::sys::signal::kill;
81            use nix::unistd::Pid;
82
83            kill(Pid::from_raw(pid as i32), nix::sys::signal::Signal::SIGTERM)
84                .context("Failed to send SIGTERM to Lore server")?;
85        }
86
87        #[cfg(windows)]
88        {
89            use windows_sys::Win32::Foundation::CloseHandle;
90            use windows_sys::Win32::System::Threading::{
91                OpenProcess, PROCESS_TERMINATE, TerminateProcess,
92            };
93
94            unsafe {
95                let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
96                if handle == std::ptr::null_mut() {
97                    anyhow::bail!("Failed to open process handle for PID {}", pid);
98                }
99                let result = TerminateProcess(handle, 1);
100                CloseHandle(handle);
101                if result == 0 {
102                    anyhow::bail!("Failed to terminate process with PID {}", pid);
103                }
104            }
105        }
106
107        tracing::info!(pid, "Lore server stopped");
108        Ok(())
109    }
110
111    /// Check if Lore server is running by PID
112    pub fn is_running(pid: u32) -> bool {
113        #[cfg(unix)]
114        {
115            use nix::sys::signal::kill;
116            use nix::unistd::Pid;
117
118            kill(Pid::from_raw(pid as i32), None).is_ok()
119        }
120
121        #[cfg(windows)]
122        {
123            use windows_sys::Win32::Foundation::CloseHandle;
124            use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION};
125
126            unsafe {
127                let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid);
128                if handle == std::ptr::null_mut() {
129                    return false;
130                }
131                CloseHandle(handle);
132                true
133            }
134        }
135    }
136
137    /// Perform health check on Lore server
138    pub async fn health_check(port: u16, timeout: Duration) -> Result<bool> {
139        let url = format!("http://127.0.0.1:{}/health_check", port);
140
141        let client = reqwest::Client::builder()
142            .timeout(timeout)
143            .build()
144            .context("Failed to build HTTP client for Lore health check")?;
145
146        let response = client.get(&url).send().await;
147
148        match response {
149            Ok(resp) => {
150                let is_healthy = resp.status().is_success();
151                if is_healthy {
152                    tracing::debug!(
153                        port,
154                        url = %url,
155                        "Lore server health check passed"
156                    );
157                } else {
158                    tracing::warn!(
159                        port,
160                        url = %url,
161                        status = %resp.status(),
162                        "Lore server health check returned non-success status"
163                    );
164                }
165                Ok(is_healthy)
166            }
167            Err(e) => {
168                if e.is_timeout() {
169                    tracing::debug!(
170                        port,
171                        url = %url,
172                        timeout_ms = timeout.as_millis(),
173                        "Lore server health check timed out after {:?} — server may still be starting",
174                        timeout
175                    );
176                } else if e.is_connect() {
177                    tracing::debug!(
178                        port,
179                        url = %url,
180                        "Lore server health check connection refused — server is not listening on port {}",
181                        port
182                    );
183                } else {
184                    tracing::debug!(
185                        port,
186                        url = %url,
187                        error = %e,
188                        "Lore server health check failed"
189                    );
190                }
191                Ok(false)
192            }
193        }
194    }
195
196    /// Wait for Lore server to become healthy
197    pub async fn wait_for_healthy(
198        port: u16,
199        timeout: Duration,
200        retry_interval: Duration,
201    ) -> Result<()> {
202        let start = std::time::Instant::now();
203        let mut attempt = 0;
204
205        while start.elapsed() < timeout {
206            attempt += 1;
207            tracing::debug!(
208                attempt,
209                elapsed_ms = start.elapsed().as_millis(),
210                timeout_ms = timeout.as_millis(),
211                "Health check attempt for Lore server on port {}",
212                port
213            );
214
215            if Self::health_check(port, retry_interval).await? {
216                tracing::info!(
217                    attempt,
218                    elapsed_ms = start.elapsed().as_millis(),
219                    port,
220                    "Lore server became healthy after {} attempts ({:?})",
221                    attempt,
222                    start.elapsed()
223                );
224                return Ok(());
225            }
226            tokio::time::sleep(retry_interval).await;
227        }
228
229        anyhow::bail!(
230            "Lore server on port {} did not become healthy within {:?} ({} attempts). \
231             Possible causes: server crash during startup, port conflict, \
232             configuration error, or insufficient resources. \
233             Check logs for details.",
234            port,
235            timeout,
236            attempt
237        );
238    }
239
240    /// Get the log file path
241    pub fn log_path(&self) -> &Path {
242        &self.log_path
243    }
244
245    /// Get the configuration path
246    pub fn config_path(&self) -> &Path {
247        &self.config_path
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use tempfile::TempDir;
255
256    #[test]
257    fn test_process_manager_creation() {
258        let temp_dir = TempDir::new().unwrap();
259        let manager = LoreProcessManager::new(temp_dir.path());
260
261        assert_eq!(
262            manager.config_path(),
263            temp_dir
264                .path()
265                .join("lore")
266                .join("config")
267                .join("local.toml")
268        );
269        assert_eq!(
270            manager.log_path(),
271            temp_dir
272                .path()
273                .join("lore")
274                .join("logs")
275                .join("loreserver.log")
276        );
277    }
278
279    #[test]
280    fn test_is_running() {
281        // Test with current process PID
282        let current_pid = std::process::id();
283        assert!(LoreProcessManager::is_running(current_pid));
284
285        // Test with invalid PID
286        assert!(!LoreProcessManager::is_running(999999));
287    }
288}