hyperdb_mcp/daemon/discovery.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Discovery file management for the single-instance daemon.
5//!
6//! The daemon writes a JSON file to `~/.hyperdb/daemon.json` containing its
7//! PID and the `hyperd` endpoint. Clients read this file to locate the running
8//! daemon, validating liveness via a TCP health check before trusting it.
9
10use std::io;
11use std::path::PathBuf;
12use std::time::Duration;
13
14use serde::{Deserialize, Serialize};
15
16use super::{DAEMON_PORT_SCAN_SPAN, DEFAULT_DAEMON_BASE_PORT};
17
18/// Information written by the daemon so clients can discover and connect.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct DaemonInfo {
21 /// OS process ID of the daemon.
22 pub pid: u32,
23 /// The `hyperd` libpq endpoint clients should connect to (e.g. `127.0.0.1:54321`).
24 pub hyperd_endpoint: String,
25 /// The TCP port the daemon's health listener is bound to.
26 pub health_port: u16,
27 /// ISO-8601 timestamp when the daemon started.
28 pub started_at: String,
29 /// Version of the daemon binary.
30 pub version: String,
31}
32
33/// Returns the directory used for daemon state files.
34///
35/// Resolution order:
36/// 1. `HYPERDB_STATE_DIR` environment variable (if set)
37/// 2. `~/.hyperdb/` (where `~` is `HOME` on Unix, `USERPROFILE` on Windows)
38///
39/// # Errors
40/// Returns an error if neither the env var nor the home directory can be determined.
41pub fn state_dir() -> io::Result<PathBuf> {
42 if let Some(dir) = std::env::var_os("HYPERDB_STATE_DIR") {
43 return Ok(PathBuf::from(dir));
44 }
45 let home = home_dir().ok_or_else(|| {
46 io::Error::new(io::ErrorKind::NotFound, "cannot determine home directory")
47 })?;
48 Ok(home.join(".hyperdb"))
49}
50
51/// Returns the path to the discovery file.
52///
53/// # Errors
54/// Returns an error if the home directory cannot be determined.
55pub fn discovery_file_path() -> io::Result<PathBuf> {
56 Ok(state_dir()?.join("daemon.json"))
57}
58
59/// Write the discovery file atomically (write-to-temp then rename).
60///
61/// # Errors
62/// Returns an error if the state directory cannot be created or the file cannot be written.
63pub fn write_discovery_file(info: &DaemonInfo) -> io::Result<()> {
64 let dir = state_dir()?;
65 std::fs::create_dir_all(&dir)?;
66
67 let path = dir.join("daemon.json");
68 let tmp_path = dir.join("daemon.json.tmp");
69 let json = serde_json::to_string_pretty(info).map_err(|e| io::Error::other(e.to_string()))?;
70 std::fs::write(&tmp_path, json.as_bytes())?;
71 // On Windows, rename fails if target exists. Remove stale target first.
72 let _ = std::fs::remove_file(&path);
73 std::fs::rename(&tmp_path, &path)?;
74 Ok(())
75}
76
77/// Read the discovery file and validate that the daemon is still alive.
78/// Returns `None` if no daemon is running (file missing, stale, or unreachable).
79pub fn discover() -> Option<DaemonInfo> {
80 let path = discovery_file_path().ok()?;
81 let contents = std::fs::read_to_string(&path).ok()?;
82 let info: DaemonInfo = serde_json::from_str(&contents).ok()?;
83
84 // Validate liveness by connecting to the health port
85 if is_daemon_alive(info.health_port) {
86 Some(info)
87 } else {
88 // Stale file — daemon crashed. Clean up.
89 let _ = std::fs::remove_file(&path);
90 None
91 }
92}
93
94/// Remove the discovery file (called during graceful shutdown).
95pub fn remove_discovery_file() {
96 if let Ok(path) = discovery_file_path() {
97 let _ = std::fs::remove_file(&path);
98 }
99}
100
101/// Check if the daemon is alive by sending PING and verifying the identifying token.
102/// No longer accepts a bare TCP connect (prevents collisions with foreign services).
103fn is_daemon_alive(port: u16) -> bool {
104 super::health::ping_identified(port, Duration::from_millis(300), Duration::from_millis(300))
105 .is_some()
106}
107
108/// Port scan configuration: a base port and the number of ports to scan.
109/// When `span == 1`, the port is pinned (no scan). Used by the later
110/// port-scanning stage to discover or spawn a daemon across a range.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub struct PortScan {
113 pub base: u16,
114 pub span: u16,
115}
116
117/// Resolve the daemon health port scan configuration from environment or default.
118/// If `HYPERDB_DAEMON_PORT` is set and valid, returns a pinned scan (span=1) at
119/// that exact port. Otherwise, returns the default base port with the full scan span.
120pub fn resolve_port_scan() -> PortScan {
121 if let Some(port) = std::env::var(super::ENV_DAEMON_PORT)
122 .ok()
123 .and_then(|v| v.parse::<u16>().ok())
124 {
125 PortScan {
126 base: port,
127 span: 1,
128 }
129 } else {
130 PortScan {
131 base: DEFAULT_DAEMON_BASE_PORT,
132 span: DAEMON_PORT_SCAN_SPAN,
133 }
134 }
135}
136
137/// Resolve the daemon health port from environment or default. Back-compat
138/// wrapper for single-port callers; returns the base port from [`resolve_port_scan`].
139/// New code that needs scan-aware logic should call [`resolve_port_scan`] directly.
140pub fn resolve_port() -> u16 {
141 resolve_port_scan().base
142}
143
144/// Cross-platform home directory resolution.
145fn home_dir() -> Option<PathBuf> {
146 // Try HOME (Unix) then USERPROFILE (Windows)
147 std::env::var_os("HOME")
148 .or_else(|| std::env::var_os("USERPROFILE"))
149 .map(PathBuf::from)
150}
151
152/// Result of probing a single port: either our daemon, something else, or refused.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum ProbeResult {
155 /// A hyperdb-mcp daemon answered with valid STATUS.
156 OurDaemon(Box<DaemonInfo>),
157 /// The port accepted TCP but isn't our daemon (foreign service or broken STATUS).
158 Camped,
159 /// Connection refused (port is free).
160 Refused,
161}
162
163/// Probe a single port to determine if it's occupied by our daemon, a foreign service, or free.
164fn probe_port(port: u16) -> ProbeResult {
165 let ping_timeout = Duration::from_millis(300);
166
167 if let Some(_version) = super::health::ping_identified(port, ping_timeout, ping_timeout) {
168 // PING succeeded — something is answering with our token. Now send STATUS
169 // to retrieve the full daemon info. If STATUS fails we can't trust this
170 // process (might be a test stub or a broken daemon), so treat it as Camped.
171 match super::health::send_command_with_timeout(port, "STATUS", ping_timeout, ping_timeout) {
172 Ok(response) => {
173 if let Ok(info) = serde_json::from_str::<DaemonInfo>(response.trim()) {
174 ProbeResult::OurDaemon(Box::new(info))
175 } else {
176 // Parsed PING but STATUS is malformed — treat as Camped.
177 ProbeResult::Camped
178 }
179 }
180 Err(_) => ProbeResult::Camped,
181 }
182 } else {
183 // PING failed or returned no identifying token. Distinguish "refused"
184 // from "camped non-daemon" via a raw TCP connect attempt.
185 let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
186 match std::net::TcpStream::connect_timeout(&addr, ping_timeout) {
187 Ok(_) => ProbeResult::Camped, // TCP accepted but PING failed → foreign
188 Err(_) => ProbeResult::Refused, // Connection refused → port is free
189 }
190 }
191}
192
193/// The outcome of scanning a port range for a running daemon or a free port to spawn on.
194#[derive(Debug)]
195pub enum ScanOutcome {
196 /// Found a running hyperdb-mcp daemon.
197 Found(Box<DaemonInfo>),
198 /// No daemon found, but this port is free (can spawn here).
199 FreePort(u16),
200 /// All ports in the range are occupied (either by our daemon, foreign services, or both).
201 AllOccupied,
202}
203
204/// Scan the configured port range to find a running daemon or identify a free port.
205/// If any port in the range answers identified-PING and returns valid STATUS, we return
206/// `Found` immediately (first wins). Otherwise, we return `FreePort` with the first
207/// refused port encountered, or `AllOccupied` if everything is in use.
208///
209/// Product decision: prefer finding an existing daemon anywhere in range over
210/// spawning a new one. Only spawn if no daemon exists.
211pub fn scan_for_daemon(scan: PortScan) -> ScanOutcome {
212 let mut first_free: Option<u16> = None;
213
214 for offset in 0..scan.span {
215 let Some(port) = scan.base.checked_add(offset) else {
216 break; // Overflow guard: stop at u16::MAX
217 };
218
219 match probe_port(port) {
220 ProbeResult::OurDaemon(info) => {
221 // Found a running daemon — return immediately.
222 return ScanOutcome::Found(info);
223 }
224 ProbeResult::Refused => {
225 // Port is free. Remember the first one we see.
226 if first_free.is_none() {
227 first_free = Some(port);
228 }
229 }
230 ProbeResult::Camped => {
231 // Port is occupied by something else. Keep scanning.
232 }
233 }
234 }
235
236 // No daemon found. Return the first free port, or AllOccupied if none.
237 match first_free {
238 Some(port) => ScanOutcome::FreePort(port),
239 None => ScanOutcome::AllOccupied,
240 }
241}
242
243/// Discover a running daemon via the discovery file, or by scanning the configured
244/// port range. Returns `None` if no daemon is found in either place.
245///
246/// Used by CLI commands (status/stop) that want to find a daemon but not spawn one.
247pub fn find_running_daemon() -> Option<DaemonInfo> {
248 discover().or_else(|| match scan_for_daemon(resolve_port_scan()) {
249 ScanOutcome::Found(info) => Some(*info),
250 _ => None,
251 })
252}