nap_core/server/
process.rs1use anyhow::{Context, Result};
8use std::path::Path;
9use std::process::{Command, Stdio};
10use std::time::Duration;
11
12pub 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 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 #[allow(clippy::needless_return)]
35 pub fn start(&self) -> Result<u32> {
36 if let Some(parent) = self.log_path.parent() {
38 std::fs::create_dir_all(parent).context("Failed to create log directory")?;
39 }
40
41 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 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 #[cfg(unix)]
63 {
64 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 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 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 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 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 pub fn log_path(&self) -> &Path {
283 &self.log_path
284 }
285
286 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 let current_pid = std::process::id();
324 assert!(LoreProcessManager::is_running(current_pid));
325
326 assert!(!LoreProcessManager::is_running(999999));
328 }
329}