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}