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