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 wait_for_daemon()
44 }
45 ScanOutcome::AllOccupied => Err(io::Error::new(
46 io::ErrorKind::AddrInUse,
47 format!(
48 "no free hyperdb daemon port in {}..{}",
49 scan.base,
50 scan.base.saturating_add(scan.span)
51 ),
52 )),
53 }
54}
55
56/// Spawn `hyperdb-mcp daemon` as a fully detached background process.
57fn spawn_detached(port: u16) -> io::Result<()> {
58 let exe = std::env::current_exe()?;
59 let port_str = port.to_string();
60
61 let mut cmd = Command::new(&exe);
62 cmd.arg("daemon").arg("--port").arg(&port_str);
63
64 // Detach from parent: redirect stdio to null
65 cmd.stdin(std::process::Stdio::null());
66 cmd.stdout(std::process::Stdio::null());
67 cmd.stderr(std::process::Stdio::null());
68
69 // Platform-specific detach flags
70 #[cfg(unix)]
71 {
72 use std::os::unix::process::CommandExt;
73 // SAFETY: setsid() is async-signal-safe per POSIX. Called in pre_exec
74 // (between fork and exec) to create a new session so the daemon isn't
75 // killed when the parent terminal/process exits.
76 unsafe {
77 cmd.pre_exec(|| {
78 libc::setsid();
79 Ok(())
80 });
81 }
82 }
83
84 #[cfg(windows)]
85 {
86 use std::os::windows::process::CommandExt;
87 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
88 const DETACHED_PROCESS: u32 = 0x0000_0008;
89 cmd.creation_flags(CREATE_NO_WINDOW | DETACHED_PROCESS);
90 }
91
92 let child = cmd.spawn()?;
93 info!(pid = child.id(), "daemon process spawned");
94 Ok(())
95}
96
97/// Poll for the discovery file to appear (daemon is ready).
98fn wait_for_daemon() -> io::Result<DaemonInfo> {
99 let start = Instant::now();
100 loop {
101 if let Some(info) = discovery::discover() {
102 info!(endpoint = %info.hyperd_endpoint, "daemon is ready");
103 return Ok(info);
104 }
105
106 if start.elapsed() >= SPAWN_TIMEOUT {
107 return Err(io::Error::new(
108 io::ErrorKind::TimedOut,
109 format!(
110 "daemon did not become ready within {} seconds",
111 SPAWN_TIMEOUT.as_secs()
112 ),
113 ));
114 }
115
116 std::thread::sleep(POLL_INTERVAL);
117 }
118}
119
120/// Pure version comparison: returns `true` if the client should take over the daemon.
121/// Only returns `true` when both versions parse successfully AND client > daemon.
122/// Unparseable versions or equal/older client always return `false` (reuse daemon).
123pub fn client_should_take_over(client_ver: &str, daemon_ver: &str) -> bool {
124 let Ok(client) = semver::Version::parse(client_ver) else {
125 return false;
126 };
127 let Ok(daemon) = semver::Version::parse(daemon_ver) else {
128 return false;
129 };
130 client > daemon
131}
132
133/// Decide whether to reuse the running daemon or take it over with a newer version.
134/// If the client is newer, we send STOP to the old daemon, wait for it to release
135/// the port, then spawn a fresh daemon on the same port. Otherwise we reuse the
136/// existing daemon.
137///
138/// The `scan` argument is intentionally unused for *where* to respawn: a takeover
139/// always reuses the port the discovered daemon already holds (`info.health_port`),
140/// because that is the port guaranteed to free up when the old daemon stops. A
141/// mid-session change to `HYPERDB_DAEMON_PORT` (so the pinned `scan.base` differs
142/// from `info.health_port`) is not honored here — spawning on a *different* port
143/// would leave the old daemon alive and create two daemons rather than replace one.
144/// That edge case is pathological (operators don't repin a live daemon) and the
145/// daemon found via discovery is authoritative for its own port.
146fn maybe_take_over(info: DaemonInfo, _scan: PortScan) -> io::Result<DaemonInfo> {
147 let client_ver = crate::version::MCP_VERSION;
148
149 if !client_should_take_over(client_ver, &info.version) {
150 // Client is older or equal, or one/both versions failed to parse → reuse.
151 // Distinguish the two reasons so an unexpected unparseable daemon version
152 // (corrupt daemon.json, foreign writer) is visible when debugging.
153 let parse_failed = semver::Version::parse(client_ver).is_err()
154 || semver::Version::parse(&info.version).is_err();
155 debug!(
156 daemon_version = %info.version,
157 client_version = %client_ver,
158 port = info.health_port,
159 reason = if parse_failed { "version unparseable" } else { "client not newer" },
160 "reusing existing daemon"
161 );
162 return Ok(info);
163 }
164
165 // Client is newer — take over.
166 info!(
167 daemon_version = %info.version,
168 client_version = %client_ver,
169 port = info.health_port,
170 "newer MCP client taking over older daemon"
171 );
172
173 // Send STOP (best-effort; ignore error if daemon is already dying).
174 let _ = super::health::send_command(info.health_port, "STOP");
175
176 // Wait for the old daemon to release the port (confirmed by ping_identified returning None).
177 let deadline = Instant::now() + SPAWN_TIMEOUT;
178 while Instant::now() < deadline {
179 if super::health::ping_identified(
180 info.health_port,
181 Duration::from_millis(200),
182 Duration::from_millis(200),
183 )
184 .is_none()
185 {
186 // Port is free — spawn the new daemon on the same port.
187 //
188 // There is a benign TOCTOU window here: another client could also
189 // observe the freed port and spawn concurrently. That is safe by the
190 // same argument as the FreePort path — `spawn_detached` is
191 // fire-and-forget (it does not itself bind the port; the spawned
192 // daemon's `HealthListener::bind` is the real single-instance lock).
193 // The OS grants the bind to exactly one daemon; the loser exits at
194 // step 1 of `run_daemon` (before spawning hyperd or writing the
195 // discovery file), and `wait_for_daemon` (which polls `discover()`)
196 // converges on whichever daemon won. No duplicate daemon survives
197 // and no AddrInUse surfaces to the client.
198 //
199 // Defensive narrowing of that window: if a concurrent takeover has
200 // already published a fresh, identity-verified daemon on this port,
201 // adopt it instead of spawning — this avoids returning the stale
202 // `info` (old endpoint) we were carrying and skips a redundant spawn.
203 if let Some(fresh) = discovery::discover() {
204 if fresh.health_port == info.health_port {
205 return Ok(fresh);
206 }
207 }
208 spawn_detached(info.health_port)?;
209 return wait_for_daemon();
210 }
211 std::thread::sleep(POLL_INTERVAL);
212 }
213
214 // Old daemon didn't die within the deadline — log a warning and reuse it
215 // rather than fail the client.
216 warn!(
217 port = info.health_port,
218 timeout_secs = SPAWN_TIMEOUT.as_secs(),
219 "old daemon did not stop within timeout, reusing it"
220 );
221 Ok(info)
222}