Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions hyperdb-mcp/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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")
Expand Down
12 changes: 12 additions & 0 deletions hyperdb-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,18 @@ async fn run_mcp_mode(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
);

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?;

Expand Down
39 changes: 32 additions & 7 deletions hyperdb-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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);
Expand Down
Loading