Skip to main content

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}