Skip to content

feat(mcp): connect the daemon to its engine over IPC - #304

Merged
StefanSteiner merged 3 commits into
tableau:mainfrom
StefanSteiner:feat/daemon-engine-ipc
Sep 7, 2026
Merged

feat(mcp): connect the daemon to its engine over IPC#304
StefanSteiner merged 3 commits into
tableau:mainfrom
StefanSteiner:feat/daemon-engine-ipc

Conversation

@StefanSteiner

@StefanSteiner StefanSteiner commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What & why

The single-instance MCP daemon now reaches its hyperd engine over a local IPC channel — a Unix domain socket on Unix/macOS, a named pipe on Windows — instead of a loopback TCP port. This is PR A of the daemon IPC transport plan.

Scope (deliberately narrow)

In scope — only the daemon's engine connection moves to IPC:

  • daemon/run.rs::build_params now sets TransportMode::Ipc.
  • On Unix it creates an owner-only (0700) socket directory under the state dir (~/.hyperdb/sockets) via the existing state_perms::ensure_owner_only_dir, and hands it to HyperProcess through the domain_socket_directory override so hyperd binds inside an already-locked directory — there is no window for a client-side chmod race. On Windows the named pipe carries hyperd's default owner-only DACL (we never set named_pipes_allow_all_users), so there is no directory to place.

Explicitly deferred to PR B (NOT touched here):

  • The health/control channel stays a loopback TcpListener; daemon.json keeps its numeric health_port.
  • Discovery is untouched — resolve_port_scan, probe_port, ProbeResult, the port scan, and port-0 handling all remain and still govern the health port.
  • The per-start callback "dead man's switch" TcpListener is untouched.

Clean break: no dual-transport and no TCP fallback for the engine connection.

Notable discovery (verified, not assumed)

