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
15 changes: 8 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions hyperdb-api/src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1005,6 +1005,28 @@ impl HyperProcess {
}
}

/// Returns true if the hyperd child process has exited (or no child exists).
///
/// Uses [`std::process::Child::try_wait`] under the hood, which is correct
/// on both Unix and Windows. On Unix this also reaps any zombie state as a
/// side effect — a hyperd that has been SIGKILLed but not yet `wait()`ed
/// on by the parent will be observed as exited and cleaned up here.
///
/// Prefer this over [`Self::is_running`] when the caller owns the
/// `HyperProcess` mutably and needs an authoritative liveness signal.
/// `is_running` uses `kill -0` on Unix (which incorrectly reports zombies
/// as alive) and is a no-op on Windows.
pub fn has_exited(&mut self) -> bool {
match self.child.as_mut() {
Some(child) => match child.try_wait() {
Ok(Some(_status)) => true,
Ok(None) => false,
Err(_) => true,
},
None => true,
}
}

/// Shuts down the Hyper server gracefully with a timeout.
///
/// This closes the callback connection, which signals Hyper to shut down gracefully.
Expand Down
60 changes: 60 additions & 0 deletions hyperdb-api/tests/process_lifecycle_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Tests for `HyperProcess` lifecycle helpers — specifically the `has_exited`
//! probe used by the hyperdb-mcp daemon's restart logic.

mod common;

use common::test_hyper_params;
use hyperdb_api::HyperProcess;

/// A freshly-spawned hyperd should not appear exited.
#[test]
fn has_exited_returns_false_for_running_hyperd() {
let params = test_hyper_params("has_exited_running").unwrap();
let mut hyper = HyperProcess::new(None, Some(&params)).unwrap();
assert!(
!hyper.has_exited(),
"freshly-spawned hyperd should be running"
);
}

