hyperdb_mcp/daemon/
health.rs1use std::io::{BufRead, BufReader, Write};
20use std::net::{TcpListener, TcpStream};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use tracing::{debug, warn};
26
27use super::discovery::DaemonInfo;
28
29#[derive(Debug)]
31pub struct HealthListener {
32 listener: TcpListener,
33 pub port: u16,
34}
35
36#[derive(Debug)]
38pub struct DaemonState {
39 pub last_activity: Mutex<Instant>,
41 pub shutdown: AtomicBool,
43 pub restart_requested: AtomicBool,
46}
47
48impl Default for DaemonState {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54impl DaemonState {
55 pub fn new() -> Self {
56 Self {
57 last_activity: Mutex::new(Instant::now()),
58 shutdown: AtomicBool::new(false),
59 restart_requested: AtomicBool::new(false),
60 }
61 }
62
63 pub fn touch(&self) {
68 *self.last_activity.lock().expect("mutex poisoned") = Instant::now();
69 }
70
71 pub fn idle_duration(&self) -> Duration {
76 self.last_activity.lock().expect("mutex poisoned").elapsed()
77 }
78
79 pub fn request_shutdown(&self) {
80 self.shutdown.store(true, Ordering::Release);
81 }
82
83 pub fn should_shutdown(&self) -> bool {
84 self.shutdown.load(Ordering::Acquire)
85 }
86
87 pub fn request_restart(&self) {
89 self.restart_requested.store(true, Ordering::Release);
90 }
91
92 pub fn consume_restart_request(&self) -> bool {
95 self.restart_requested.swap(false, Ordering::AcqRel)
96 }
97}
98
99impl HealthListener {
100 pub fn bind(port: u16) -> std::io::Result<Self> {
106 let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
107 let listener = TcpListener::bind(addr)?;
108 listener.set_nonblocking(true)?;
109 let port = listener.local_addr()?.port();
110 Ok(Self { listener, port })
111 }
112
113 #[expect(
120 clippy::needless_pass_by_value,
121 reason = "Arcs are cloned into per-connection threads"
122 )]
123 pub fn run(self, state: Arc<DaemonState>, info: Arc<Mutex<DaemonInfo>>) {
124 loop {
125 if state.should_shutdown() {
126 break;
127 }
128
129 match self.listener.accept() {
130 Ok((stream, _addr)) => {
131 let state = Arc::clone(&state);
132 let info = Arc::clone(&info);
133 std::thread::spawn(move || {
134 handle_client(stream, &state, &info);
135 });
136 }
137 Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
138 std::thread::sleep(Duration::from_millis(100));
139 }
140 Err(e) => {
141 warn!(error = %e, "health listener accept error");
142 std::thread::sleep(Duration::from_millis(500));
143 }
144 }
145 }
146 debug!("health listener shut down");
147 }
148}
149
150#[expect(
151 clippy::needless_pass_by_value,
152 reason = "TcpStream must be owned for BufReader"
153)]
154fn handle_client(stream: TcpStream, state: &DaemonState, info: &Mutex<DaemonInfo>) {
155 let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
156 let mut reader = BufReader::new(&stream);
157 let mut writer = &stream;
158 let mut line = String::new();
159
160 loop {
161 line.clear();
162 match reader.read_line(&mut line) {
163 Ok(0) => break,
164 Ok(_) => {
165 let cmd = line.trim();
166 let response = match cmd {
167 "PING" => "PONG\n".to_string(),
168 "HEARTBEAT" => {
169 state.touch();
170 "OK\n".to_string()
171 }
172 "STOP" => {
173 state.request_shutdown();
174 "STOPPING\n".to_string()
175 }
176 "STATUS" => {
177 let snapshot = info.lock().expect("DaemonInfo mutex poisoned").clone();
179 let json = serde_json::to_string(&snapshot).unwrap_or_default();
180 format!("{json}\n")
181 }
182 "REPORT_HYPERD_ERROR" => {
183 state.request_restart();
184 "OK\n".to_string()
185 }
186 _ => "ERR unknown command\n".to_string(),
187 };
188 if writer.write_all(response.as_bytes()).is_err() {
189 break;
190 }
191 }
192 Err(_) => break,
193 }
194 }
195}
196
197pub fn send_command(port: u16, command: &str) -> std::io::Result<String> {
206 send_command_with_timeout(
207 port,
208 command,
209 Duration::from_secs(2),
210 Duration::from_secs(5),
211 )
212}
213
214pub fn report_hyperd_error_to_daemon() {
219 let port = super::discovery::resolve_port();
220 let timeout = Duration::from_millis(200);
221 match send_command_with_timeout(port, "REPORT_HYPERD_ERROR", timeout, timeout) {
222 Ok(response) => {
223 debug!(response = %response.trim(), "reported hyperd error to daemon");
224 }
225 Err(e) => {
226 debug!(error = %e, "could not report hyperd error to daemon (best-effort)");
227 }
228 }
229}
230
231pub fn send_command_with_timeout(
237 port: u16,
238 command: &str,
239 connect_timeout: Duration,
240 read_timeout: Duration,
241) -> std::io::Result<String> {
242 let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
243 let mut stream = TcpStream::connect_timeout(&addr, connect_timeout)?;
244 stream.set_read_timeout(Some(read_timeout))?;
245
246 let msg = format!("{command}\n");
247 stream.write_all(msg.as_bytes())?;
248 stream.flush()?;
249
250 let mut reader = BufReader::new(&stream);
251 let mut response = String::new();
252 reader.read_line(&mut response)?;
253 Ok(response)
254}