The daemon previously published HyperProcess::endpoint() — the raw callback descriptor. For a Unix socket that string reconstructs the path as <dir>/domain/hyper (a tab.domain:// scheme artifact), but hyperd actually binds <dir>/hyper, so a client dialing the published string got ENOENT. Fix: publish connection_endpoint(), which carries the path a client can actually connect to (TCP and Windows named pipes were already consistent). That in turn required Engine::is_running()'s daemon-mode probe to connect over the endpoint's transport (UDS / named pipe / TCP) instead of TCP only.

Review fixes applied (adversarial review — "With fixes", no Critical)

  • Fix 1 (symmetric per-process IPC endpoint suffix). The default UDS socket directory had the same hyper-<pid> collision the Windows pipe name did — two concurrently-live IPC HyperProcess instances in one process collided (the sequential case only worked because Drop removed the per-pid dir first). Both now carry a monotonic per-process suffix (hyper-<pid>-<seq>) from one shared IPC_INSTANCE_SEQ counter. Drop's cleanup keys on starts_with("hyper-"), which still matches; the socket filename is always hyper, so nothing parses the directory basename. New unit test asserts two default_socket_dir() calls produce distinct, hyper--prefixed names. The hyperdb-api changelog bullet was rewritten to state the truth for both platforms.
  • Fix 2 (fast-fail pre-flight + load-bearing ordering comment). build_params now tries UnixStream::connect(<socket_dir>/hyper) before spawning; a successful connect means another hyperd is genuinely bound → return a clear AddrInUse error naming the path, instead of a 60 s callback-timeout. A refused/missing socket (stale file, dead owner) proceeds. This cannot wedge the crash-restart path: try_restart_hyperd reaps the SIGKILLed child (guard.hyper = None) before build_params, so the connect is refused and the respawn rebinds. That drop-before-respawn ordering is now documented as load-bearing.
  • Fix 3 (doc). probe_endpoint_alive's comment no longer claims "TCP connect"; it describes the three-transport connect.
  • Fix 4 (test safety). The socket-path lsof branch now uses -c hyperd so a client sharing the socket can't be the PID the test kills.
  • Fix 5 (PR B landmine). A comment at the socket-dir creation warns the basename must not start with hyper-, since HyperProcess::drop remove_dir_alls caller-supplied dirs whose basename starts_with("hyper-").

Known / deferred (noted, not fixed here)

  • M-3 — the Windows liveness probe returns a false-negative on ERROR_PIPE_BUSY. Bounded to the status JSON field; it cannot cause a spurious restart (restarts come from connection_lost, not this probe). Deferred.
  • M-4hyperdb-api's transport_type() reports "TCP" for a UDS connection (pre-existing; describe_endpoint is correct). Out of scope.
  • M-5 — a very deep HYPERDB_STATE_DIR could push the socket path past the sun_path 104/108-byte limit. Flagged as a PR B validation item; no validation added now.

Evidence it works over IPC

Verified locally against the pinned engine (0.0.26479):

  • Daemon spawns → publishes …/sockets/hyperstatus reports transport: "unix_domain_socket" → engine connects and runs CREATE/INSERT/SELECT over the socket.
  • The crash-restart trio (hyperd SIGKILLed → monitor restarts it → client reconnects to the re-bound socket path) passes 4/4 locally with the Fix 2 pre-flight in place (isolated + 3× parallel).
  • New owner-only socket-dir unit test proves 0700; new default_socket_dir uniqueness test guards Fix 1.

Gate (local, real numbers)

  • cargo fmt --all -- --check → exit 0
  • cargo clippy --workspace --all-targets --all-features -- -D warnings → exit 0
  • cargo test -p hyperdb-api633 passed, 0 failed (incl. the new default_socket_dir test)
  • cargo test -p hyperdb-mcp --no-fail-fast663 passed, 1 failed, 17 ignored — the single failure is smoke_demo_and_changelog_contract (see CI caveat)
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps → exit 0
  • npx markdownlint-cli2 → 0 issues in 68 files

CI caveat (both failures are pre-existing / main-side, not from this PR's fixes)

  • smoke_demo_and_changelog_contract fails on every platform. #307 ("roll over per-crate changelogs for 1.0.0-rc.3") moved the mcp ## [Unreleased] bullets under ## [1.0.0-rc.3] without updating this contract test, which still hard-codes rc.3-era tokens (doctor, bar_orientation, …) as required under ## [Unreleased]. main itself fails this test today (its test CI on b5fb14c has not run). Reproduced locally: dropping main's own hyperdb-mcp/CHANGELOG.md into the test fails identically. This PR keeps its new IPC bullet under ## [Unreleased] and leaves the rc.3 history intact; the token list is a main-side fix.
  • The IPC crash-restart tests (engine_recovers_after_hyperd_killed, hyperd_monitor_detects_killed_hyperd_and_restarts) time out on ubuntu-latest. They use a fixed UDS path + the daemon's ~5 s monitor tick within a tight 12 s budget, so a loaded CI runner occasionally misses the window (the third restart test, client_report_triggers_restart_after_kill, passes on the same run, and all three pass 4/4 locally). This timing fragility predates the five review fixes (already #[ignore]d on macOS for a related startup-timeout reason) and is unrelated to the Fix 2 pre-flight, which adds negligible latency and does not error on the restart path.

The Windows named-pipe path is not exercisable on the macOS dev host; it rests on the test (windows-latest) and clippy (windows-latest) CI legs.

Rebase note

Rebased onto current main tip b5fb14c (#307 — the rc.3 per-crate changelog rollover). The changelog resolution is take-both: main's rollover (empty ## [Unreleased] + populated ## [1.0.0-rc.3]) is preserved verbatim, and this PR's new entries are added under ## [Unreleased] only (### Fixed for hyperdb-api, ### Changed for hyperdb-mcp). No rc.3 history is rewritten; no MD024 duplicate headings.

@StefanSteiner
StefanSteiner force-pushed the feat/daemon-engine-ipc branch 2 times, most recently from 1ad38f1 to c1bd1c3 Compare September 7, 2026 05:23
The single-instance daemon now reaches its hyperd engine over a local
IPC channel — a Unix domain socket on Unix/macOS, a named pipe on
Windows — instead of a loopback TCP port.

Scope (PR A of the daemon IPC plan): only the daemon's *engine*
connection moves. The health/control channel and daemon discovery keep
their loopback TCP port (daemon.json still carries a numeric
health_port); collapsing discovery is deferred to PR B. The per-start
callback "dead man's switch" stays TCP. Clean break: no dual-transport
and no TCP fallback for the engine connection.

- build_params sets TransportMode::Ipc and, on Unix, creates an
  owner-only (0700) socket directory under the state dir via the existing
  state_perms::ensure_owner_only_dir, passed through the
  domain_socket_directory override so hyperd binds inside an
  already-locked directory (no client-side chmod race). On Windows the
  named pipe carries hyperd's default owner-only DACL.
- The daemon publishes connection_endpoint() rather than the raw
  endpoint() string: for a Unix socket the raw descriptor reconstructs a
  non-connectable "<dir>/domain/hyper" path (a tab.domain:// scheme
  artifact) while hyperd actually binds "<dir>/hyper".
- Engine::is_running()'s daemon-mode probe now connects over the
  endpoint's transport (UDS / named pipe / TCP) instead of TCP only.
- hyperdb-api: the Windows named-pipe name gains a monotonic per-process
  suffix so several HyperProcess instances in one process (a daemon
  restart, or the test harness) never reuse a pipe name and fail to bind.

Verified end-to-end: the daemon spawns, publishes a socket-path endpoint,
and a client connects and runs queries over it; the crash-restart and
idle-timeout integration tests pass over IPC.
…tableau#310)

find_hyperd_pid_for_endpoint located hyperd over a Unix domain socket with
`lsof -nP -t -c hyperd <path>`. lsof ORs separately-stated selectors unless
`-a` is given, so `-c hyperd <path>` matched *every* running hyperd process,
not just the one bound to <path>. Under the parallel test binary that OR
union made `.find(first)` return an unrelated concurrent test's hyperd; the
restart test then SIGKILLed the wrong process, its own hyperd stayed alive,
the daemon's liveness monitor correctly never restarted, and
wait_for_live_hyperd_after_kill timed out. This reproduced only on the loaded
ubuntu-latest runner (two of three restart tests, both attempts) and never in
the serial or macOS runs; the TCP path on main was immune because
`-iTCP:{port} -sTCP:LISTEN` already names a unique listener.

Add `-a` so the command-name and path selectors are ANDed and only the hyperd
actually bound to the socket path is returned. Test-only; no production or
public-API change.
@StefanSteiner
StefanSteiner merged commit bcd88fd into tableau:main Sep 7, 2026
30 checks passed
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