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 hyperd liveness
5//! and optional idle timeout, restarts hyperd if it dies.
6//!
7//! By default, the daemon never auto-shuts down due to inactivity (opt-in via
8//! `--idle-timeout` flag or `HYPERDB_DAEMON_IDLE_TIMEOUT` env var). When enabled,
9//! client HEARTBEAT commands reset the idle timer (see [`DaemonState`]).
10
11use 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/// Configuration for the daemon process.
24#[derive(Debug)]
25pub struct DaemonConfig {
26    pub port: u16,
27    /// Idle timeout duration. When `None`, the daemon never auto-shuts down due to
28    /// inactivity (default behavior). When `Some`, the daemon shuts down after the
29    /// specified duration without client activity.
30    pub idle_timeout: Option<Duration>,
31}
32
33impl DaemonConfig {
34    /// Construct a `DaemonConfig` from CLI args and environment variables.
35    ///
36    /// Idle-timeout resolution:
37    /// 1. If `idle_timeout_secs` is `Some`, use that value.
38    /// 2. Otherwise, if `HYPERDB_DAEMON_IDLE_TIMEOUT` env var is set and parseable, use it.
39    /// 3. Otherwise, `None` (never auto-shutdown).
40    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
53/// Restart-attempt rate limit: at most 3 attempts within this window.
54/// The 4th attempt within the window is rejected and triggers daemon shutdown.
55pub const RESTART_WINDOW: Duration = Duration::from_secs(60);
56pub const RESTART_LIMIT: usize = 3;
57
58/// Polling interval for the hyperd-liveness monitor.
59const HYPERD_POLL_INTERVAL: Duration = Duration::from_secs(5);
60
61/// State the monitor task mutates and the main task drops on shutdown.
62/// Holds the live `HyperProcess` and the rolling restart-attempt history.
63struct HyperState {
64    hyper: Option<HyperProcess>,
65    restart_history: Vec<Instant>,
66}
67
68#[derive(Debug)]
69enum RestartError {
70    /// More than `RESTART_LIMIT` restart attempts within `RESTART_WINDOW`.
71    TooManyRestarts,
72    /// `HyperProcess::new` failed or the new process produced no endpoint.
73    SpawnFailed(String),
74}
75
76/// Run the daemon. This function blocks until shutdown is triggered.
77///
78/// # Errors
79/// Returns an error if the health port cannot be bound, `hyperd` fails to start,
80/// or the discovery file cannot be written.
81pub async fn run_daemon(config: DaemonConfig) -> Result<(), Box<dyn std::error::Error>> {
82    // Step 1: Bind health port (single-instance lock)
83    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    // Step 2: Spawn hyperd with TCP transport
98    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    // Step 3: Build DaemonInfo and write discovery file
106    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    // Step 4: Build the shared state.
117    // - `info_arc` is shared with the health listener so STATUS reports the
118    //   current endpoint even after a restart.
119    // - `hyper_state` is owned by the monitor task; the listener never touches it.
120    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    // Step 5: Start health listener in a background thread
128    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    // Log idle-timeout status at startup
135    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    // Step 6: Run the three monitors concurrently. Whichever completes first
142    // triggers shutdown. If `idle_timeout` is `None`, the idle monitor never fires.
143    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    // Step 7: Graceful shutdown.
159    // `tokio::select!` already cancelled the monitor and idle-monitor futures
160    // when one branch completed, releasing their `hyper_state` Arc clones.
161    // When this function returns, the last Arc drops, which drops the inner
162    // `HyperState`, which drops the `HyperProcess`, which closes the callback
163    // connection and lets hyperd exit cleanly. We don't lock-and-clear here
164    // because that would gain nothing — the same drop happens via Arc refcount.
165    info!("shutting down daemon");
166    discovery::remove_discovery_file();
167    let _ = health_handle.join();
168    drop(hyper_state); // explicit ordering: drop after health-listener join
169
170    Ok(())
171}
172
173/// Outcome of a single rate-limit check on the restart-history vector.
174#[derive(Debug, PartialEq, Eq)]
175pub enum RestartAttempt {
176    /// The attempt is within the allowed budget; recorded.
177    Recorded,
178    /// `RESTART_LIMIT` attempts already happened in the current window.
179    LimitExceeded,
180}
181
182/// Prune restart-history entries older than `RESTART_WINDOW`, then either
183/// record `now` as a new attempt or report that the limit is already
184/// exceeded.
185///
186/// Pulled out as a standalone function so the rate-limit policy can be
187/// tested directly without spinning up a real daemon.
188pub 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
197/// Build the Parameters used for every hyperd spawn (initial start and restarts).
198fn 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
210/// Watches for idle timeout. Triggers shutdown when no activity for
211/// `idle_timeout`. Wakes every 10 seconds.
212async 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
228/// Watches hyperd's liveness. If hyperd has exited (or a client reported it as
229/// dead), restarts it and rewrites the discovery file. If restarts exceed the
230/// rate limit, returns and lets the main task initiate shutdown.
231async 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        // Check liveness and consume the restart-request flag *atomically* with
243        // the decision: we only swap-to-false when we're actually committing to
244        // act, so a flag set after this point survives to the next tick.
245        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            // Only consume the flag when we're going to restart anyway, OR
249            // when the process is alive and we want to honor a client report.
250            if process_dead {
251                // Drain the flag so a stale post-death report doesn't cause a
252                // spurious double-restart on the next tick.
253                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                // Drain any reports that landed *during* the restart — those
268                // clients were complaining about the now-replaced hyperd, not
269                // the freshly spawned one. Without this, the next tick would
270                // see an alive process + a stale flag and trigger a spurious
271                // double-restart.
272                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
289/// Attempt one restart of hyperd. Drops the old process, spawns a new one,
290/// updates `DaemonInfo`, and rewrites the discovery file.
291///
292/// Every call (success or spawn-failure) consumes one slot from the rate-limit
293/// window — a broken hyperd binary should not spin forever.
294fn 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    // Rate-limit check: prune-check-push.
301    if try_record_restart_attempt(&mut guard.restart_history, Instant::now())
302        == RestartAttempt::LimitExceeded
303    {
304        return Err(RestartError::TooManyRestarts);
305    }
306
307    // Drop the old hyperd. For an already-exited process this is near-instant;
308    // for a still-alive process, Drop waits up to ~5s for graceful shutdown.
309    guard.hyper = None;
310
311    // Spawn the replacement.
312    let params = build_params().map_err(|e| RestartError::SpawnFailed(e.to_string()))?;
313    let new_hyper = HyperProcess::new(None, Some(&params))
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    // Publish the new endpoint to STATUS readers and to the discovery file.
321    // We snapshot the updated DaemonInfo while holding info_arc's lock, then
322    // write the file outside the lock to keep the critical section small.
323    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}