hyperdb_mcp/daemon/spawn.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Spawn the daemon as a detached background process.
5//!
6//! When an MCP client starts and no daemon is running, it spawns one using the
7//! current binary with the `daemon` subcommand. The spawned process is fully
8//! detached so it outlives the parent MCP session.
9
10use std::io;
11use std::process::Command;
12use std::time::{Duration, Instant};
13
14use tracing::{debug, info, warn};
15
16use super::discovery::{self, DaemonInfo, PortScan, ScanOutcome};
17
18/// Maximum time to wait for the daemon to write its discovery file after spawning.
19const SPAWN_TIMEOUT: Duration = Duration::from_secs(10);
20
21/// Polling interval while waiting for the discovery file.
22const POLL_INTERVAL: Duration = Duration::from_millis(100);
23
24/// Ensure a daemon is running and return its info. If a daemon is found (via
25/// discovery file or port scan), we MAY take it over if the client is newer.
26/// Otherwise we spawn a fresh daemon on the first free port in the scan range.
27///
28/// # Errors
29/// Returns an error if no free port is available, the daemon cannot be spawned,
30/// or it does not become ready within the timeout period.
31pub fn ensure_daemon(scan: PortScan) -> io::Result<DaemonInfo> {
32 // Check discovery file first (fast path).
33 if let Some(info) = discovery::discover() {
34 return maybe_take_over(info, scan);
35 }
36
37 // Scan the port range for a running daemon or a free port.
38 match discovery::scan_for_daemon(scan) {
39 ScanOutcome::Found(info) => maybe_take_over(*info, scan),
40 ScanOutcome::FreePort(port) => {
41 info!(port, "no running daemon detected, spawning on free port");
42 spawn_detached(port)?;
43 let info = wait_for_daemon()?;
44 // If the daemon we just spawned bound a port above the scan base (because
45 // concurrent clients raced and one of them grabbed the base port first),
46 // prefer the lower-port daemon so we don't accumulate redundant
47 // daemon+hyperd pairs on adjacent ports. The lower-port daemon wins
48 // because it bound first and is the canonical single instance.
49 if info.health_port > scan.base {
50 let lower_scan = PortScan {
51 base: scan.base,
52 span: info.health_port.saturating_sub(scan.base),
53 };
54 if let ScanOutcome::Found(lower_info) = discovery::scan_for_daemon(lower_scan) {
55 debug!(
56 prefer_port = lower_info.health_port,
57 stop_port = info.health_port,
58 "found lower-port daemon from concurrent spawn; stopping off-base daemon"
59 );
60 // Best-effort STOP — if it fails the off-base daemon idles
61 // harmlessly (it has no clients and will only cost background CPU).
62 let _ = super::health::send_command(info.health_port, "STOP");
63 return Ok(*lower_info);
64 }
65 }
66 Ok(info)
67 }
68 ScanOutcome::AllOccupied => Err(io::Error::new(
69 io::ErrorKind::AddrInUse,
70 format!(
71 "no free hyperdb daemon port in {}..{}",
72 scan.base,
73 scan.base.saturating_add(scan.span)
74 ),
75 )),
76 }
77}
78
79/// Spawn `hyperdb-mcp daemon` as a fully detached background process.
80fn spawn_detached(port: u16) -> io::Result<()> {
81 let exe = std::env::current_exe()?;
82 let port_str = port.to_string();
83
84 let mut cmd = Command::new(&exe);
85 cmd.arg("daemon").arg("--port").arg(&port_str);
86
87 // Detach from parent: redirect stdio to null
88 cmd.stdin(std::process::Stdio::null());
89 cmd.stdout(std::process::Stdio::null());
90 cmd.stderr(std::process::Stdio::null());
91
92 // Platform-specific detach flags
93 #[cfg(unix)]
94 {
95 use std::os::unix::process::CommandExt;
96 // SAFETY: setsid() is async-signal-safe per POSIX. Called in pre_exec
97 // (between fork and exec) to create a new session so the daemon isn't
98 // killed when the parent terminal/process exits.
99 unsafe {
100 cmd.pre_exec(|| {
101 libc::setsid();
102 Ok(())
103 });
104 }
105 }
106
107 #[cfg(windows)]
108 {
109 use std::os::windows::process::CommandExt;
110 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
111 const DETACHED_PROCESS: u32 = 0x0000_0008;
112 cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
113 }
114
115 let child = cmd.spawn()?;
116 info!(pid = child.id(), "daemon process spawned");
117 Ok(())
118}
119
120/// Poll for the discovery file to appear (daemon is ready).
121fn wait_for_daemon() -> io::Result<DaemonInfo> {
122 let start = Instant::now();
123 loop {
124 if let Some(info) = discovery::discover() {
125 info!(endpoint = %info.hyperd_endpoint, "daemon is ready");
126 return Ok(info);
127 }
128
129 if start.elapsed() >= SPAWN_TIMEOUT {
130 return Err(io::Error::new(
131 io::ErrorKind::TimedOut,
132 format!(
133 "daemon did not become ready within {} seconds",
134 SPAWN_TIMEOUT.as_secs()
135 ),
136 ));
137 }
138
139 std::thread::sleep(POLL_INTERVAL);
140 }
141}
142
143/// Pure version comparison: returns `true` if the client should take over the daemon.
144/// Only returns `true` when both versions parse successfully AND client > daemon.
145/// Unparseable versions or equal/older client always return `false` (reuse daemon).
146pub fn client_should_take_over(client_ver: &str, daemon_ver: &str) -> bool {
147 let Ok(client) = semver::Version::parse(client_ver) else {
148 return false;
149 };
150 let Ok(daemon) = semver::Version::parse(daemon_ver) else {
151 return false;
152 };
153 client > daemon
154}
155
156/// Decide whether to reuse the running daemon or take it over with a newer version.
157/// If the client is newer, we send STOP to the old daemon, wait for it to release
158/// the port, then spawn a fresh daemon on the same port. Otherwise we reuse the
159/// existing daemon.
160///
161/// The `scan` argument is intentionally unused for *where* to respawn: a takeover
162/// always reuses the port the discovered daemon already holds (`info.health_port`),
163/// because that is the port guaranteed to free up when the old daemon stops. A
164/// mid-session change to `HYPERDB_DAEMON_PORT` (so the pinned `scan.base` differs
165/// from `info.health_port`) is not honored here — spawning on a *different* port
166/// would leave the old daemon alive and create two daemons rather than replace one.
167/// That edge case is pathological (operators don't repin a live daemon) and the
168/// daemon found via discovery is authoritative for its own port.
169fn maybe_take_over(info: DaemonInfo, _scan: PortScan) -> io::Result<DaemonInfo> {
170 let client_ver = crate::version::MCP_VERSION;
171
172 if !client_should_take_over(client_ver, &info.version) {
173 // Client is older or equal, or one/both versions failed to parse → reuse.
174 // Distinguish the two reasons so an unexpected unparseable daemon version
175 // (corrupt daemon.json, foreign writer) is visible when debugging.
176 let parse_failed = semver::Version::parse(client_ver).is_err()
177 || semver::Version::parse(&info.version).is_err();
178 debug!(
179 daemon_version = %info.version,
180 client_version = %client_ver,
181 port = info.health_port,
182 reason = if parse_failed { "version unparseable" } else { "client not newer" },
183 "reusing existing daemon"
184 );
185 return Ok(info);
186 }
187
188 // Client is newer — take over.
189 info!(
190 daemon_version = %info.version,
191 client_version = %client_ver,
192 port = info.health_port,
193 "newer MCP client taking over older daemon"
194 );
195
196 // Send STOP (best-effort; ignore error if daemon is already dying).
197 let _ = super::health::send_command(info.health_port, "STOP");
198
199 // Wait for the old daemon to release the port (confirmed by ping_identified returning None).
200 let deadline = Instant::now() + SPAWN_TIMEOUT;
201 while Instant::now() < deadline {
202 if super::health::ping_identified(
203 info.health_port,
204 Duration::from_millis(200),
205 Duration::from_millis(200),
206 )
207 .is_none()
208 {
209 // Port is free — spawn the new daemon on the same port.
210 //
211 // There is a benign TOCTOU window here: another client could also
212 // observe the freed port and spawn concurrently. That is safe by the
213 // same argument as the FreePort path — `spawn_detached` is
214 // fire-and-forget (it does not itself bind the port; the spawned
215 // daemon's `HealthListener::bind` is the real single-instance lock).
216 // The OS grants the bind to exactly one daemon; the loser exits at
217 // step 1 of `run_daemon` (before spawning hyperd or writing the
218 // discovery file), and `wait_for_daemon` (which polls `discover()`)
219 // converges on whichever daemon won. No duplicate daemon survives
220 // and no AddrInUse surfaces to the client.
221 //
222 // Defensive narrowing of that window: if a concurrent takeover has
223 // already published a fresh, identity-verified daemon on this port,
224 // adopt it instead of spawning — this avoids returning the stale
225 // `info` (old endpoint) we were carrying and skips a redundant spawn.
226 if let Some(fresh) = discovery::discover() {
227 if fresh.health_port == info.health_port {
228 return Ok(fresh);
229 }
230 }
231 spawn_detached(info.health_port)?;
232 return wait_for_daemon();
233 }
234 std::thread::sleep(POLL_INTERVAL);
235 }
236
237 // Old daemon didn't die within the deadline — log a warning and reuse it
238 // rather than fail the client.
239 warn!(
240 port = info.health_port,
241 timeout_secs = SPAWN_TIMEOUT.as_secs(),
242 "old daemon did not stop within timeout, reusing it"
243 );
244 Ok(info)
245}