/// After SIGKILL, `has_exited` must observe the child as exited.
/// This is the path the daemon's monitor relies on to detect a dead hyperd.
/// Reaping the child as a side effect is also exercised here — without it,
/// the next `has_exited` call could return false on a zombie.
#[cfg(unix)]
#[test]
fn has_exited_returns_true_after_sigkill() {
use std::process::Command;
use std::time::Duration;

let params = test_hyper_params("has_exited_killed").unwrap();
let mut hyper = HyperProcess::new(None, Some(&params)).unwrap();
let pid = hyper.pid().expect("hyperd should have a pid");

// Kill the process directly. The HyperProcess `Drop` would also kill it,
// but we need to test detection while we still own the handle.
let status = Command::new("kill")
.args(["-9", &pid.to_string()])
.status()
.expect("kill -9 should succeed");
assert!(status.success(), "kill -9 returned non-zero");

// Give the OS a moment to reap the SIGKILL'd process and update its state.
// Up to 2 seconds in 50ms increments — under load CI may take longer than
// a tight loop expects, but normal latency is sub-100ms.
let mut detected = false;
for _ in 0..40 {
if hyper.has_exited() {
detected = true;
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
detected,
"has_exited should observe the killed child within 2s"
);
}
25 changes: 25 additions & 0 deletions hyperdb-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- **Single-instance `hyperd` daemon** — by default, all MCP clients now
share one `hyperd` process per user instead of each spawning their own.
Multiple AI clients (Claude Code, Cursor, VS Code Copilot, etc.) can
access the same persistent databases simultaneously with reduced
resource overhead. The daemon auto-spawns on first client connect and
shuts down after 30 minutes idle. Pass `--no-daemon` to opt out.
- New `daemon` subcommand: `hyperdb-mcp daemon status` / `daemon stop`.
- New environment variables: `HYPERDB_STATE_DIR`, `HYPERDB_DAEMON_PORT`,
`HYPERDB_DAEMON_IDLE_TIMEOUT`.
- Ephemeral databases now `DETACH DATABASE` before deletion on session
end — required on Windows where the OS enforces file locks on open
Hyper files.
- **Daemon-side `hyperd` restart on crash.** The daemon polls `hyperd`
every 5 seconds via `Child::try_wait()` and automatically restarts it
if the process has exited, atomically updating the discovery file
with the new endpoint. Clients reconnect transparently via the
existing `ConnectionLost` recovery path. New `REPORT_HYPERD_ERROR`
health-protocol command lets clients fast-path the signal when they
detect a dead hyperd before the daemon's polling tick. Restart
attempts are rate-limited to 3 per 60 seconds; exceeding the limit
triggers daemon shutdown so the user sees the failure clearly
rather than spinning silently.

## [0.1.1] - 2026-05-13

### Added
Expand Down
5 changes: 4 additions & 1 deletion hyperdb-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ path = "src/main.rs"
[dependencies]
hyperdb-api = { path = "../hyperdb-api", version = "0.1.1" }
rmcp = { version = "1.7", features = ["server", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "signal"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "signal", "time"] }
serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
clap = { version = "4", features = ["derive"] }
Expand All @@ -42,6 +42,9 @@ tokio-util = { version = "0.7", features = ["rt"] }
tempfile = { workspace = true }
sqlformat = "0.5.0"

[target.'cfg(unix)'.dependencies]
libc = "0.2"

[lints]
workspace = true

Expand Down
44 changes: 42 additions & 2 deletions hyperdb-mcp/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,11 +198,51 @@ Derived fields are merged into the serialized JSON so callers get a self-contain

## Workspace Modes Internals

- **Ephemeral** — `Engine::new(None)` creates a temp directory (`$TMPDIR/hyperdb-mcp-<pid>/`) with a `workspace.hyper` file. Cleaned up on process exit.
- **Ephemeral** — `Engine::new(None)` creates a temp directory (`$TMPDIR/hyperdb-mcp-<pid>/`) with a `workspace.hyper` file. Cleaned up on process exit. In daemon mode, `Engine::drop` issues `DETACH DATABASE` (releasing Hyper's file lock — required on Windows) before `remove_dir_all` deletes the temp directory.
- **Persistent** — `Engine::new(Some(path))` uses the caller-supplied path. Parent directories are created automatically. `~` is expanded via `$HOME` (no shell crate dependency).

Logs (both `hyperd` server logs and the MCP client log) land in the same directory as the workspace file. The `status` tool reports log paths so operators know where to look.

## Daemon Mode Internals

`Engine::new` defaults to *daemon mode* — it tries `daemon::spawn::ensure_daemon()` first, which discovers an existing daemon via `~/.hyperdb/daemon.json` (overridable via `HYPERDB_STATE_DIR`) or auto-spawns one as a detached background process. The Engine then connects via TCP (`Connection::connect(endpoint, …)`) without owning any `HyperProcess`.

Falls back to local mode (per-session `hyperd` via `HyperProcess::new`) when the daemon can't be reached, or always when `--no-daemon` is passed.

Cross-platform single-instance lock is the daemon's TCP health port — bind succeeds for exactly one process per user. Liveness is validated by the discovery flow before trusting the file: a stale `daemon.json` (daemon crashed) is detected and removed.

The daemon's main loop tracks idle time via `DaemonState::last_activity`. `HEARTBEAT` commands from active clients reset the timer; clients debounce these to once per 60 seconds in `HyperMcpServer::with_engine`. Idle timeout (default 30 min) triggers graceful shutdown: discovery file removed → `hyperd` dropped → health listener exits.

### hyperd liveness monitoring and restart

A second monitor task in `run.rs::hyperd_monitor` polls the owned `HyperProcess` every 5 seconds via `HyperProcess::has_exited` (a `Child::try_wait()` call — zero SQL, correct on Unix and Windows, reaps zombies as a side effect). When the process is detected dead — or when a client sends `REPORT_HYPERD_ERROR` to the health port via `daemon::health::report_hyperd_error_to_daemon` — the monitor enters `try_restart_hyperd`:

1. Prune the rolling restart-history vector to entries within `RESTART_WINDOW` (60s); reject if `RESTART_LIMIT` (3) attempts have already happened.
2. Drop the old `HyperProcess` (its `Drop` impl waits up to 5s for graceful shutdown — near-instant for an already-exited process).
3. Spawn a replacement via `HyperProcess::new` with the same parameters as initial startup.
4. Update the shared `Arc<Mutex<DaemonInfo>>` with the new endpoint (the health listener reads through the same Arc, so `STATUS` reports the current endpoint).
5. Atomically rewrite `daemon.json` via the existing temp-and-rename in `discovery::write_discovery_file`.

After a successful restart, the monitor drains the restart-request flag once more — any `REPORT_HYPERD_ERROR` that landed *during* the restart was complaining about the now-replaced hyperd, not the freshly spawned one, and would otherwise trigger a spurious double-restart.

When the rate limit is exceeded, the monitor returns; the main task observes that the `tokio::select!` branch completed, requests shutdown, and `hyperd_state` is dropped via Arc refcount as `run_daemon` returns.

### Client-side recovery

Existing engine recovery is unchanged: `HyperMcpServer::with_engine` drops the engine on `ConnectionLost` (server.rs around line 1085); the next tool call calls `Engine::new` → `try_daemon_mode` → re-reads `daemon.json` → connects to whatever endpoint is currently published. After a hyperd restart, the discovery file has the new endpoint, so reconnection happens transparently.

Two new code paths fire `report_hyperd_error_to_daemon` (best-effort, 200ms timeouts so the user-facing tool handler isn't stalled):

- After detecting `ConnectionLost` in `with_engine`.
- When `Connection::connect` to the daemon's advertised endpoint fails in `Engine::try_daemon_mode`.

### Known limitations

- **Hung-but-alive `hyperd`** (TCP listening, but unresponsive to queries) is NOT detected. The monitor's `try_wait()` returns `None` for a hung process; client tool calls hang on the read side without producing a `ConnectionLost` error. Operator recovery is `hyperdb-mcp daemon stop` followed by reconnect.
- **Watchers (background tasks holding `AsyncConnection` handles in `WatcherRegistry`)** do not currently auto-reconnect after a hyperd restart. They will go quiet until the user re-issues `watch_directory`.

See `src/daemon/{mod,discovery,health,run,spawn}.rs` for the full implementation.

---

## Known Tech Debt / Future Work
Expand All @@ -220,6 +260,6 @@ Logs (both `hyperd` server logs and the MCP client log) land in the same directo

## Forward-Looking Design Notes

Feature-level ideas that aren't bugs or tech debt (which are tracked above) — shared `hyperd` daemon, cross-database tools, catalog awareness for attached databases, `switch_workspace`, and cross-workspace data-movement fallbacks — now live in [ROADMAP.md](ROADMAP.md). That split keeps this file focused on the current codebase and how to work in it, while ROADMAP.md captures the "not built yet but worth thinking about" material.
Feature-level ideas that aren't bugs or tech debt (which are tracked above) — cross-database tools, catalog awareness for attached databases, `switch_workspace`, and cross-workspace data-movement fallbacks — now live in [ROADMAP.md](ROADMAP.md). That split keeps this file focused on the current codebase and how to work in it, while ROADMAP.md captures the "not built yet but worth thinking about" material.

---
75 changes: 72 additions & 3 deletions hyperdb-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ LLMs are powerful at reasoning but cannot natively crunch millions of rows. This
## Features

- **Zero setup** — `HyperProcess` auto-starts the Hyper server
- **Shared `hyperd` daemon** — one Hyper process per user, shared across all MCP clients (Claude Code, Cursor, VS Code, etc.) for reduced memory overhead and concurrent access to the same persistent databases
- **Any data in** — JSON, CSV, Parquet, Arrow IPC, Apache Iceberg; schema inferred or exact
- **SQL at scale** — thousands to billions of rows
- **Data out** — export to CSV, Parquet, Apache Iceberg, Arrow IPC, or `.hyper` (Tableau Desktop-ready)
Expand Down Expand Up @@ -135,7 +136,8 @@ For a **persistent workspace** (tables survive across sessions), add `"args"`:
```json
"args": ["--workspace", "/path/to/my-project.hyper"]
```
This is still **experimental** and will only work with only one session at a time since the Hyper database is locked by Hyper. Each session is isolated and has its own Hyper instance running. Future work will allow multiple sessions to share the same database but requires work to spin up a shared Hyper instance.

Multiple MCP clients can point at the **same** persistent workspace simultaneously — they all connect through the shared `hyperd` daemon and use Hyper's MVCC transaction isolation. See [Operating Modes](#operating-modes) below.

#### Claude Code / AI Suite

Expand All @@ -159,6 +161,59 @@ Any tool that supports the MCP stdio transport can use this server. Point it at

---

## Operating Modes

The server has two independent mode dimensions: **how the Hyper engine is run** and **where the database is stored**.

### Hyper engine

| Mode | Flag | Behavior |
|---|---|---|
| **Shared daemon** *(default)* | *(none)* | One `hyperd` process per user, shared across all MCP clients. The first client auto-spawns the daemon; subsequent clients discover and reuse it. Idle for 30 minutes → daemon shuts itself down; the next client spawns a fresh one. |
| **Private hyperd** | `--no-daemon` | Each MCP client spawns its own `hyperd` (legacy behavior, one per session). |

The shared daemon is the bigger win for users running multiple AI clients (Claude Code + Cursor + VS Code) — they all share one Hyper engine instead of spawning three.

### Database persistence

| Mode | Flag | Behavior |
|---|---|---|
| **Ephemeral** *(default)* | *(none)* | A temp `.hyper` file is created per session and deleted on exit (DETACH + delete in daemon mode, Windows-safe). |
| **Persistent** | `--workspace <PATH>` | Uses the supplied `.hyper` file; survives across sessions. Multiple clients can point at the same path simultaneously. |

The two dimensions are orthogonal — any combination works. With the default (shared daemon + ephemeral), every client gets its own scratch database living inside the same shared engine; with `--workspace` added, multiple clients can collaborate on the same persistent dataset.

### Daemon management

The daemon is normally invisible — it auto-spawns and idle-times-out on its own. For diagnostics:

```bash
hyperdb-mcp daemon status # Show running daemon (PID, endpoint, started_at, version)
hyperdb-mcp daemon stop # Gracefully shut down the daemon
hyperdb-mcp daemon # Run as a daemon explicitly (rarely needed)
```

State files live at `~/.hyperdb/` by default (override with `HYPERDB_STATE_DIR`).

### Recovery from hyperd crashes

The daemon polls `hyperd` every 5 seconds. If the process has exited (crashed, OOM, killed), the daemon spawns a replacement, atomically updates `~/.hyperdb/daemon.json` with the new endpoint, and continues serving clients. Clients see one failed tool call (the request that was in flight when hyperd died); the next tool call transparently reconnects to the new hyperd via the same recovery path used for normal connection drops.

If a client itself notices hyperd is unreachable before the next polling tick, it sends a fast-path `REPORT_HYPERD_ERROR` signal to the daemon so the restart kicks off without waiting for the timer.

If hyperd repeatedly fails to start (3 attempts within 60 seconds — e.g., misconfigured `HYPERD_PATH`, port exhaustion, broken binary), the daemon shuts itself down and removes the discovery file. The next MCP client to start up will then spawn a fresh daemon, surfacing any persistent failure clearly to the user rather than spinning silently.

**Known limitation:** if hyperd hangs (alive at the OS level but unresponsive to queries), the daemon's polling can't detect it and your tool call may stall indefinitely. The recovery path is `hyperdb-mcp daemon stop` followed by reconnecting from your MCP client.

### Other behavioral flags

| Flag | Behavior |
|---|---|
| `--read-only` | Disables `execute`, `load_data`, `load_file`, `watch_directory`, `save_query`, `delete_query`, and Hyper-format export. See [Read-Only Mode](#read-only-mode). |
| `--bare` | Skips MCP-managed auxiliary tables (`_table_catalog`); saved queries are kept in-memory only, even with `--workspace`. |

---

## MCP Tools

### One-Shot Tools
Expand Down Expand Up @@ -639,15 +694,29 @@ Full reference: [Data Cloud SQL Reference](https://developer.salesforce.com/docs
## CLI Reference

```
hyperdb-mcp [OPTIONS]
hyperdb-mcp [OPTIONS] [COMMAND]

Commands:
daemon Run as a background daemon managing a shared hyperd process

Options:
--workspace <PATH> Path to the `.hyper` workspace file for persistent mode (omit for ephemeral)
--read-only Disable mutating tools (execute, load_data, load_file, save_query, delete_query, watch_directory)
--bare Skip MCP-managed auxiliary tables (`_table_catalog`) and force saved queries into in-memory storage, even with --workspace
--no-daemon Disable the shared daemon and spawn a private hyperd (legacy per-session behavior)

Daemon subcommand:
hyperdb-mcp daemon Start the daemon (usually auto-spawned)
hyperdb-mcp daemon stop Gracefully stop the running daemon
hyperdb-mcp daemon status Show running daemon info
hyperdb-mcp daemon --port <PORT> Override the health/lock port (default 7484)
hyperdb-mcp daemon --idle-timeout <SECS> Override idle timeout (default 1800 = 30 min)

Environment:
HYPERD_PATH Path to hyperd binary (auto-detected if on PATH)
HYPERD_PATH Path to hyperd binary (auto-detected if on PATH)
HYPERDB_STATE_DIR Override daemon state directory (default ~/.hyperdb/)
HYPERDB_DAEMON_PORT Override daemon health/lock port (default 7484)
HYPERDB_DAEMON_IDLE_TIMEOUT Override daemon idle timeout in seconds (default 1800)
```

---
Expand Down
Loading
Loading