diff --git a/hyperdb-mcp/src/engine.rs b/hyperdb-mcp/src/engine.rs index deb6ba1..a6cd0e4 100644 --- a/hyperdb-mcp/src/engine.rs +++ b/hyperdb-mcp/src/engine.rs @@ -426,13 +426,27 @@ impl Engine { })) } - /// Whether the backing `hyperd` process is still alive. - /// In daemon mode, checks the daemon health port. + /// Whether the backing `hyperd` is currently reachable. + /// + /// In local mode, delegates to the owned `HyperProcess`. In daemon mode, + /// probes the cached libpq `daemon_endpoint` directly with a short-timeout + /// TCP connect — the same endpoint queries run against. This reflects + /// *current* liveness of the resource the engine actually depends on, and + /// is robust to two failure modes the health-port PING is not: + /// - the health port being unreachable (stale `daemon.json`, + /// port-scan-adopted daemon, firewall) while the libpq endpoint serves; + /// - the daemon restarting `hyperd` on a new port, leaving the cached + /// endpoint stale (the probe then correctly reports `false`). + /// + /// Falls back to discovery (`daemon.json` + health-port PING) only when no + /// endpoint has been cached yet (before the first connection attempt). pub fn is_running(&self) -> bool { if let Some(ref hyper) = self.hyper { hyper.is_running() + } else if let Some(ref endpoint) = self.daemon_endpoint { + probe_endpoint_alive(endpoint) } else { - // Daemon mode: check if daemon is still reachable + // No cached endpoint yet — fall back to discovery. daemon::discovery::discover().is_some() } } @@ -1871,6 +1885,25 @@ impl Drop for Engine { } } +/// Cheap liveness probe for a daemon-mode `hyperd`: attempt a short-timeout +/// TCP connect to `endpoint` (`host:port`). Returns `true` if the connect +/// succeeds (something is listening). A bare connect is sufficient here — we +/// only need to know the port the engine's libpq connection targets is still +/// accepting connections, not to perform a full protocol round-trip. +fn probe_endpoint_alive(endpoint: &str) -> bool { + use std::net::ToSocketAddrs; + const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(300); + match endpoint.to_socket_addrs() { + // Probe each resolved address, short-circuiting on the first that + // accepts a connection. `daemon_endpoint` is normally a single + // `127.0.0.1:PORT`, so this is one connect in the common case. + Ok(mut addrs) => { + addrs.any(|addr| std::net::TcpStream::connect_timeout(&addr, PROBE_TIMEOUT).is_ok()) + } + Err(_) => false, + } +} + fn bootstrap_public_schema(connection: &Connection) -> Result<(), McpError> { connection .execute_command("CREATE SCHEMA IF NOT EXISTS public") diff --git a/hyperdb-mcp/src/main.rs b/hyperdb-mcp/src/main.rs index 4c80d1d..b2e9426 100644 --- a/hyperdb-mcp/src/main.rs +++ b/hyperdb-mcp/src/main.rs @@ -230,6 +230,18 @@ async fn run_mcp_mode(cli: Cli) -> Result<(), Box> { ); let server = HyperMcpServer::with_no_daemon(persistent_str, cli.read_only, cli.no_daemon); + // Eagerly initialize the engine before accepting tool calls so observer + // tools like `status` report full stats on the first call (issue #138). + // Errors are logged and swallowed inside `warm_up_engine` — startup + // proceeds even if hyperd is momentarily unreachable. Run on a blocking + // thread: warm-up does synchronous I/O (and may spawn the daemon) and + // would otherwise stall a runtime worker. Nothing else runs on the + // runtime yet (serve() is below), so this only delays startup. + let server = tokio::task::spawn_blocking(move || { + server.warm_up_engine(); + server + }) + .await?; let service = server.serve(rmcp::transport::io::stdio()).await?; service.waiting().await?; diff --git a/hyperdb-mcp/src/server.rs b/hyperdb-mcp/src/server.rs index c2478cb..5e35770 100644 --- a/hyperdb-mcp/src/server.rs +++ b/hyperdb-mcp/src/server.rs @@ -1122,6 +1122,30 @@ impl HyperMcpServer { Ok(guard) } + /// Eagerly initialize the engine at server startup, before any tool call + /// arrives. This makes read-only observer tools like `status` able to + /// report full stats on the very first call without having to trigger + /// initialization themselves — `status` stays a pure, non-blocking + /// observer (honoring issue #118). + /// + /// Errors are logged and swallowed rather than propagated: if `hyperd` is + /// unreachable at startup the server still comes up, and the first + /// data-plane tool call will retry initialization via `with_engine`. + pub fn warm_up_engine(&self) { + match self.ensure_engine() { + Ok(_guard) => { + tracing::info!("engine initialized eagerly at startup"); + } + Err(e) => { + tracing::warn!( + err = %e.message, + "eager engine initialization failed at startup; \ + will retry on first data-plane tool call" + ); + } + } + } + /// Idempotently create and reconcile `_table_catalog` on first call /// per engine. No-op in bare or read-only mode (read-only can't /// mutate; bare callers never wanted the catalog in the first place). @@ -2957,17 +2981,18 @@ impl HyperMcpServer { // operation on the same session (issue #118). If the engine lock is held // by another tool call, return a degraded-but-instant response with the // metadata available without the engine (daemon health, paths, watchers). + // + // `status` is a pure observer: it never initializes the engine. The + // engine is initialized eagerly at server startup (see + // `warm_up_engine`) and lazily by data-plane tools via `with_engine`, + // so by the time a client can call any tool the engine is normally + // already `Some`. If it is still `None` here (eager init failed because + // hyperd was down at startup, or a ConnectionLost just dropped it), we + // report the degraded response honestly rather than blocking to init. let Ok(guard) = self.engine.try_lock() else { - // Engine is locked by another tool call — return a degraded - // response rather than blocking. The caller sees `engine_busy: - // true` and knows table/disk stats are unavailable this call. return Self::ok_content(self.status_degraded()); }; let Some(engine) = guard.as_ref() else { - // Engine not yet initialized (first call after server start, or - // after a ConnectionLost drop). Return the degraded response rather - // than an error — the first data-plane call will init the engine, - // and subsequent `status` calls will get the full response. return Self::ok_content(self.status_degraded()); }; self.ensure_catalog_ready(engine);