Skip to main content

hyperdb_mcp/daemon/
health.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! TCP health listener for the daemon.
5//!
6//! The health listener serves two purposes:
7//! 1. **Single-instance lock** — binding the port guarantees at most one daemon per user.
8//! 2. **Liveness probe + heartbeat** — clients connect and send simple text commands.
9//!
10//! Protocol (line-based, newline-terminated):
11//! - `PING\n` → `PONG hyperdb-mcp <version>\n` (liveness check; the identifying
12//!   token proves it's a hyperdb-mcp daemon, not a foreign process on the same port)
13//! - `HEARTBEAT\n` → `OK\n` (resets idle timer)
14//! - `STOP\n` → `STOPPING\n` (triggers graceful shutdown)
15//! - `STATUS\n` → JSON line with daemon info (reports the *current* hyperd
16//!   endpoint, which can change after a restart).
17//! - `REPORT_HYPERD_ERROR\n` → `OK\n` (sets the restart-requested flag —
18//!   the monitor task picks it up on its next tick).
19
20use std::io::{BufRead, BufReader, Write};
21use std::net::{TcpListener, TcpStream};
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::{Arc, Mutex};
24use std::time::{Duration, Instant};
25
26use tracing::{debug, warn};
27
28use super::discovery::DaemonInfo;
29
30/// Identifying token included in PONG responses. Used to verify that a bound
31/// port is owned by a hyperdb-mcp daemon (not a foreign service).
32pub const PONG_TOKEN: &str = "hyperdb-mcp";
33
34/// Construct the PONG response with the identifying token and version.
35fn pong_response() -> String {
36    format!("PONG {PONG_TOKEN} {}\n", crate::version::MCP_VERSION)
37}
38
39/// Handle to the health listener, used to check binding success and manage lifecycle.
40#[derive(Debug)]
41pub struct HealthListener {
42    listener: TcpListener,
43    pub port: u16,
44}
45
46/// Shared state between the health listener and the daemon main loop.
47#[derive(Debug)]
48pub struct DaemonState {
49    /// Last time any client sent a heartbeat or query.
50    pub last_activity: Mutex<Instant>,
51    /// Signal to shut down the daemon.
52    pub shutdown: AtomicBool,
53    /// Set by clients reporting that hyperd looks dead from over there;
54    /// consumed by the daemon's restart monitor.
55    pub restart_requested: AtomicBool,
56}
57
58impl Default for DaemonState {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl DaemonState {
65    pub fn new() -> Self {
66        Self {
67            last_activity: Mutex::new(Instant::now()),
68            shutdown: AtomicBool::new(false),
69            restart_requested: AtomicBool::new(false),
70        }
71    }
72
73    /// Record activity (resets idle timer).
74    ///
75    /// # Panics
76    /// Panics if the internal mutex is poisoned.
77    pub fn touch(&self) {
78        *self.last_activity.lock().expect("mutex poisoned") = Instant::now();
79    }
80
81    /// Duration since the last activity.
82    ///
83    /// # Panics
84    /// Panics if the internal mutex is poisoned.
85    pub fn idle_duration(&self) -> Duration {
86        self.last_activity.lock().expect("mutex poisoned").elapsed()
87    }
88
89    pub fn request_shutdown(&self) {
90        self.shutdown.store(true, Ordering::Release);
91    }
92
93    pub fn should_shutdown(&self) -> bool {
94        self.shutdown.load(Ordering::Acquire)
95    }
96
97    /// Signal that hyperd appears to have died and a restart is needed.
98    pub fn request_restart(&self) {
99        self.restart_requested.store(true, Ordering::Release);
100    }
101
102    /// Atomically read-and-clear the restart-request flag.
103    /// Returns true if a restart was requested since the last call.
104    pub fn consume_restart_request(&self) -> bool {
105        self.restart_requested.swap(false, Ordering::AcqRel)
106    }
107}
108
109impl HealthListener {
110    /// Try to bind the health port.
111    ///
112    /// # Errors
113    /// Returns `Err` if the port is already in use (another daemon is running)
114    /// or the bind fails for another reason.
115    pub fn bind(port: u16) -> std::io::Result<Self> {
116        let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
117        let listener = TcpListener::bind(addr)?;
118        listener.set_nonblocking(true)?;
119        let port = listener.local_addr()?.port();
120        Ok(Self { listener, port })
121    }
122
123    /// Run the health listener loop. Spawns per-connection threads until shutdown.
124    /// Consumes `self` because this is intended to be called from a dedicated thread.
125    ///
126    /// `info` is shared (`Arc<Mutex<DaemonInfo>>`) so the listener reports the
127    /// *current* hyperd endpoint after a restart — the monitor task updates the
128    /// same Arc once a new hyperd is running.
129    #[expect(
130        clippy::needless_pass_by_value,
131        reason = "Arcs are cloned into per-connection threads"
132    )]
133    pub fn run(self, state: Arc<DaemonState>, info: Arc<Mutex<DaemonInfo>>) {
134        loop {
135            if state.should_shutdown() {
136                break;
137            }
138
139            match self.listener.accept() {
140                Ok((stream, _addr)) => {
141                    let state = Arc::clone(&state);
142                    let info = Arc::clone(&info);
143                    std::thread::spawn(move || {
144                        handle_client(stream, &state, &info);
145                    });
146                }
147                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
148                    std::thread::sleep(Duration::from_millis(100));
149                }
150                Err(e) => {
151                    warn!(error = %e, "health listener accept error");
152                    std::thread::sleep(Duration::from_millis(500));
153                }
154            }
155        }
156        debug!("health listener shut down");
157    }
158}
159
160#[expect(
161    clippy::needless_pass_by_value,
162    reason = "TcpStream must be owned for BufReader"
163)]
164fn handle_client(stream: TcpStream, state: &DaemonState, info: &Mutex<DaemonInfo>) {
165    let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
166    let mut reader = BufReader::new(&stream);
167    let mut writer = &stream;
168    let mut line = String::new();
169
170    loop {
171        line.clear();
172        match reader.read_line(&mut line) {
173            Ok(0) => break,
174            Ok(_) => {
175                let cmd = line.trim();
176                let response = match cmd {
177                    "PING" => pong_response(),
178                    "HEARTBEAT" => {
179                        state.touch();
180                        "OK\n".to_string()
181                    }
182                    "STOP" => {
183                        state.request_shutdown();
184                        "STOPPING\n".to_string()
185                    }
186                    "STATUS" => {
187                        // Brief lock — only to clone the current snapshot.
188                        let snapshot = info.lock().expect("DaemonInfo mutex poisoned").clone();
189                        let json = serde_json::to_string(&snapshot).unwrap_or_default();
190                        format!("{json}\n")
191                    }
192                    "REPORT_HYPERD_ERROR" => {
193                        state.request_restart();
194                        "OK\n".to_string()
195                    }
196                    _ => "ERR unknown command\n".to_string(),
197                };
198                if writer.write_all(response.as_bytes()).is_err() {
199                    break;
200                }
201            }
202            Err(_) => break,
203        }
204    }
205}
206
207/// Send a command to the daemon's health port and return the response.
208///
209/// Uses generous timeouts (2s connect, 5s read) suitable for `STOP`/`STATUS`
210/// where the caller is willing to wait. Use [`send_command_with_timeout`] for
211/// best-effort fire-and-forget calls (e.g. heartbeat, error reporting).
212///
213/// # Errors
214/// Returns an error if the connection fails or the response cannot be read.
215pub fn send_command(port: u16, command: &str) -> std::io::Result<String> {
216    send_command_with_timeout(
217        port,
218        command,
219        Duration::from_secs(2),
220        Duration::from_secs(5),
221    )
222}
223
224/// Best-effort fire-and-forget: tell the running daemon that hyperd appears to
225/// be dead from this client's perspective. Uses short timeouts (200ms each) so
226/// the calling tool handler isn't stalled if the daemon itself is slow.
227/// Errors are logged at debug level and otherwise ignored.
228pub fn report_hyperd_error_to_daemon() {
229    let port = super::discovery::resolve_port();
230    let timeout = Duration::from_millis(200);
231    match send_command_with_timeout(port, "REPORT_HYPERD_ERROR", timeout, timeout) {
232        Ok(response) => {
233            debug!(response = %response.trim(), "reported hyperd error to daemon");
234        }
235        Err(e) => {
236            debug!(error = %e, "could not report hyperd error to daemon (best-effort)");
237        }
238    }
239}
240
241/// Send a command with caller-specified connect/read timeouts.
242///
243/// # Errors
244/// Returns an error if the connection fails or the response cannot be read
245/// within the supplied timeouts.
246pub fn send_command_with_timeout(
247    port: u16,
248    command: &str,
249    connect_timeout: Duration,
250    read_timeout: Duration,
251) -> std::io::Result<String> {
252    let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
253    let mut stream = TcpStream::connect_timeout(&addr, connect_timeout)?;
254    stream.set_read_timeout(Some(read_timeout))?;
255
256    let msg = format!("{command}\n");
257    stream.write_all(msg.as_bytes())?;
258    stream.flush()?;
259
260    let mut reader = BufReader::new(&stream);
261    let mut response = String::new();
262    reader.read_line(&mut response)?;
263    Ok(response)
264}
265
266/// Send PING and verify the response contains the identifying token. Returns
267/// `Some(version)` if the responding daemon is a hyperdb-mcp daemon (the version
268/// string is the daemon's `MCP_VERSION`), or `None` if connection fails, the
269/// response lacks the expected token, or read times out. An empty version string
270/// (`Some(String::new())`) is returned if the PONG prefix matches but no version
271/// token is present (graceful degradation for forward/backward compat).
272///
273/// This is the primitive for liveness checks now that bare TCP connect is
274/// insufficient (a foreign service on the same port would cause collisions).
275pub fn ping_identified(
276    port: u16,
277    connect_timeout: Duration,
278    read_timeout: Duration,
279) -> Option<String> {
280    let response = send_command_with_timeout(port, "PING", connect_timeout, read_timeout).ok()?;
281    // Validate by exact tokens, not a string prefix: a prefix check on
282    // "PONG hyperdb-mcp" would also match a foreign reply like
283    // "PONG hyperdb-mcpEVIL 1.0.0". Require the first two whitespace-separated
284    // tokens to be exactly "PONG" and the token, so only our daemon passes.
285    let mut tokens = response.split_whitespace();
286    if tokens.next() != Some("PONG") || tokens.next() != Some(PONG_TOKEN) {
287        return None;
288    }
289    // The 3rd token is the daemon's version; absent ⇒ accept with empty
290    // version (future-proofing for a token-only reply).
291    Some(tokens.next().unwrap_or("").to_string())
292}