Skip to main content

hyperdb_mcp/daemon/
run.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Daemon main loop: spawns `hyperd`, runs health listener, monitors idle timeout
5//! and hyperd liveness, restarts hyperd if it dies.
6
7use std::sync::{Arc, Mutex};
8use std::time::{Duration, Instant};
9
10use tokio::signal;
11use tracing::{error, info, warn};
12
13use hyperdb_api::{HyperProcess, Parameters, TransportMode};
14
15use super::discovery::{self, DaemonInfo};
16use super::health::{DaemonState, HealthListener};
17use super::{DEFAULT_IDLE_TIMEOUT_SECS, ENV_IDLE_TIMEOUT};
18
19/// Configuration for the daemon process.
20#[derive(Debug)]
21pub struct DaemonConfig {
22    pub port: u16,
23    pub idle_timeout: Duration,
24}
25
26impl DaemonConfig {
27    pub fn from_args(port: u16, idle_timeout_secs: Option<u64>) -> Self {
28        let idle_timeout_secs = idle_timeout_secs
29            .or_else(|| {
30                std::env::var(ENV_IDLE_TIMEOUT)
31                    .ok()
32                    .and_then(|v| v.parse().ok())
33            })
34            .unwrap_or(DEFAULT_IDLE_TIMEOUT_SECS);
35
36        Self {
37            port,
38            idle_timeout: Duration::from_secs(idle_timeout_secs),
39        }
40    }
41}
42
43/// Restart-attempt rate limit: at most 3 attempts within this window.
44/// The 4th attempt within the window is rejected and triggers daemon shutdown.
45pub const RESTART_WINDOW: Duration = Duration::from_secs(60);
46pub const RESTART_LIMIT: usize = 3;
47
48/// Polling interval for the hyperd-liveness monitor.
49const HYPERD_POLL_INTERVAL: Duration = Duration::from_secs(5);
50
51/// State the monitor task mutates and the main task drops on shutdown.
52/// Holds the live `HyperProcess` and the rolling restart-attempt history.
53struct HyperState {
54    hyper: Option<HyperProcess>,
55    restart_history: Vec<Instant>,
56}
57
58#[derive(Debug)]
59enum RestartError {
60    /// More than `RESTART_LIMIT` restart attempts within `RESTART_WINDOW`.
61    TooManyRestarts,
62    /// `HyperProcess::new` failed or the new process produced no endpoint.
63    SpawnFailed(String),
64}
65
66/// Run the daemon. This function blocks until shutdown is triggered.
67///
68/// # Errors
69/// Returns an error if the health port cannot be bound, `hyperd` fails to start,
70/// or the discovery file cannot be written.
71pub async fn run_daemon(config: DaemonConfig) -> Result<(), Box<dyn std::error::Error>> {
72    // Step 1: Bind health port (single-instance lock)
73    let listener = HealthListener::bind(config.port).map_err(|e| {
74        if e.kind() == std::io::ErrorKind::AddrInUse {
75            format!(
76                "Another hyperdb daemon is already running on port {}. \
77                 Use `hyperdb-mcp daemon status` to check or `hyperdb-mcp daemon stop` to stop it.",
78                config.port
79            )
80        } else {
81            format!("Failed to bind health port {}: {e}", config.port)
82        }
83    })?;
84    let bound_port = listener.port;
85    info!(port = bound_port, "daemon health listener bound");
86
87    // Step 2: Spawn hyperd with TCP transport
88    let hyper = HyperProcess::new(None, Some(&build_params()?))?;
89    let endpoint = hyper
90        .endpoint()
91        .ok_or("hyperd did not report an endpoint")?
92        .to_string();
93    info!(endpoint = %endpoint, "hyperd started");
94
95    // Step 3: Build DaemonInfo and write discovery file
96    let info = DaemonInfo {
97        pid: std::process::id(),
98        hyperd_endpoint: endpoint.clone(),
99        health_port: bound_port,
100        started_at: chrono::Utc::now().to_rfc3339(),
101        version: env!("CARGO_PKG_VERSION").to_string(),
102    };
103    discovery::write_discovery_file(&info)?;
104    info!(path = %discovery::discovery_file_path()?.display(), "discovery file written");
105
106    // Step 4: Build the shared state.
107    // - `info_arc` is shared with the health listener so STATUS reports the
108    //   current endpoint even after a restart.
109    // - `hyper_state` is owned by the monitor task; the listener never touches it.
110    let state = Arc::new(DaemonState::new());
111    let info_arc = Arc::new(Mutex::new(info));
112    let hyper_state = Arc::new(Mutex::new(HyperState {
113        hyper: Some(hyper),
114        restart_history: Vec::new(),
115    }));
116
117    // Step 5: Start health listener in a background thread
118    let health_state = Arc::clone(&state);
119    let health_info = Arc::clone(&info_arc);
120    let health_handle = std::thread::spawn(move || {
121        listener.run(health_state, health_info);
122    });
123
124    // Step 6: Run the three monitors concurrently. Whichever completes first
125    // triggers shutdown.
126    tokio::select! {
127        () = idle_monitor(Arc::clone(&state), config.idle_timeout) => {}
128        () = hyperd_monitor(Arc::clone(&state), Arc::clone(&hyper_state), Arc::clone(&info_arc)) => {}
129        () = shutdown_signal() => {
130            info!("received shutdown signal");
131        }
132    }
133    state.request_shutdown();
134
135    // Step 7: Graceful shutdown.
136    // `tokio::select!` already cancelled the monitor and idle-monitor futures
137    // when one branch completed, releasing their `hyper_state` Arc clones.
138    // When this function returns, the last Arc drops, which drops the inner
139    // `HyperState`, which drops the `HyperProcess`, which closes the callback
140    // connection and lets hyperd exit cleanly. We don't lock-and-clear here
141    // because that would gain nothing — the same drop happens via Arc refcount.
142    info!("shutting down daemon");
143    discovery::remove_discovery_file();
144    let _ = health_handle.join();
145    drop(hyper_state); // explicit ordering: drop after health-listener join
146
147    Ok(())
148}
149
150/// Outcome of a single rate-limit check on the restart-history vector.
151#[derive(Debug, PartialEq, Eq)]
152pub enum RestartAttempt {
153    /// The attempt is within the allowed budget; recorded.
154    Recorded,
155    /// `RESTART_LIMIT` attempts already happened in the current window.
156    LimitExceeded,
157}
158
159/// Prune restart-history entries older than `RESTART_WINDOW`, then either
160/// record `now` as a new attempt or report that the limit is already
161/// exceeded.
162///
163/// Pulled out as a standalone function so the rate-limit policy can be
164/// tested directly without spinning up a real daemon.
165pub fn try_record_restart_attempt(history: &mut Vec<Instant>, now: Instant) -> RestartAttempt {
166    history.retain(|t| now.duration_since(*t) < RESTART_WINDOW);
167    if history.len() >= RESTART_LIMIT {
168        return RestartAttempt::LimitExceeded;
169    }
170    history.push(now);
171    RestartAttempt::Recorded
172}
173
174/// Build the Parameters used for every hyperd spawn (initial start and restarts).
175fn build_params() -> std::io::Result<Parameters> {
176    let log_dir = discovery::state_dir()?.join("logs");
177    std::fs::create_dir_all(&log_dir)?;
178
179    let mut params = Parameters::new();
180    params.set("log_file_max_count", "2");
181    params.set("log_file_size_limit", "100M");
182    params.set("log_dir", log_dir.to_string_lossy().as_ref());
183    params.set_transport_mode(TransportMode::Tcp);
184    Ok(params)
185}
186
187/// Watches for idle timeout. Triggers shutdown when no activity for
188/// `idle_timeout`. Wakes every 10 seconds.
189async fn idle_monitor(state: Arc<DaemonState>, idle_timeout: Duration) {
190    loop {
191        tokio::time::sleep(Duration::from_secs(10)).await;
192        if state.should_shutdown() {
193            return;
194        }
195        if state.idle_duration() >= idle_timeout {
196            info!(
197                idle_secs = idle_timeout.as_secs(),
198                "idle timeout reached, shutting down"
199            );
200            return;
201        }
202    }
203}
204
205/// Watches hyperd's liveness. If hyperd has exited (or a client reported it as
206/// dead), restarts it and rewrites the discovery file. If restarts exceed the
207/// rate limit, returns and lets the main task initiate shutdown.
208async fn hyperd_monitor(
209    state: Arc<DaemonState>,
210    hyper_state: Arc<Mutex<HyperState>>,
211    info_arc: Arc<Mutex<DaemonInfo>>,
212) {
213    loop {
214        tokio::time::sleep(HYPERD_POLL_INTERVAL).await;
215        if state.should_shutdown() {
216            return;
217        }
218
219        // Check liveness and consume the restart-request flag *atomically* with
220        // the decision: we only swap-to-false when we're actually committing to
221        // act, so a flag set after this point survives to the next tick.
222        let needs_restart = {
223            let mut guard = hyper_state.lock().expect("HyperState mutex poisoned");
224            let process_dead = guard.hyper.as_mut().map_or(true, HyperProcess::has_exited);
225            // Only consume the flag when we're going to restart anyway, OR
226            // when the process is alive and we want to honor a client report.
227            if process_dead {
228                // Drain the flag so a stale post-death report doesn't cause a
229                // spurious double-restart on the next tick.
230                let _ = state.consume_restart_request();
231                true
232            } else {
233                state.consume_restart_request()
234            }
235        };
236
237        if !needs_restart {
238            continue;
239        }
240
241        match try_restart_hyperd(&hyper_state, &info_arc) {
242            Ok(new_endpoint) => {
243                info!(endpoint = %new_endpoint, "hyperd restarted");
244                // Drain any reports that landed *during* the restart — those
245                // clients were complaining about the now-replaced hyperd, not
246                // the freshly spawned one. Without this, the next tick would
247                // see an alive process + a stale flag and trigger a spurious
248                // double-restart.
249                let _ = state.consume_restart_request();
250            }
251            Err(RestartError::TooManyRestarts) => {
252                error!(
253                    limit = RESTART_LIMIT,
254                    window_secs = RESTART_WINDOW.as_secs(),
255                    "hyperd restart limit exceeded — daemon shutting down"
256                );
257                return;
258            }
259            Err(RestartError::SpawnFailed(e)) => {
260                warn!(error = %e, "hyperd spawn failed during restart; will retry on next tick");
261            }
262        }
263    }
264}
265
266/// Attempt one restart of hyperd. Drops the old process, spawns a new one,
267/// updates `DaemonInfo`, and rewrites the discovery file.
268///
269/// Every call (success or spawn-failure) consumes one slot from the rate-limit
270/// window — a broken hyperd binary should not spin forever.
271fn try_restart_hyperd(
272    hyper_state: &Mutex<HyperState>,
273    info_arc: &Mutex<DaemonInfo>,
274) -> Result<String, RestartError> {
275    let mut guard = hyper_state.lock().expect("HyperState mutex poisoned");
276
277    // Rate-limit check: prune-check-push.
278    if try_record_restart_attempt(&mut guard.restart_history, Instant::now())
279        == RestartAttempt::LimitExceeded
280    {
281        return Err(RestartError::TooManyRestarts);
282    }
283
284    // Drop the old hyperd. For an already-exited process this is near-instant;
285    // for a still-alive process, Drop waits up to ~5s for graceful shutdown.
286    guard.hyper = None;
287
288    // Spawn the replacement.
289    let params = build_params().map_err(|e| RestartError::SpawnFailed(e.to_string()))?;
290    let new_hyper = HyperProcess::new(None, Some(&params))
291        .map_err(|e| RestartError::SpawnFailed(e.to_string()))?;
292    let new_endpoint = new_hyper
293        .endpoint()
294        .ok_or_else(|| RestartError::SpawnFailed("hyperd did not report endpoint".into()))?
295        .to_string();
296
297    // Publish the new endpoint to STATUS readers and to the discovery file.
298    // We snapshot the updated DaemonInfo while holding info_arc's lock, then
299    // write the file outside the lock to keep the critical section small.
300    let snapshot = {
301        let mut info_guard = info_arc.lock().expect("DaemonInfo mutex poisoned");
302        info_guard.hyperd_endpoint.clone_from(&new_endpoint);
303        info_guard.clone()
304    };
305    discovery::write_discovery_file(&snapshot)
306        .map_err(|e| RestartError::SpawnFailed(format!("discovery write: {e}")))?;
307
308    guard.hyper = Some(new_hyper);
309    Ok(new_endpoint)
310}
311
312async fn shutdown_signal() {
313    let ctrl_c = signal::ctrl_c();
314
315    #[cfg(unix)]
316    {
317        let mut sigterm =
318            signal::unix::signal(signal::unix::SignalKind::terminate()).expect("sigterm handler");
319        tokio::select! {
320            _ = ctrl_c => {}
321            _ = sigterm.recv() => {}
322        }
323    }
324
325    #[cfg(not(unix))]
326    {
327        ctrl_c.await.ok();
328    }
329}