hyperdb_mcp/daemon/
run.rs1use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant};
13
14use tokio::signal;
15use tracing::{error, info, warn};
16
17use hyperdb_api::{HyperProcess, Parameters, TransportMode};
18
19use super::discovery::{self, DaemonInfo};
20use super::health::{DaemonState, HealthListener};
21use super::ENV_IDLE_TIMEOUT;
22
23#[derive(Debug)]
25pub struct DaemonConfig {
26 pub port: u16,
27 pub idle_timeout: Option<Duration>,
31}
32
33impl DaemonConfig {
34 pub fn from_args(port: u16, idle_timeout_secs: Option<u64>) -> Self {
41 let idle_timeout = idle_timeout_secs
42 .or_else(|| {
43 std::env::var(ENV_IDLE_TIMEOUT)
44 .ok()
45 .and_then(|v| v.parse().ok())
46 })
47 .map(Duration::from_secs);
48
49 Self { port, idle_timeout }
50 }
51}
52
53pub const RESTART_WINDOW: Duration = Duration::from_secs(60);
56pub const RESTART_LIMIT: usize = 3;
57
58const HYPERD_POLL_INTERVAL: Duration = Duration::from_secs(5);
60
61struct HyperState {
64 hyper: Option<HyperProcess>,
65 restart_history: Vec<Instant>,
66}
67
68#[derive(Debug)]
69enum RestartError {
70 TooManyRestarts,
72 SpawnFailed(String),
74}
75
76pub async fn run_daemon(config: DaemonConfig) -> Result<(), Box<dyn std::error::Error>> {
82 let listener = HealthListener::bind(config.port).map_err(|e| {
84 if e.kind() == std::io::ErrorKind::AddrInUse {
85 format!(
86 "Another hyperdb daemon is already running on port {}. \
87 Use `hyperdb-mcp daemon status` to check or `hyperdb-mcp daemon stop` to stop it.",
88 config.port
89 )
90 } else {
91 format!("Failed to bind health port {}: {e}", config.port)
92 }
93 })?;
94 let bound_port = listener.port;
95 info!(port = bound_port, "daemon health listener bound");
96
97 let hyper = HyperProcess::new(None, Some(&build_params()?))?;
99 let endpoint = hyper
100 .endpoint()
101 .ok_or("hyperd did not report an endpoint")?
102 .to_string();
103 info!(endpoint = %endpoint, "hyperd started");
104
105 let info = DaemonInfo {
107 pid: std::process::id(),
108 hyperd_endpoint: endpoint.clone(),
109 health_port: bound_port,
110 started_at: chrono::Utc::now().to_rfc3339(),
111 version: env!("CARGO_PKG_VERSION").to_string(),
112 };
113 discovery::write_discovery_file(&info)?;
114 info!(path = %discovery::discovery_file_path()?.display(), "discovery file written");
115
116 let state = Arc::new(DaemonState::new());
121 let info_arc = Arc::new(Mutex::new(info));
122 let hyper_state = Arc::new(Mutex::new(HyperState {
123 hyper: Some(hyper),
124 restart_history: Vec::new(),
125 }));
126
127 let health_state = Arc::clone(&state);
129 let health_info = Arc::clone(&info_arc);
130 let health_handle = std::thread::spawn(move || {
131 listener.run(health_state, health_info);
132 });
133
134 if let Some(d) = config.idle_timeout {
136 info!(idle_timeout_secs = d.as_secs(), "idle shutdown enabled");
137 } else {
138 info!("idle shutdown disabled (daemon will stay resident)");
139 }
140
141 let idle_fut = async {
144 match config.idle_timeout {
145 Some(d) => idle_monitor(Arc::clone(&state), d).await,
146 None => std::future::pending::<()>().await,
147 }
148 };
149 tokio::select! {
150 () = idle_fut => {}
151 () = hyperd_monitor(Arc::clone(&state), Arc::clone(&hyper_state), Arc::clone(&info_arc)) => {}
152 () = shutdown_signal() => {
153 info!("received shutdown signal");
154 }
155 }
156 state.request_shutdown();
157
158 info!("shutting down daemon");
166 discovery::remove_discovery_file();
167 let _ = health_handle.join();
168 drop(hyper_state); Ok(())
171}
172
173#[derive(Debug, PartialEq, Eq)]
175pub enum RestartAttempt {
176 Recorded,
178 LimitExceeded,
180}
181
182pub fn try_record_restart_attempt(history: &mut Vec<Instant>, now: Instant) -> RestartAttempt {
189 history.retain(|t| now.duration_since(*t) < RESTART_WINDOW);
190 if history.len() >= RESTART_LIMIT {
191 return RestartAttempt::LimitExceeded;
192 }
193 history.push(now);
194 RestartAttempt::Recorded
195}
196
197fn build_params() -> std::io::Result<Parameters> {
199 let log_dir = discovery::state_dir()?.join("logs");
200 std::fs::create_dir_all(&log_dir)?;
201
202 let mut params = Parameters::new();
203 params.set("log_file_max_count", "2");
204 params.set("log_file_size_limit", "100M");
205 params.set("log_dir", log_dir.to_string_lossy().as_ref());
206 params.set_transport_mode(TransportMode::Tcp);
207 Ok(params)
208}
209
210async fn idle_monitor(state: Arc<DaemonState>, idle_timeout: Duration) {
213 loop {
214 tokio::time::sleep(Duration::from_secs(10)).await;
215 if state.should_shutdown() {
216 return;
217 }
218 if state.idle_duration() >= idle_timeout {
219 info!(
220 idle_secs = idle_timeout.as_secs(),
221 "idle timeout reached, shutting down"
222 );
223 return;
224 }
225 }
226}
227
228async fn hyperd_monitor(
232 state: Arc<DaemonState>,
233 hyper_state: Arc<Mutex<HyperState>>,
234 info_arc: Arc<Mutex<DaemonInfo>>,
235) {
236 loop {
237 tokio::time::sleep(HYPERD_POLL_INTERVAL).await;
238 if state.should_shutdown() {
239 return;
240 }
241
242 let needs_restart = {
246 let mut guard = hyper_state.lock().expect("HyperState mutex poisoned");
247 let process_dead = guard.hyper.as_mut().map_or(true, HyperProcess::has_exited);
248 if process_dead {
251 let _ = state.consume_restart_request();
254 true
255 } else {
256 state.consume_restart_request()
257 }
258 };
259
260 if !needs_restart {
261 continue;
262 }
263
264 match try_restart_hyperd(&hyper_state, &info_arc) {
265 Ok(new_endpoint) => {
266 info!(endpoint = %new_endpoint, "hyperd restarted");
267 let _ = state.consume_restart_request();
273 }
274 Err(RestartError::TooManyRestarts) => {
275 error!(
276 limit = RESTART_LIMIT,
277 window_secs = RESTART_WINDOW.as_secs(),
278 "hyperd restart limit exceeded — daemon shutting down"
279 );
280 return;
281 }
282 Err(RestartError::SpawnFailed(e)) => {
283 warn!(error = %e, "hyperd spawn failed during restart; will retry on next tick");
284 }
285 }
286 }
287}
288
289fn try_restart_hyperd(
295 hyper_state: &Mutex<HyperState>,
296 info_arc: &Mutex<DaemonInfo>,
297) -> Result<String, RestartError> {
298 let mut guard = hyper_state.lock().expect("HyperState mutex poisoned");
299
300 if try_record_restart_attempt(&mut guard.restart_history, Instant::now())
302 == RestartAttempt::LimitExceeded
303 {
304 return Err(RestartError::TooManyRestarts);
305 }
306
307 guard.hyper = None;
310
311 let params = build_params().map_err(|e| RestartError::SpawnFailed(e.to_string()))?;
313 let new_hyper = HyperProcess::new(None, Some(¶ms))
314 .map_err(|e| RestartError::SpawnFailed(e.to_string()))?;
315 let new_endpoint = new_hyper
316 .endpoint()
317 .ok_or_else(|| RestartError::SpawnFailed("hyperd did not report endpoint".into()))?
318 .to_string();
319
320 let snapshot = {
324 let mut info_guard = info_arc.lock().expect("DaemonInfo mutex poisoned");
325 info_guard.hyperd_endpoint.clone_from(&new_endpoint);
326 info_guard.clone()
327 };
328 discovery::write_discovery_file(&snapshot)
329 .map_err(|e| RestartError::SpawnFailed(format!("discovery write: {e}")))?;
330
331 guard.hyper = Some(new_hyper);
332 Ok(new_endpoint)
333}
334
335async fn shutdown_signal() {
336 let ctrl_c = signal::ctrl_c();
337
338 #[cfg(unix)]
339 {
340 let mut sigterm =
341 signal::unix::signal(signal::unix::SignalKind::terminate()).expect("sigterm handler");
342 tokio::select! {
343 _ = ctrl_c => {}
344 _ = sigterm.recv() => {}
345 }
346 }
347
348 #[cfg(not(unix))]
349 {
350 ctrl_c.await.ok();
351 }
352}