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            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}