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::net::TcpStream;
12use std::path::PathBuf;
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use super::DEFAULT_DAEMON_PORT;
18
19/// Information written by the daemon so clients can discover and connect.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct DaemonInfo {
22 /// OS process ID of the daemon.
23 pub pid: u32,
24 /// The `hyperd` libpq endpoint clients should connect to (e.g. `127.0.0.1:54321`).
25 pub hyperd_endpoint: String,
26 /// The TCP port the daemon's health listener is bound to.
27 pub health_port: u16,
28 /// ISO-8601 timestamp when the daemon started.
29 pub started_at: String,
30 /// Version of the daemon binary.
31 pub version: String,
32}
33
34/// Returns the directory used for daemon state files.
35///
36/// Resolution order:
37/// 1. `HYPERDB_STATE_DIR` environment variable (if set)
38/// 2. `~/.hyperdb/` (where `~` is `HOME` on Unix, `USERPROFILE` on Windows)
39///
40/// # Errors
41/// Returns an error if neither the env var nor the home directory can be determined.
42pub fn state_dir() -> io::Result<PathBuf> {
43 if let Some(dir) = std::env::var_os("HYPERDB_STATE_DIR") {
44 return Ok(PathBuf::from(dir));
45 }
46 let home = home_dir().ok_or_else(|| {
47 io::Error::new(io::ErrorKind::NotFound, "cannot determine home directory")
48 })?;
49 Ok(home.join(".hyperdb"))
50}
51
52/// Returns the path to the discovery file.
53///
54/// # Errors
55/// Returns an error if the home directory cannot be determined.
56pub fn discovery_file_path() -> io::Result<PathBuf> {
57 Ok(state_dir()?.join("daemon.json"))
58}
59
60/// Write the discovery file atomically (write-to-temp then rename).
61///
62/// # Errors
63/// Returns an error if the state directory cannot be created or the file cannot be written.
64pub fn write_discovery_file(info: &DaemonInfo) -> io::Result<()> {
65 let dir = state_dir()?;
66 std::fs::create_dir_all(&dir)?;
67
68 let path = dir.join("daemon.json");
69 let tmp_path = dir.join("daemon.json.tmp");
70 let json = serde_json::to_string_pretty(info).map_err(|e| io::Error::other(e.to_string()))?;
71 std::fs::write(&tmp_path, json.as_bytes())?;
72 // On Windows, rename fails if target exists. Remove stale target first.
73 let _ = std::fs::remove_file(&path);
74 std::fs::rename(&tmp_path, &path)?;
75 Ok(())
76}
77
78/// Read the discovery file and validate that the daemon is still alive.
79/// Returns `None` if no daemon is running (file missing, stale, or unreachable).
80pub fn discover() -> Option<DaemonInfo> {
81 let path = discovery_file_path().ok()?;
82 let contents = std::fs::read_to_string(&path).ok()?;
83 let info: DaemonInfo = serde_json::from_str(&contents).ok()?;
84
85 // Validate liveness by connecting to the health port
86 if is_daemon_alive(info.health_port) {
87 Some(info)
88 } else {
89 // Stale file — daemon crashed. Clean up.
90 let _ = std::fs::remove_file(&path);
91 None
92 }
93}
94
95/// Remove the discovery file (called during graceful shutdown).
96pub fn remove_discovery_file() {
97 if let Ok(path) = discovery_file_path() {
98 let _ = std::fs::remove_file(&path);
99 }
100}
101
102/// Check if the daemon is alive by attempting a TCP connection to its health port.
103fn is_daemon_alive(port: u16) -> bool {
104 TcpStream::connect_timeout(
105 &std::net::SocketAddr::from(([127, 0, 0, 1], port)),
106 Duration::from_secs(2),
107 )
108 .is_ok()
109}
110
111/// Resolve the daemon health port from environment or default.
112pub fn resolve_port() -> u16 {
113 std::env::var(super::ENV_DAEMON_PORT)
114 .ok()
115 .and_then(|v| v.parse().ok())
116 .unwrap_or(DEFAULT_DAEMON_PORT)
117}
118
119/// Cross-platform home directory resolution.
120fn home_dir() -> Option<PathBuf> {
121 // Try HOME (Unix) then USERPROFILE (Windows)
122 std::env::var_os("HOME")
123 .or_else(|| std::env::var_os("USERPROFILE"))
124 .map(PathBuf::from)
125}