Skip to content

feat: single-instance daemon for shared hyperd across MCP clients#26

Merged
StefanSteiner merged 3 commits into
tableau:mainfrom
StefanSteiner:ssteiner/single-instance-hyper
May 25, 2026
Merged

feat: single-instance daemon for shared hyperd across MCP clients#26
StefanSteiner merged 3 commits into
tableau:mainfrom
StefanSteiner:ssteiner/single-instance-hyper

Conversation

@StefanSteiner

@StefanSteiner StefanSteiner commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR turns hyperdb-mcp into a robust shared-engine architecture. Three commits land together:

  1. Single-instance daemon — one hyperd per user, shared across all MCP clients (Claude Code, Cursor, VS Code Copilot, etc.). Reduces memory overhead and lets multiple AI clients access the same persistent databases simultaneously.
  2. Documentation — README "Operating Modes" section, DEVELOPMENT.md daemon internals, ROADMAP.md cleanup.
  3. Crash recovery — daemon detects dead hyperd and restarts it automatically, with rate limiting to fail loudly when something is fundamentally broken.

Architecture

┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│ MCP Client 1 │  │ MCP Client 2 │  │ MCP Client 3 │
└──────┬───────┘  └──────┬───────┘  └──────┬───────┘
       └─────────────────┼─────────────────┘
                         │ libpq (TCP 127.0.0.1)
              ┌──────────┴──────────┐
              │   hyperdb daemon    │
              │  (single instance)  │
              └──────────┬──────────┘
              ┌──────────┴──────────┐
              │       hyperd        │
              └─────────────────────┘

Key design decisions:

  • TCP port binding as cross-platform single-instance lock (works identically on Windows/macOS/Linux)
  • Discovery file at ~/.hyperdb/daemon.json (overridable via HYPERDB_STATE_DIR)
  • Health protocol (PING/HEARTBEAT/STOP/STATUS/REPORT_HYPERD_ERROR) for liveness checks, idle tracking, and crash signaling
  • Idle timeout (30 min default) with debounced heartbeats from active clients
  • --no-daemon flag to opt out and use legacy per-client hyperd

Operating modes

Two independent dimensions:

Engine Persistence Behavior
Shared daemon (default) Ephemeral (default) All clients share one hyperd; each gets a temp .hyper deleted on session end
Shared daemon --workspace <path> All clients can collaborate on the same persistent .hyper file
--no-daemon Ephemeral Each client spawns its own hyperd (legacy)
--no-daemon --workspace <path> One client owns its own hyperd + persistent file

Crash recovery

The daemon polls hyperd every 5 seconds via Child::try_wait() (zero SQL traffic — no log noise). If hyperd has exited:

  1. Drop the old HyperProcess.
  2. Spawn a replacement with the same parameters.
  3. Atomically rewrite daemon.json with the new endpoint.
  4. Clients reconnect transparently via the existing ConnectionLost recovery path.

Clients can also signal REPORT_HYPERD_ERROR to fast-path the restart when they detect a dead endpoint before the daemon's polling tick.

Restart attempts are rate-limited to 3 per 60 seconds. The 4th attempt within the window triggers daemon shutdown so the user sees the failure clearly rather than spinning silently. The next MCP client to start will spawn a fresh daemon.

Known limitation: hung-but-alive hyperd (TCP listening but query-stuck) is not detected. Operator recovery is hyperdb-mcp daemon stop.

New CLI commands

hyperdb-mcp daemon              # Run as daemon (usually auto-spawned)
hyperdb-mcp daemon stop         # Gracefully shut down running daemon
hyperdb-mcp daemon status       # Show running daemon info
hyperdb-mcp --no-daemon         # Legacy mode: private hyperd per session

New environment variables:

  • HYPERDB_STATE_DIR — override ~/.hyperdb/
  • HYPERDB_DAEMON_PORT — override default port 7484
  • HYPERDB_DAEMON_IDLE_TIMEOUT — override 30-minute default

Test plan

37 new tests, all passing:

  • Daemon basics (26): DaemonState idle tracking + shutdown flag; discovery file write/read/overwrite/remove/stale-cleanup; health protocol (PING, HEARTBEAT, STOP, STATUS, unknown command, multi-command); single-instance lock (second bind fails with AddrInUse); idle timeout triggers shutdown / heartbeats prevent it; engine connects to shared daemon; two engines share one hyperd; persistent database file survives engine drop; ephemeral database cleaned up on drop (DETACH + delete).
  • Crash recovery (11): HyperProcess::has_exited correctness on Unix and after SIGKILL; DaemonState restart-flag round-trip; restart-history rate-limit math (records under limit, rejects 4th in window, prunes after window expires); REPORT_HYPERD_ERROR health command sets the flag; full integration — kill -9 hyperd → daemon restarts it → endpoint is reachable; client report triggers restart; end-to-end engine recovery — a fresh Engine::new after a hyperd kill successfully reconnects and reads data persisted before the kill.
  • All existing workspace tests pass unchanged
  • cargo clippy --workspace --tests and cargo fmt --check clean
  • Manual smoke: 7 MCP clients sharing 1 daemon-managed hyperd verified via ps aux

