Skip to main content

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}