Adversarial review

Per request, both plan and code went through adversarial review. Findings caught and fixed before commit:

  • Plan phase: Windows is_running() was a no-op (always returned true); plan v1 would have shipped a Windows-broken feature. Fixed by switching to Child::try_wait() which is correct on both platforms. Also caught: alive-but-hung detection was overstated as covered (it isn't — documented as a known limitation).
  • Code phase: Spurious-double-restart race — if a client REPORT_HYPERD_ERROR arrived during a restart, the next monitor tick would consume the stale flag and restart the just-spawned healthy hyperd. Fixed by draining the flag after a successful restart.
  • Code phase: TestDaemon::Drop waited only 200ms — could leave hyperd running between tests under load. Fixed by polling the daemon's health port until unreachable, with a 10s timeout.

Commits

  • 178e40efeat: single-instance daemon for shared hyperd across MCP clients
  • fbccfa8docs: describe shared-daemon operating modes and CLI surface
  • 760575cfeat(daemon): detect and restart crashed hyperd

A lightweight daemon manages one shared hyperd process per user so
multiple AI clients (Claude Code, Cursor, VS Code, etc.) can access the
same persistent databases simultaneously with reduced resource overhead.

Architecture:
- TCP port binding as cross-platform single-instance lock
- Discovery file at ~/.hyperdb/daemon.json (overridable via HYPERDB_STATE_DIR)
- Health protocol (PING/HEARTBEAT/STOP/STATUS) for liveness and idle tracking
- Auto-spawn: MCP clients transparently start the daemon if none is running
- Idle timeout (default 30 min) with heartbeat-based keep-alive
- Ephemeral databases DETACH + delete on session end (Windows-safe)
- --no-daemon flag to opt out and use legacy per-client hyperd

New files:
- hyperdb-mcp/src/daemon/{mod,discovery,health,run,spawn}.rs
- hyperdb-mcp/tests/daemon_tests.rs (26 tests: unit + integration)
The single-instance daemon shipped in the previous commit but the docs
still framed persistent workspaces as "experimental, one session at a
time" and listed the shared daemon as future work.

- README: new "Operating Modes" section covering the two independent
  dimensions (engine: shared daemon vs --no-daemon; persistence:
  ephemeral vs --workspace); CLI Reference expanded with the daemon
  subcommand and HYPERDB_* env vars.
- DEVELOPMENT.md: new "Daemon Mode Internals" section.
- ROADMAP.md: drops the "Shared hyperd daemon" entry since it shipped.
- CHANGELOG.md: unreleased entry summarizing the feature.
Adds liveness monitoring and automatic restart for the daemon's hyperd
process. Previously, if hyperd crashed while the daemon kept running,
clients would silently fail to connect to a stale endpoint.

Detection:
- New HyperProcess::has_exited() uses Child::try_wait() — correct on
  both Unix and Windows, reaps zombies as a side effect. Replaces the
  Windows-broken kill -0 / always-true heuristic that is_running used.
- Daemon polls every 5 seconds.
- Clients fast-path the signal via a new REPORT_HYPERD_ERROR health
  command when they detect a dead endpoint before the polling tick.

Restart:
- try_restart_hyperd drops old hyperd, spawns replacement with same
  parameters, atomically rewrites daemon.json with the new endpoint.
- STATUS reads stay non-blocking — endpoint info is shared via
  Arc<Mutex<DaemonInfo>>, separate from the HyperProcess mutex.
- Rate-limited to 3 restarts per 60 seconds; the 4th attempt triggers
  daemon shutdown so the user sees the failure clearly.

Recovery:
- Existing ConnectionLost → drop-engine → re-discover path picks up
  the new endpoint transparently. No MCP tool-handler changes.

Tests:
- 11 new tests covering has_exited semantics, restart-history math
  (off-by-N enforcement), the REPORT_HYPERD_ERROR command, and
  end-to-end engine recovery after SIGKILL.
- Adversarial reviewers caught a Windows is_running bug in the plan
  phase and a spurious-double-restart race in the code phase; both
  fixed before this commit.

Known limitation: hung-but-alive hyperd (TCP listening, query-stuck)
is not detected — operator recovery is `hyperdb-mcp daemon stop`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant