khive_runtime/daemon.rs
1//! khived daemon server — persistent warm runtime over a Unix socket.
2//!
3//! The daemon binds `~/.khive/khived.sock`, accepts length-prefixed request
4//! frames, dispatches them through a [`DaemonDispatch`] implementor, and serves
5//! results back. It is transport-agnostic: the MCP crate provides the dispatch
6//! impl, but any future client (CLI, HTTP gateway) can reuse this server.
7//!
8//! The client side (forwarding, auto-spawn) lives in the transport crate
9//! (e.g. `khive-mcp`), not here.
10
11use std::sync::Arc;
12
13#[cfg(unix)]
14use std::io::Write as _;
15#[cfg(unix)]
16use std::os::unix::fs::PermissionsExt;
17#[cfg(unix)]
18use std::os::unix::io::AsRawFd;
19#[cfg(unix)]
20use std::path::PathBuf;
21
22#[cfg(unix)]
23use async_trait::async_trait;
24#[cfg(unix)]
25use libc;
26use serde::{Deserialize, Serialize};
27#[cfg(unix)]
28use tokio::io::{AsyncReadExt, AsyncWriteExt};
29#[cfg(unix)]
30use tokio::net::{UnixListener, UnixStream};
31
32#[cfg(unix)]
33use khive_db::{run_checkpoint_task, CheckpointConfig, ConnectionPool};
34
35#[cfg(unix)]
36use crate::pack::RequestIdentity;
37
38/// Maximum frame size accepted in either direction.
39pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
40
41/// Wire protocol version for the daemon IPC framing.
42///
43/// Increment this constant whenever the request or response frame shape
44/// changes in a backward-incompatible way. The client sends its version
45/// in every request; the daemon rejects mismatches with an explicit error
46/// that names both sides so the operator knows exactly what to do
47/// (`make local` rebuilds the client binary).
48/// See `docs/api/daemon.md#protocol_version` for the version-by-version history.
49pub const PROTOCOL_VERSION: u32 = 3;
50
51#[cfg(unix)]
52const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 10;
53
54// ── paths ─────────────────────────────────────────────────────────────────────
55
56#[cfg(unix)]
57fn khive_dir() -> PathBuf {
58 let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
59 PathBuf::from(home).join(".khive")
60}
61
62/// Unix socket path the daemon binds and clients connect to.
63///
64/// Overridable via the `KHIVE_SOCKET` env var (for tests and ops).
65#[cfg(unix)]
66pub fn socket_path() -> PathBuf {
67 if let Ok(p) = std::env::var("KHIVE_SOCKET") {
68 if !p.is_empty() {
69 return PathBuf::from(p);
70 }
71 }
72 khive_dir().join("khived.sock")
73}
74
75/// PID file path written by the daemon.
76///
77/// Overridable via the `KHIVE_PID` env var.
78#[cfg(unix)]
79pub fn pid_path() -> PathBuf {
80 if let Ok(p) = std::env::var("KHIVE_PID") {
81 if !p.is_empty() {
82 return PathBuf::from(p);
83 }
84 }
85 khive_dir().join("khived.pid")
86}
87
88/// Advisory lock file used to serialize stale-daemon recovery across concurrent
89/// clients (flock on the file; released when the lock file handle is dropped).
90///
91/// Overridable via the `KHIVE_LOCK` env var (for tests).
92#[cfg(unix)]
93pub fn lock_path() -> PathBuf {
94 if let Ok(p) = std::env::var("KHIVE_LOCK") {
95 if !p.is_empty() {
96 return PathBuf::from(p);
97 }
98 }
99 khive_dir().join("khived.recovery.lock")
100}
101
102/// Advisory lock file used to serialize RECOVERY (kill+respawn) attempts
103/// across concurrent clients only — the daemon's own boot sequence never
104/// acquires this file ([`lock_path`] / [`acquire_daemon_boot_guard`] is the
105/// boot-side lock). A recoverer holding this lock across dead-confirmation
106/// → kill → spawn (khive-mcp's `kill_and_respawn`) therefore can never
107/// deadlock against a peer daemon's boot, unlike holding the shared boot
108/// lock for that whole span would.
109///
110/// Overridable via the `KHIVE_RECOVERER_LOCK` env var (for tests).
111#[cfg(unix)]
112pub fn recoverer_lock_path() -> PathBuf {
113 if let Ok(p) = std::env::var("KHIVE_RECOVERER_LOCK") {
114 if !p.is_empty() {
115 return PathBuf::from(p);
116 }
117 }
118 khive_dir().join("khived.recoverer.lock")
119}
120
121#[cfg(unix)]
122fn open_lock_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
123 if let Some(parent) = path.parent() {
124 let _ = std::fs::create_dir_all(parent);
125 }
126 std::fs::OpenOptions::new()
127 .create(true)
128 .truncate(false)
129 .write(true)
130 .open(path)
131}
132
133#[cfg(unix)]
134fn acquire_flock_blocking(path: &std::path::Path, label: &str) -> Option<std::fs::File> {
135 let file = match open_lock_file(path) {
136 Ok(f) => f,
137 Err(e) => {
138 tracing::warn!(error = %e, path = ?path, "cannot open {label} lock file");
139 return None;
140 }
141 };
142 // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
143 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
144 if rc != 0 {
145 tracing::warn!("flock LOCK_EX failed on {label} lock");
146 return None;
147 }
148 Some(file)
149}
150
151/// Acquire an exclusive advisory flock on the recovery/startup lock file.
152///
153/// The returned `File` holds the lock for its lifetime; dropping it releases
154/// it. Used by both the client (serializing kill+spawn) and the daemon server
155/// (serializing cleanup+bind+pid-write) so the two critical sections are
156/// mutually exclusive across processes.
157#[cfg(unix)]
158pub fn acquire_recovery_lock() -> Option<std::fs::File> {
159 acquire_flock_blocking(&lock_path(), "recovery")
160}
161
162/// Attempt to acquire an exclusive advisory flock on `path`, retrying with a
163/// non-blocking `flock(LOCK_NB)` until `deadline` elapses. Bounded alternative
164/// to `acquire_recovery_lock`/`acquire_daemon_boot_guard`'s unbounded blocking
165/// flock — see `docs/api/daemon.md#try_acquire_flock_until` for why a caller
166/// merely detecting lock freedom needs a deadline instead.
167///
168/// - `Ok(Some(file))` — the lock was free within the deadline.
169/// - `Ok(None)` — `deadline` elapsed while the lock stayed held; an explicit
170/// "could not confirm" outcome, distinct from a hard I/O error.
171/// - `Err(_)` — the lock file could not be opened, or `flock` failed for a
172/// reason other than contention.
173///
174/// Blocking (paces retries with `std::thread::sleep`) — async callers must
175/// run this via `spawn_blocking`.
176#[cfg(unix)]
177fn try_acquire_flock_until(
178 path: &std::path::Path,
179 deadline: std::time::Instant,
180) -> std::io::Result<Option<std::fs::File>> {
181 let file = open_lock_file(path)?;
182 let poll_interval = std::time::Duration::from_millis(10);
183 loop {
184 // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
185 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
186 if rc == 0 {
187 return Ok(Some(file));
188 }
189 let err = std::io::Error::last_os_error();
190 if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
191 return Err(err);
192 }
193 let now = std::time::Instant::now();
194 if now >= deadline {
195 return Ok(None);
196 }
197 std::thread::sleep(poll_interval.min(deadline - now));
198 }
199}
200
201/// Bounded, deadline-aware variant of [`acquire_daemon_boot_guard`]: attempts
202/// the SAME boot/recovery lock ([`lock_path`]) but gives up at `deadline`
203/// instead of blocking forever. For callers that need to detect "is a boot in
204/// progress right now" without risking an unbounded wait behind a wedged
205/// holder: e.g. khive-mcp's `confirm_genuinely_dead` re-probing rounds,
206/// where `DEAD_CONFIRM_ROUNDS` must bound elapsed time, not just probe count.
207#[cfg(unix)]
208pub fn try_acquire_daemon_boot_guard_until(
209 deadline: std::time::Instant,
210) -> std::io::Result<Option<DaemonBootGuard>> {
211 try_acquire_flock_until(&lock_path(), deadline)
212}
213
214/// Bounded, deadline-aware acquisition of the recoverer-only lock
215/// ([`recoverer_lock_path`]). See [`try_acquire_daemon_boot_guard_until`] for
216/// the shared rationale — a second recoverer waiting for a peer's dead
217/// confirmation/kill/spawn critical section must give up and report
218/// "uncertain" rather than block forever if that peer is itself wedged.
219#[cfg(unix)]
220pub fn try_acquire_recoverer_lock_until(
221 deadline: std::time::Instant,
222) -> std::io::Result<Option<std::fs::File>> {
223 try_acquire_flock_until(&recoverer_lock_path(), deadline)
224}
225
226/// Guard returned by [`acquire_daemon_boot_guard`], held across cold-boot
227/// schema initialization (migrations + pack schema plans / FTS DDL) through
228/// daemon bind + pid-write.
229#[cfg(unix)]
230pub type DaemonBootGuard = std::fs::File;
231
232/// Acquire the recovery/boot lock, treating failure as fatal.
233///
234/// Unlike [`acquire_recovery_lock`] (best-effort, `None` on failure: used by
235/// shutdown cleanup, where skipping unlink is safer than blocking forever),
236/// daemon-mode boot must hold this lock across migrations/FTS DDL through
237/// bind+pid-write. Silently continuing with no lock reopens the cold-boot FTS
238/// race this guard exists to close, so callers that are about to run
239/// daemon-mode boot (or wait for one to quiesce) must fail loudly instead of
240/// proceeding unguarded.
241#[cfg(unix)]
242pub fn acquire_daemon_boot_guard() -> anyhow::Result<DaemonBootGuard> {
243 acquire_recovery_lock()
244 .ok_or_else(|| anyhow::anyhow!("failed to acquire daemon boot/recovery lock"))
245}
246
247/// Identity of a bound Unix socket path, used to tell "the socket I bound" apart
248/// from "a same-path socket some other daemon bound after mine was removed".
249///
250/// A socket path can be recreated by a different process between the time
251/// this daemon captures its identity and the time it later checks it, so `dev`
252/// and `ino` (not the path) are what must match for cleanup to be safe.
253#[cfg(unix)]
254#[derive(Clone, Copy, PartialEq, Eq)]
255struct SocketIdentity {
256 dev: u64,
257 ino: u64,
258}
259
260#[cfg(unix)]
261fn socket_identity(path: &std::path::Path) -> Option<SocketIdentity> {
262 use std::os::unix::fs::MetadataExt;
263 let meta = std::fs::metadata(path).ok()?;
264 Some(SocketIdentity {
265 dev: meta.dev(),
266 ino: meta.ino(),
267 })
268}
269
270// ── wire types ────────────────────────────────────────────────────────────────
271
272/// Request frame sent from a client to the daemon.
273#[derive(Serialize, Deserialize, Default)]
274pub struct DaemonRequestFrame {
275 pub ops: String,
276 pub presentation: Option<String>,
277 pub presentation_per_op: Option<Vec<Option<String>>>,
278 /// The client's resolved storage/gate default namespace for this request.
279 ///
280 /// As of protocol version 3 (ADR-096) the daemon serves the request under
281 /// this namespace instead of rejecting on mismatch: a per-request
282 /// identity input, not a same-process-identity assertion.
283 pub namespace: String,
284 /// The client's resolved write-stamp / gate actor identity (ADR-057),
285 /// carried on the frame so the warm daemon stamps writes with the
286 /// *caller's* actor instead of its own baked `actor_id` (ADR-096). `None`
287 /// mints `ActorRef::anonymous()`, matching an unconfigured actor.
288 #[serde(default)]
289 pub actor_id: Option<String>,
290 /// The client's resolved extra read-visibility namespaces (ADR-007 Rule
291 /// 3b), carried on the frame so the warm daemon widens read scope to
292 /// match the caller's own configuration rather than its own baked
293 /// `visible_namespaces` (ADR-096). Empty means no extra visibility beyond
294 /// `namespace` itself.
295 #[serde(default)]
296 pub visible_namespaces: Vec<String>,
297 /// Fingerprint of the client's engine-coherence config: packs, db target,
298 /// embedders, backend routing, and construction-baked outbound policy.
299 /// Identity fields are carried separately in this frame. The daemon rejects
300 /// a request whose `config_id` differs from its own so a restricted client
301 /// (e.g. `--pack kg`, `--db :memory:`) never dispatches through the broader
302 /// default daemon. See ADR-027 / ADR-049 / ADR-096.
303 #[serde(default)]
304 pub config_id: String,
305 /// IPC protocol version sent by the client. Pre-versioning clients omit
306 /// this field (deserializes to 0). The daemon compares against
307 /// [`PROTOCOL_VERSION`] and rejects mismatches with an explicit error.
308 #[serde(default)]
309 pub protocol_version: u32,
310 /// When `true`, the daemon returns an identity frame (ok=true, result=None)
311 /// immediately after identity validation — without calling the dispatcher.
312 /// Used by the client's under-lock recovery probe to confirm a daemon is
313 /// alive and identity-matching without dispatching any mutating verb.
314 /// Pre-probe clients omit this field (deserializes to false → normal dispatch).
315 #[serde(default)]
316 pub probe_only: bool,
317 /// When `true`, the daemon returns a point-in-time [`MetricsSnapshot`] of
318 /// its server-side gauges (a read-only measurement surface for the
319 /// load/perf harness) instead of dispatching any op. Handled before the
320 /// `config_id` equality reject: a gauge read is process-global and
321 /// namespace/config-agnostic, not a namespaced record operation.
322 /// READ-ONLY — this field is the only input the frame accepts for a
323 /// metrics request; there is no reset or mutation reachable over the
324 /// wire. Pre-metrics clients omit this field (deserializes to `false` →
325 /// normal dispatch, unaffected).
326 #[serde(default)]
327 pub metrics_only: bool,
328 /// Output format for this request (ADR-078). Forwarded to the daemon's
329 /// serialization seam. `None` means use the daemon's resolved default.
330 #[serde(default)]
331 pub format: Option<String>,
332 /// Per-operation output format overrides (ADR-078).
333 #[serde(default)]
334 pub format_per_op: Option<Vec<Option<String>>>,
335 /// Whether this request originated from the agent-facing MCP `request`
336 /// tool (the wire surface). When `true`, the daemon rejects
337 /// `Visibility::Subhandler` verbs: agents must not invoke internal
338 /// subhandlers. When `false` (the default, and the only value any
339 /// operator path sends), subhandlers are allowed: `kkernel exec` and
340 /// other in-process callers are trusted operator surfaces.
341 ///
342 /// This is the origin discriminator, not a daemon-vs-local one: operator
343 /// requests flow through the daemon by default too, so the gate cannot
344 /// key on transport.
345 #[serde(default)]
346 pub from_wire: bool,
347 /// Caller-supplied correlation id (khive#948): a `u64` from the caller's
348 /// own process-local monotonic counter, echoed back unchanged on
349 /// [`DaemonResponseFrame::request_id`] and stamped into the dispatch's
350 /// audit event (`resource.request_id`) so a benchmark harness can join
351 /// its own pre-send sample to the server-side audit row for the same
352 /// request. Purely additive — `#[serde(default)]` matches
353 /// `metrics_only`/`format`/`format_per_op` precedent, no
354 /// `PROTOCOL_VERSION` bump. `None` means the caller supplied no id.
355 #[serde(default)]
356 pub request_id: Option<u64>,
357}
358
359/// Response frame sent from the daemon back to a client.
360#[derive(Serialize, Deserialize, Debug)]
361pub struct DaemonResponseFrame {
362 pub ok: bool,
363 pub result: Option<String>,
364 pub error: Option<String>,
365 pub namespace_mismatch: bool,
366 /// Set when the request's `config_id` does not match the daemon's. Like
367 /// `namespace_mismatch`, this signals the client to fall back to local
368 /// dispatch rather than execute under a different runtime/config.
369 #[serde(default)]
370 pub config_mismatch: bool,
371 /// The `config_id` the daemon dispatched under, echoed back so the client
372 /// can positively confirm the result came from a matching runtime. A
373 /// pre-`config_id` daemon omits this field (deserializes to `None`), which
374 /// the client treats as a mismatch and falls back to local dispatch — this
375 /// closes the upgrade window where a new restricted client could otherwise
376 /// trust a still-warm legacy daemon's broader registry.
377 #[serde(default)]
378 pub served_config_id: Option<String>,
379 /// Set when the client's `protocol_version` does not match the daemon's
380 /// [`PROTOCOL_VERSION`]. The client must treat this as a hard error and
381 /// surface the human-readable `error` field rather than falling back to
382 /// local dispatch (which would hide the version skew).
383 #[serde(default)]
384 pub version_mismatch: bool,
385 /// The daemon's [`PROTOCOL_VERSION`], echoed in error responses so the
386 /// client can include both sides in the diagnostic message. Pre-versioning
387 /// daemons omit this field (deserializes to 0).
388 #[serde(default)]
389 pub daemon_protocol_version: u32,
390 /// Populated when the request set `metrics_only: true`: a point-in-time
391 /// snapshot of the daemon's server-side gauges. `None` on every other
392 /// response, and on any response from a daemon that predates this field
393 /// (client-side back-compat via `#[serde(default)]`, matching
394 /// `served_config_id`'s upgrade-window handling above).
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub metrics: Option<MetricsSnapshot>,
397 /// Echo of the request's `request_id` (khive#948), present whenever the
398 /// frame that produced this response carried one — including on every
399 /// error/denied arm, not only success, so a client can join a failure
400 /// the same way it joins a success. `#[serde(default)]` so an older
401 /// daemon's response (predating this field) deserializes to `None`
402 /// rather than a parse error.
403 #[serde(default)]
404 pub request_id: Option<u64>,
405}
406
407/// Point-in-time snapshot of the daemon's server-side gauges — the
408/// load/perf harness read-surface (measurement substrate, not a product feature).
409///
410/// Every field here is a **server-side** gauge reachable from `handle_conn`
411/// without any mutation: [`khive_storage::tx_registry`] (ADR-091 Plank 0,
412/// process-global singleton), the WAL checkpoint task's last-observed page
413/// count and TRUNCATE counters (`khive_db::checkpoint`), and the ADR-067
414/// Component A write queue depth (only when `KHIVE_WRITE_QUEUE=1`). There is
415/// no reset reachable through this type or through [`DaemonRequestFrame`] —
416/// gauges out, nothing in.
417#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
418pub struct MetricsSnapshot {
419 /// Last-observed WAL page count from the periodic checkpoint tick.
420 /// `None` when the checkpoint task has never ticked in this process
421 /// (for example, an in-memory dispatcher with no pool, or a daemon that
422 /// just started and hasn't hit its first tick yet).
423 pub wal_pages: Option<u64>,
424 /// Total WAL TRUNCATE escalation attempts (ADR-091 Plank 2) made in this
425 /// process's lifetime, regardless of whether they succeeded in reclaiming
426 /// pages.
427 pub wal_truncate_attempts: u64,
428 /// Current consecutive-failure count for TRUNCATE attempts that failed to
429 /// bring the WAL back below `warn_pages`; resets to 0 the next time an
430 /// attempt clears it.
431 pub wal_truncate_consecutive_failures: u64,
432 /// Total checkpoint ticks skipped because the writer mutex was busy
433 /// (ADR-091 checkpoint-pressure telemetry), across this process's
434 /// lifetime. `#[serde(default)]` so an older client decoding a newer
435 /// daemon's snapshot (or vice versa) does not fail.
436 #[serde(default)]
437 pub wal_checkpoint_skipped_ticks: u64,
438 /// Current consecutive-skip run length; 0 once the next tick is observed.
439 #[serde(default)]
440 pub wal_checkpoint_consecutive_skips: u64,
441 /// WAL page count last known at the time of the most recent skip, if any
442 /// skip has occurred yet in this process.
443 #[serde(default)]
444 pub wal_checkpoint_last_skip_wal_pages: Option<u64>,
445 /// Age, in microseconds, of the oldest currently-open transaction
446 /// registry entry (ADR-091 Plank 0). `None` when no transaction is
447 /// currently open.
448 pub oldest_pinned_tx_micros: Option<u64>,
449 /// Diagnostic label of the oldest currently-open transaction registry
450 /// entry, if any and if it was registered with one.
451 pub oldest_pinned_tx_label: Option<String>,
452 /// Number of currently open transaction registry entries.
453 pub open_tx_count: usize,
454 /// Current write-queue backlog depth (ADR-067 Component A): requests
455 /// enqueued but not yet accepted by the `WriterTask` drain loop. `None`
456 /// unless the write queue is enabled (`KHIVE_WRITE_QUEUE=1`) and a
457 /// file-backed pool is available.
458 pub write_queue_depth: Option<usize>,
459 /// The write queue's configured bounded capacity
460 /// (`PoolConfig::write_queue_capacity`), gated the same as
461 /// `write_queue_depth`.
462 pub write_queue_capacity: Option<usize>,
463}
464
465// ── framing ───────────────────────────────────────────────────────────────────
466
467/// Read one length-prefixed frame (4-byte BE u32 length + JSON bytes).
468#[cfg(unix)]
469pub async fn read_frame(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
470 let mut len_buf = [0u8; 4];
471 stream.read_exact(&mut len_buf).await?;
472 let len = u32::from_be_bytes(len_buf) as usize;
473 if len > MAX_FRAME_BYTES {
474 return Err(std::io::Error::new(
475 std::io::ErrorKind::InvalidData,
476 format!("daemon frame of {len} bytes exceeds {MAX_FRAME_BYTES} cap"),
477 ));
478 }
479 let mut buf = vec![0u8; len];
480 stream.read_exact(&mut buf).await?;
481 Ok(buf)
482}
483
484/// Write one length-prefixed frame.
485#[cfg(unix)]
486pub async fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> {
487 if payload.len() > MAX_FRAME_BYTES {
488 return Err(std::io::Error::new(
489 std::io::ErrorKind::InvalidData,
490 format!(
491 "daemon frame of {} bytes exceeds {MAX_FRAME_BYTES} cap",
492 payload.len()
493 ),
494 ));
495 }
496 let len = (payload.len() as u32).to_be_bytes();
497 stream.write_all(&len).await?;
498 stream.write_all(payload).await?;
499 stream.flush().await?;
500 Ok(())
501}
502
503// ── dispatch trait ────────────────────────────────────────────────────────────
504
505/// Transport-agnostic dispatch interface for the daemon server.
506///
507/// The MCP crate implements this by dispatching through the shared request body
508/// while honoring [`DaemonRequestFrame::from_wire`] (so subhandler visibility is
509/// gated by request origin, not by transport); any future transport can do the
510/// same.
511#[cfg(unix)]
512#[async_trait]
513pub trait DaemonDispatch: Clone + Send + Sync + 'static {
514 /// Dispatch a verb-DSL request string and return the rendered result.
515 ///
516 /// `from_wire` carries the origin discriminator from
517 /// [`DaemonRequestFrame::from_wire`]: when `true`, the implementor enforces
518 /// verb visibility (rejects `Visibility::Subhandler` verbs); when `false`,
519 /// the request is from a trusted operator surface and subhandlers pass.
520 ///
521 /// `identity` is the per-request identity context threaded from the frame
522 /// (ADR-096): `Some(..)` when serving a request forwarded over the
523 /// daemon socket (built from `frame.namespace` / `frame.actor_id` /
524 /// `frame.visible_namespaces` by the connection handler), `None` for any
525 /// other dispatch path. Implementors should mint the storage/gate token from
526 /// `identity` when present and fall back to their own construction-baked
527 /// identity when absent, so pure local (non-daemon) dispatch is unchanged.
528 #[allow(clippy::too_many_arguments)]
529 async fn dispatch(
530 &self,
531 ops: String,
532 presentation: Option<String>,
533 presentation_per_op: Option<Vec<Option<String>>>,
534 format: Option<String>,
535 format_per_op: Option<Vec<Option<String>>>,
536 from_wire: bool,
537 identity: Option<RequestIdentity>,
538 ) -> Result<String, String>;
539
540 /// Warm every pack's in-memory state (ANN indexes, etc.).
541 async fn warm_all(&self);
542
543 /// The namespace this dispatcher was configured for.
544 fn namespace(&self) -> &str;
545
546 /// Fingerprint of this dispatcher's resolved runtime config (packs, db
547 /// target, embedders). Used to reject forwarded requests from clients whose
548 /// config differs, so a restricted client cannot dispatch through a broader
549 /// daemon.
550 fn config_id(&self) -> &str;
551
552 /// Return the pool to use for background WAL checkpointing, if available.
553 ///
554 /// Implementors backed by a file-based SQLite database should return
555 /// `Some(pool_arc)`. In-memory or test dispatchers that have no pool
556 /// return `None` and the checkpoint task is not spawned.
557 ///
558 /// The default implementation returns `None`.
559 fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
560 None
561 }
562
563 /// Return the audit `EventStore` the checkpoint task should append
564 /// ADR-094 lifecycle events (`CheckpointOutcomeRecorded`) to, if any.
565 ///
566 /// Mirrors [`Self::pool_for_checkpoint`]'s default-`None` shape: an
567 /// implementor with no configured event store (or no pool at all) simply
568 /// gets a checkpoint task that never appends events — the checkpoint
569 /// task itself remains fully functional either way.
570 ///
571 /// The default implementation returns `None`.
572 fn event_store_for_checkpoint(&self) -> Option<Arc<dyn khive_storage::EventStore>> {
573 None
574 }
575}
576
577// ── tracked background tasks ─────────────────────────────────────────────────
578//
579// Pack handlers (e.g. memory.recall's ADR-081 serve-ledger append) fire
580// fire-and-forget `tokio::spawn`ed work off the response path so the caller
581// never waits on a cross-pack dispatch or a SQL write. Left untracked, that
582// work is invisible to `drain()`: a SIGTERM landing between the response
583// returning and the spawned task completing can abort it mid-flight with no
584// log and no row. `track_background_task` gives such spawns a process-wide
585// presence that `drain()` waits on, exactly like the `active` counter does
586// for in-flight connections: the caller still only pays for the spawn +
587// counter increment, never the task's own work.
588static BACKGROUND_TASKS: std::sync::OnceLock<Arc<std::sync::atomic::AtomicUsize>> =
589 std::sync::OnceLock::new();
590
591fn background_tasks() -> &'static Arc<std::sync::atomic::AtomicUsize> {
592 BACKGROUND_TASKS.get_or_init(|| Arc::new(std::sync::atomic::AtomicUsize::new(0)))
593}
594
595/// Decrements the shared background-task counter from `Drop`, so the count
596/// comes back down whether the tracked future returns normally, panics, or
597/// is cancelled — a plain post-`await` `fetch_sub` only covers the return
598/// path and leaks the count forever on a panic, since unwinding skips every
599/// statement after the panic point.
600struct BackgroundTaskGuard {
601 counter: Arc<std::sync::atomic::AtomicUsize>,
602}
603
604impl Drop for BackgroundTaskGuard {
605 fn drop(&mut self) {
606 self.counter
607 .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
608 }
609}
610
611/// Spawn a fire-and-forget background task that daemon shutdown's `drain()`
612/// waits for, instead of a bare `tokio::spawn` that a SIGTERM can abort
613/// mid-flight with no trace. Only the enqueue (an atomic increment) is
614/// synchronous on the caller's path — the future itself still runs fully
615/// off-path, unawaited. The decrement happens via `BackgroundTaskGuard`'s
616/// `Drop`, so a panic inside `fut` still restores the count.
617pub fn track_background_task<F>(fut: F)
618where
619 F: std::future::Future<Output = ()> + Send + 'static,
620{
621 background_tasks().fetch_add(1, std::sync::atomic::Ordering::Relaxed);
622 let guard = BackgroundTaskGuard {
623 counter: background_tasks().clone(),
624 };
625 tokio::spawn(async move {
626 let _guard = guard;
627 fut.await;
628 });
629}
630
631/// Current count of in-flight tasks started via [`track_background_task`].
632/// Exposed for tests; `drain()` reads the shared counter directly.
633pub fn background_task_count() -> usize {
634 background_tasks().load(std::sync::atomic::Ordering::Relaxed)
635}
636
637// ── active background phase names (ADR-103) ──────────────────────────────────
638//
639// A lightweight, best-effort process-wide gauge of which named background
640// phases (e.g. `ann_warm`) are in flight right now, read by `comm.health`'s
641// resource self-report so a caller can see "what is the daemon doing" at a
642// glance without correlating timestamps across the event log itself. Counted
643// per name rather than boolean, since more than one occurrence of the same
644// named phase can legitimately overlap (e.g. two embedding models warming
645// concurrently) — the name only drops out of the reported set once every
646// concurrent occurrence has ended.
647static ACTIVE_PHASES: std::sync::OnceLock<
648 std::sync::Mutex<std::collections::HashMap<String, usize>>,
649> = std::sync::OnceLock::new();
650
651fn active_phases() -> &'static std::sync::Mutex<std::collections::HashMap<String, usize>> {
652 ACTIVE_PHASES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
653}
654
655/// RAII guard for one occurrence of a named background phase. Increments the
656/// phase's count on creation (see [`register_active_phase`]); decrements on
657/// `Drop`, so the count comes back down whether the guarded work returns
658/// normally, panics, or is cancelled — the same rationale as
659/// `BackgroundTaskGuard` above.
660pub struct PhaseGuard {
661 name: String,
662}
663
664impl Drop for PhaseGuard {
665 fn drop(&mut self) {
666 let mut map = active_phases()
667 .lock()
668 .unwrap_or_else(std::sync::PoisonError::into_inner);
669 if let Some(count) = map.get_mut(&self.name) {
670 *count -= 1;
671 if *count == 0 {
672 map.remove(&self.name);
673 }
674 }
675 }
676}
677
678/// Register one occurrence of a named background phase as currently active.
679/// Returns a guard: drop it (or let it fall out of scope) when the phase
680/// ends. Best-effort process-wide gauge only, read by `comm.health` — never
681/// load-bearing for correctness.
682pub fn register_active_phase(name: &str) -> PhaseGuard {
683 let mut map = active_phases()
684 .lock()
685 .unwrap_or_else(std::sync::PoisonError::into_inner);
686 *map.entry(name.to_string()).or_insert(0) += 1;
687 PhaseGuard {
688 name: name.to_string(),
689 }
690}
691
692/// Currently active background-phase names, sorted for deterministic output.
693/// Empty when no tracked phase is in flight.
694pub fn active_phase_names() -> Vec<String> {
695 let map = active_phases()
696 .lock()
697 .unwrap_or_else(std::sync::PoisonError::into_inner);
698 let mut names: Vec<String> = map.keys().cloned().collect();
699 names.sort();
700 names
701}
702
703// ── server ────────────────────────────────────────────────────────────────────
704
705/// Build a point-in-time [`MetricsSnapshot`] of this process's server-side
706/// gauges. Called only from `handle_conn`'s `metrics_only` arm — a
707/// process-global, read-only assembly with no side effects of its own.
708/// See `docs/api/daemon.md#build_metrics_snapshot` for where each gauge is sourced
709/// from and why.
710#[cfg(unix)]
711fn build_metrics_snapshot<D: DaemonDispatch>(dispatcher: &D) -> MetricsSnapshot {
712 let open_tx_count = khive_storage::tx_registry::snapshot().len();
713 let (oldest_pinned_tx_micros, oldest_pinned_tx_label) =
714 match khive_storage::tx_registry::oldest() {
715 Some((_id, age, label)) => (Some(age.as_micros() as u64), label),
716 None => (None, None),
717 };
718
719 let (write_queue_depth, write_queue_capacity) = dispatcher
720 .pool_for_checkpoint()
721 .and_then(|pool| pool.writer_task_handle().ok().flatten())
722 .map(|handle| (Some(handle.queue_depth()), Some(handle.capacity())))
723 .unwrap_or((None, None));
724
725 MetricsSnapshot {
726 wal_pages: khive_db::checkpoint::last_observed_wal_pages(),
727 wal_truncate_attempts: khive_db::checkpoint::truncate_attempts(),
728 wal_truncate_consecutive_failures: khive_db::checkpoint::truncate_consecutive_failures(),
729 wal_checkpoint_skipped_ticks: khive_db::checkpoint::checkpoint_skipped_ticks(),
730 wal_checkpoint_consecutive_skips: khive_db::checkpoint::checkpoint_consecutive_skips(),
731 wal_checkpoint_last_skip_wal_pages: khive_db::checkpoint::checkpoint_last_skip_wal_pages(),
732 oldest_pinned_tx_micros,
733 oldest_pinned_tx_label,
734 open_tx_count,
735 write_queue_depth,
736 write_queue_capacity,
737 }
738}
739
740#[cfg(unix)]
741async fn handle_conn<D: DaemonDispatch>(mut stream: UnixStream, dispatcher: D) {
742 let raw = match read_frame(&mut stream).await {
743 Ok(r) => r,
744 Err(e) => {
745 tracing::debug!(error = %e, "failed to read daemon request frame");
746 return;
747 }
748 };
749 let frame: DaemonRequestFrame = match serde_json::from_slice(&raw) {
750 Ok(f) => f,
751 Err(e) => {
752 tracing::debug!(error = %e, "failed to decode daemon request frame");
753 return;
754 }
755 };
756
757 let served_config_id = Some(dispatcher.config_id().to_string());
758 let resp = if frame.protocol_version != PROTOCOL_VERSION {
759 let msg = format!(
760 "daemon protocol mismatch: client={} daemon={} — \
761 rebuild/update the client binary (make local)",
762 frame.protocol_version, PROTOCOL_VERSION,
763 );
764 tracing::warn!(
765 client_version = frame.protocol_version,
766 daemon_version = PROTOCOL_VERSION,
767 "daemon protocol version mismatch"
768 );
769 DaemonResponseFrame {
770 ok: false,
771 result: None,
772 error: Some(msg),
773 namespace_mismatch: false,
774 config_mismatch: false,
775 served_config_id,
776 version_mismatch: true,
777 daemon_protocol_version: PROTOCOL_VERSION,
778 metrics: None,
779 request_id: frame.request_id,
780 }
781 } else if frame.metrics_only {
782 // Process-global gauge read: namespace/config-agnostic, so this is
783 // handled BEFORE the `config_id` equality reject below (unlike every
784 // other arm) — a metrics probe must work regardless of which
785 // client's config is asking, since it never touches the dispatcher's
786 // packs/db/embed registry. READ-ONLY: builds a snapshot and returns
787 // immediately, never reaching the ops-dispatch arm.
788 DaemonResponseFrame {
789 ok: true,
790 result: None,
791 error: None,
792 namespace_mismatch: false,
793 config_mismatch: false,
794 served_config_id,
795 version_mismatch: false,
796 daemon_protocol_version: PROTOCOL_VERSION,
797 metrics: Some(build_metrics_snapshot(&dispatcher)),
798 request_id: frame.request_id,
799 }
800 // There is no `frame.namespace != dispatcher.namespace()` reject here.
801 // The daemon accepts and serves the request under the frame's own
802 // identity (namespace / actor / visible_namespaces, built into a
803 // `RequestIdentity` below) over its one shared warm registry, rather
804 // than rejecting a differently-attributed same-uid connection to a cold
805 // local-dispatch fallback. `config_id`: which governs packs/db/embed
806 // coherence for the shared warm engine: remains a hard reject; it is
807 // not an identity field and softening it would let a restricted client
808 // dispatch through an incompatible broader daemon.
809 } else if frame.config_id != dispatcher.config_id() {
810 DaemonResponseFrame {
811 ok: false,
812 result: None,
813 error: None,
814 namespace_mismatch: false,
815 config_mismatch: true,
816 served_config_id,
817 version_mismatch: false,
818 daemon_protocol_version: PROTOCOL_VERSION,
819 metrics: None,
820 request_id: frame.request_id,
821 }
822 } else if frame.probe_only {
823 // Probe-only request: identity checks passed; return immediately without
824 // dispatching any verb. The client uses this to confirm the daemon is
825 // alive and identity-matching without triggering any mutation.
826 DaemonResponseFrame {
827 ok: true,
828 result: None,
829 error: None,
830 namespace_mismatch: false,
831 config_mismatch: false,
832 served_config_id,
833 version_mismatch: false,
834 daemon_protocol_version: PROTOCOL_VERSION,
835 metrics: None,
836 request_id: frame.request_id,
837 }
838 } else {
839 // Build the per-request identity context from the frame so the
840 // implementor mints the storage/gate token from the CALLER's
841 // identity, not the dispatcher's own construction-baked scalars.
842 // This is always `Some` here: every frame that reaches this arm
843 // carries a `namespace` (required on the wire) plus whatever
844 // `actor_id`/`visible_namespaces` the client resolved (defaulting to
845 // `None`/`vec![]` for an older, field-absent payload, which is
846 // exactly the prior anonymous/no-extra-visibility behavior).
847 let identity = RequestIdentity {
848 namespace: frame.namespace.clone(),
849 actor_id: frame.actor_id.clone(),
850 visible_namespaces: frame.visible_namespaces.clone(),
851 request_id: frame.request_id,
852 };
853 match dispatcher
854 .dispatch(
855 frame.ops,
856 frame.presentation,
857 frame.presentation_per_op,
858 frame.format,
859 frame.format_per_op,
860 frame.from_wire,
861 Some(identity),
862 )
863 .await
864 {
865 Ok(result) => DaemonResponseFrame {
866 ok: true,
867 result: Some(result),
868 error: None,
869 namespace_mismatch: false,
870 config_mismatch: false,
871 served_config_id,
872 version_mismatch: false,
873 daemon_protocol_version: PROTOCOL_VERSION,
874 metrics: None,
875 request_id: frame.request_id,
876 },
877 Err(e) => DaemonResponseFrame {
878 ok: false,
879 result: None,
880 error: Some(e),
881 namespace_mismatch: false,
882 config_mismatch: false,
883 served_config_id,
884 version_mismatch: false,
885 daemon_protocol_version: PROTOCOL_VERSION,
886 metrics: None,
887 request_id: frame.request_id,
888 },
889 }
890 };
891
892 match serde_json::to_vec(&resp) {
893 Ok(payload) => {
894 if payload.len() > MAX_FRAME_BYTES {
895 // The serialized response exceeds the IPC frame cap. Send a
896 // small explicit error frame so the client can distinguish a
897 // per-request payload-size failure from a daemon crash. A
898 // client that receives this error frame will NOT trigger
899 // stale-daemon kill/respawn (ParseFailure requires a read_frame
900 // error, not an ok=false result).
901 tracing::warn!(
902 bytes = payload.len(),
903 limit = MAX_FRAME_BYTES,
904 "daemon response exceeds MAX_FRAME_BYTES; sending explicit error frame"
905 );
906 let err_resp = DaemonResponseFrame {
907 ok: false,
908 result: None,
909 error: Some(format!(
910 "response too large: {} bytes exceeds {} byte IPC cap",
911 payload.len(),
912 MAX_FRAME_BYTES,
913 )),
914 namespace_mismatch: false,
915 config_mismatch: false,
916 served_config_id: resp.served_config_id,
917 version_mismatch: false,
918 daemon_protocol_version: PROTOCOL_VERSION,
919 metrics: None,
920 request_id: resp.request_id,
921 };
922 if let Ok(err_payload) = serde_json::to_vec(&err_resp) {
923 if let Err(e) = write_frame(&mut stream, &err_payload).await {
924 tracing::debug!(error = %e, "failed to write oversized-response error frame");
925 }
926 }
927 } else if let Err(e) = write_frame(&mut stream, &payload).await {
928 tracing::debug!(error = %e, "failed to write daemon response frame");
929 }
930 }
931 Err(e) => tracing::warn!(error = %e, "failed to serialize daemon response frame"),
932 }
933}
934
935/// Run the daemon: bind the socket, warm in the background, serve request
936/// frames until SIGTERM/SIGINT.
937///
938/// Fatally acquires its own startup lock, which only protects
939/// cleanup→bind→pid-write — `dispatcher` has already run migrations and
940/// applied pack schema plans while constructing itself, unguarded. Production
941/// boot must go through [`run_daemon_with_boot_guard`] instead, which extends
942/// the same lock back over construction. This entry point is for callers
943/// (and tests) that build the dispatcher and start serving as one atomic
944/// step with no separate boot-guard window to protect.
945#[cfg(unix)]
946pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
947 let boot_guard = Some(acquire_daemon_boot_guard()?);
948 run_daemon_with_boot_guard(dispatcher, boot_guard).await
949}
950
951/// Run the daemon using a startup lock acquired by the caller *before*
952/// building `dispatcher`, so a second process racing to boot (e.g. two
953/// `kkernel mcp --daemon` spawns before either has bound its socket) cannot
954/// run migrations/FTS DDL concurrently against the same database file.
955/// `boot_guard` is only `None` on non-unix targets, where there is no
956/// advisory boot lock to hold in the first place; every unix daemon-mode
957/// caller passes `Some`.
958///
959/// The guard is held across cleanup → bind → pid-write, then dropped. The
960/// caller must not still be holding a *different* handle to the same lock
961/// file when this function is entered — see the "Deadlock note" on the
962/// `_startup_lock` binding below for why that would self-deadlock on `flock`.
963#[cfg(unix)]
964pub async fn run_daemon_with_boot_guard<D: DaemonDispatch>(
965 dispatcher: D,
966 boot_guard: Option<std::fs::File>,
967) -> anyhow::Result<()> {
968 let sock = socket_path();
969 let pid_file = pid_path();
970
971 if let Some(parent) = sock.parent() {
972 std::fs::create_dir_all(parent)?;
973 if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
974 tracing::warn!(error = %e, path = ?parent, "failed to chmod 0700 khive dir");
975 }
976 }
977
978 // Hold the startup lock across cleanup → bind → pid-write so a concurrent
979 // client's kill_and_respawn (which also holds this lock) cannot unlink the
980 // socket between our bind and our pid-write. The lock is released once the
981 // listener is bound and the PID file is written — at that point any racing
982 // client will find a live socket+pid and skip the stale-cleanup path.
983 //
984 // Deadlock note: the client holds this lock only during kill+spawn and
985 // releases it before the spawned daemon process starts (the lock guard is
986 // dropped when kill_and_respawn returns, before the readiness probe loop).
987 // The daemon holds exactly one handle to this lock for its whole boot
988 // sequence (received as `boot_guard`, extended from before `dispatcher`
989 // was constructed) — never a second, independently-acquired handle in the
990 // same process, which would self-deadlock on `flock`.
991 let _startup_lock = boot_guard;
992
993 if !cleanup_stale_daemon(&sock, &pid_file).await {
994 tracing::info!("a responsive khived is already running; exiting");
995 return Ok(());
996 }
997
998 let listener = UnixListener::bind(&sock)?;
999 if let Err(e) = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)) {
1000 tracing::warn!(error = %e, path = ?sock, "failed to chmod 0600 socket");
1001 }
1002 // Captured while still holding the startup lock, immediately after
1003 // bind, so shutdown cleanup can later prove "this is still the same socket
1004 // I bound" rather than trusting the path alone.
1005 let bound_identity = socket_identity(&sock);
1006
1007 if let Err(e) = write_pid_file_exclusive(&pid_file) {
1008 if e.kind() == std::io::ErrorKind::AlreadyExists {
1009 // A PID file appeared between our own `cleanup_stale_daemon`
1010 // removing it and this write — only possible if the boot lock did
1011 // not actually exclude a concurrent booter (e.g. `acquire_recovery_lock`
1012 // failed for one side). Never touch the winner's files: drop only
1013 // the socket entry we ourselves just bound (proven via identity,
1014 // not path), then decide by checking whether the PID now on disk
1015 // names a live, reachable daemon.
1016 if bound_identity.is_some() && socket_identity(&sock) == bound_identity {
1017 drop(listener);
1018 let _ = std::fs::remove_file(&sock);
1019 }
1020 if pid_file_names_a_reachable_daemon(&pid_file, &sock).await {
1021 tracing::info!(
1022 "a replacement khived already claimed the pid/socket rendezvous; exiting"
1023 );
1024 return Ok(());
1025 }
1026 anyhow::bail!(
1027 "failed to claim daemon pid file at {pid_file:?}: it already exists \
1028 and does not name a reachable daemon"
1029 );
1030 }
1031 return Err(e.into());
1032 }
1033 // Release the startup lock now: the listener is bound and the PID file is
1034 // written. Any concurrent client or daemon startup will observe a live
1035 // socket+pid and take the non-recovery path.
1036 drop(_startup_lock);
1037 tracing::info!(socket = ?sock, pid = std::process::id(), "khived listening");
1038
1039 {
1040 let warm = dispatcher.clone();
1041 tokio::spawn(async move {
1042 warm.warm_all().await;
1043 });
1044 }
1045
1046 // The checkpoint task's own strong-count-based exit is unreachable
1047 // whenever `event_store_for_checkpoint()` returns `Some` (the ordinary
1048 // production shape), because the `SqlEventStore` it wraps retains its
1049 // own clone of the same pool. An explicit watch channel replaces that
1050 // mechanism: the sender is held for the remainder of this function's
1051 // scope and signalled as the first action once shutdown is observed,
1052 // below.
1053 let (checkpoint_shutdown_tx, checkpoint_shutdown_rx) = tokio::sync::watch::channel(());
1054 if let Some(pool) = dispatcher.pool_for_checkpoint() {
1055 let cfg = CheckpointConfig::from_env();
1056 let event_store = dispatcher.event_store_for_checkpoint();
1057 let namespace = dispatcher.namespace().to_string();
1058 track_background_task(run_checkpoint_task(
1059 pool,
1060 cfg,
1061 event_store,
1062 namespace,
1063 checkpoint_shutdown_rx,
1064 ));
1065 tracing::info!("WAL checkpoint task started");
1066 }
1067
1068 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1069
1070 let shutdown = async {
1071 // REASON: signal handler registration can only fail if the global Tokio runtime
1072 // is not running or the OS rejects the signal number — both are unrecoverable
1073 // at this point in startup, so panic is the correct response.
1074 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1075 .expect("install SIGTERM handler");
1076 let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
1077 .expect("install SIGINT handler");
1078 tokio::select! {
1079 _ = sigterm.recv() => tracing::info!("received SIGTERM"),
1080 _ = sigint.recv() => tracing::info!("received SIGINT"),
1081 }
1082 };
1083
1084 tokio::select! {
1085 _ = async {
1086 loop {
1087 match listener.accept().await {
1088 Ok((stream, _)) => {
1089 let d = dispatcher.clone();
1090 let active = Arc::clone(&active);
1091 tokio::spawn(async move {
1092 active.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1093 handle_conn(stream, d).await;
1094 active.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
1095 });
1096 }
1097 Err(e) => tracing::error!(error = %e, "accept failed"),
1098 }
1099 }
1100 } => {}
1101 _ = shutdown => {}
1102 }
1103
1104 // Signal the checkpoint task to exit before draining, so `drain()`
1105 // actually waits on it via `track_background_task` rather than the
1106 // task outliving the drain window (or the process) unsignalled.
1107 let _ = checkpoint_shutdown_tx.send(());
1108
1109 drain(&active).await;
1110
1111 // A concurrent client's `kill_and_respawn` may have already decided
1112 // this daemon looked stale, killed it, and spawned a replacement that
1113 // bound the same socket/PID paths while this daemon was draining above.
1114 // Reacquire the recovery lock (the same one that serializes startup) and
1115 // only unlink if the PID file still names this process AND the socket at
1116 // `sock` is still the exact one this daemon bound — otherwise a
1117 // replacement daemon owns those paths now and unlinking would delete its
1118 // live socket/PID out from under it.
1119 match acquire_recovery_lock() {
1120 Some(_shutdown_lock) => {
1121 shutdown_cleanup_if_owned(&sock, &pid_file, bound_identity);
1122 }
1123 None => {
1124 tracing::warn!(
1125 "could not acquire recovery lock for shutdown cleanup; \
1126 skipping unlink to avoid deleting a replacement daemon's paths"
1127 );
1128 }
1129 }
1130 tracing::info!("khived stopped");
1131 Ok(())
1132}
1133
1134/// Remove `sock`/`pid_file` only if they still belong to this process: the PID
1135/// file must name `std::process::id()` AND the socket currently at `sock` must
1136/// still be the exact one identified by `bound_identity` (dev/ino, not path).
1137///
1138/// Returns `true` if cleanup ran, `false` if it was skipped because a
1139/// replacement daemon already owns those paths. The caller must hold
1140/// the recovery lock across this call — the same lock daemon startup holds
1141/// across cleanup+bind+pid-write — so no replacement can bind between this
1142/// function's checks and its unlinks.
1143#[cfg(unix)]
1144fn shutdown_cleanup_if_owned(
1145 sock: &std::path::Path,
1146 pid_file: &std::path::Path,
1147 bound_identity: Option<SocketIdentity>,
1148) -> bool {
1149 let pid_is_ours = std::fs::read_to_string(pid_file)
1150 .ok()
1151 .and_then(|s| s.trim().parse::<u32>().ok())
1152 == Some(std::process::id());
1153 let socket_is_ours = bound_identity.is_some() && socket_identity(sock) == bound_identity;
1154 if pid_is_ours && socket_is_ours {
1155 let _ = std::fs::remove_file(sock);
1156 let _ = std::fs::remove_file(pid_file);
1157 true
1158 } else {
1159 tracing::warn!(
1160 socket = ?sock,
1161 pid_file = ?pid_file,
1162 "skipping shutdown cleanup — a replacement daemon already owns this socket/PID"
1163 );
1164 false
1165 }
1166}
1167
1168// ── helpers ───────────────────────────────────────────────────────────────────
1169
1170/// Liveness verdict for a `kill(pid, 0)` probe.
1171#[cfg(unix)]
1172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1173enum PidLiveness {
1174 /// errno 0 — signal delivery succeeded, the process exists and this
1175 /// caller may signal it.
1176 Alive,
1177 /// ESRCH (or any other non-EPERM errno) — no such process.
1178 Dead,
1179 /// EPERM — the process exists but this caller lacks permission to
1180 /// signal it. Unknown-safe: treated as running so stale-daemon cleanup
1181 /// never unlinks a live daemon's socket/PID file just because it is
1182 /// owned by a different user/uid.
1183 PermissionDenied,
1184}
1185
1186#[cfg(unix)]
1187impl PidLiveness {
1188 fn is_running(self) -> bool {
1189 !matches!(self, PidLiveness::Dead)
1190 }
1191}
1192
1193/// Maps a `kill(pid, 0)` outcome (return code + errno) to a [`PidLiveness`].
1194/// Pure and side-effect-free so the errno mapping can be unit tested without
1195/// a real process probe.
1196#[cfg(unix)]
1197fn classify_kill_result(rc: i32, errno: i32) -> PidLiveness {
1198 if rc == 0 {
1199 return PidLiveness::Alive;
1200 }
1201 match errno {
1202 libc::EPERM => PidLiveness::PermissionDenied,
1203 _ => PidLiveness::Dead,
1204 }
1205}
1206
1207#[cfg(unix)]
1208fn is_process_running(pid: u32) -> bool {
1209 let Ok(pid) = i32::try_from(pid) else {
1210 return false;
1211 };
1212 if pid <= 0 {
1213 return false;
1214 }
1215 // SAFETY: signal 0 is an existence/permission probe with no side effects.
1216 let rc = unsafe { libc::kill(pid, 0) };
1217 let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
1218 classify_kill_result(rc, errno).is_running()
1219}
1220
1221#[cfg(unix)]
1222async fn cleanup_stale_daemon(sock: &std::path::Path, pid_file: &std::path::Path) -> bool {
1223 if let Ok(pid_str) = std::fs::read_to_string(pid_file) {
1224 if let Ok(pid) = pid_str.trim().parse::<u32>() {
1225 if pid != std::process::id()
1226 && is_process_running(pid)
1227 && sock.exists()
1228 && UnixStream::connect(sock).await.is_ok()
1229 {
1230 return false;
1231 }
1232 }
1233 }
1234 if sock.exists() {
1235 if let Err(e) = std::fs::remove_file(sock) {
1236 tracing::warn!(error = %e, path = ?sock, "failed to remove stale socket");
1237 }
1238 }
1239 if pid_file.exists() {
1240 if let Err(e) = std::fs::remove_file(pid_file) {
1241 tracing::warn!(error = %e, path = ?pid_file, "failed to remove stale PID file");
1242 }
1243 }
1244 true
1245}
1246
1247/// Create `pid_file` exclusively (`O_EXCL`) and write this process's PID.
1248///
1249/// Uses `create_new(true)` rather than `create(true).truncate(true)` so
1250/// this can never silently overwrite a PID file another process created —
1251/// under normal operation the boot lock already serializes cleanup → bind →
1252/// pid-write across processes, but that guarantee depends on
1253/// `acquire_recovery_lock` succeeding for every party. Exclusive creation is
1254/// the defense that holds even if the lock itself is unavailable to one side:
1255/// the loser observes `ErrorKind::AlreadyExists` instead of clobbering the
1256/// winner's PID out from under it.
1257#[cfg(unix)]
1258fn write_pid_file_exclusive(pid_file: &std::path::Path) -> std::io::Result<()> {
1259 use std::os::unix::fs::OpenOptionsExt;
1260
1261 let mut opts = std::fs::OpenOptions::new();
1262 opts.write(true).create_new(true).mode(0o600);
1263 let mut f = opts.open(pid_file)?;
1264 f.write_all(std::process::id().to_string().as_bytes())?;
1265 Ok(())
1266}
1267
1268/// Return `true` if `pid_file` currently names a different, live process that
1269/// still answers on `sock` — i.e. a daemon already owns this rendezvous and it
1270/// is safe to defer to it rather than treat the `AlreadyExists` PID-file
1271/// collision as a boot failure.
1272#[cfg(unix)]
1273async fn pid_file_names_a_reachable_daemon(
1274 pid_file: &std::path::Path,
1275 sock: &std::path::Path,
1276) -> bool {
1277 let Ok(pid_str) = std::fs::read_to_string(pid_file) else {
1278 return false;
1279 };
1280 let Ok(pid) = pid_str.trim().parse::<u32>() else {
1281 return false;
1282 };
1283 pid != std::process::id()
1284 && is_process_running(pid)
1285 && sock.exists()
1286 && UnixStream::connect(sock).await.is_ok()
1287}
1288
1289#[cfg(unix)]
1290async fn drain(active: &std::sync::atomic::AtomicUsize) {
1291 use std::sync::atomic::Ordering;
1292 let remaining = || active.load(Ordering::Relaxed) + background_task_count();
1293 if remaining() == 0 {
1294 return;
1295 }
1296 let deadline = tokio::time::Instant::now() + drain_timeout();
1297 while remaining() > 0 {
1298 if tokio::time::Instant::now() >= deadline {
1299 tracing::warn!(
1300 remaining_connections = active.load(Ordering::Relaxed),
1301 remaining_background_tasks = background_task_count(),
1302 "drain timeout reached; forcing shutdown"
1303 );
1304 break;
1305 }
1306 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1307 }
1308}
1309
1310#[cfg(unix)]
1311fn drain_timeout() -> std::time::Duration {
1312 let secs = std::env::var("KHIVE_DRAIN_TIMEOUT_SECS")
1313 .ok()
1314 .and_then(|v| v.parse::<u64>().ok())
1315 .unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS);
1316 std::time::Duration::from_secs(secs)
1317}
1318
1319/// Returns `true` for non-empty env values that are not `"0"` or `"false"`.
1320#[cfg(unix)]
1321pub fn env_truthy(key: &str) -> bool {
1322 std::env::var(key)
1323 .map(|v| {
1324 let v = v.trim();
1325 !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
1326 })
1327 .unwrap_or(false)
1328}
1329
1330#[cfg(all(test, unix))]
1331mod tests {
1332 use super::*;
1333 use serial_test::serial;
1334
1335 // Focused regression tests for the unsafe process probe (SAFETY: signal 0
1336 // is an existence check with no side effects; see is_process_running).
1337
1338 #[test]
1339 fn current_process_is_running() {
1340 // The current PID is always alive.
1341 let pid = std::process::id();
1342 assert!(
1343 is_process_running(pid),
1344 "current process {pid} should be detected as running"
1345 );
1346 }
1347
1348 #[test]
1349 fn pid_zero_is_not_running() {
1350 // PID 0 is the process group; kill(0, 0) sends to the group,
1351 // which we treat as invalid — the guard `pid <= 0` must block it.
1352 assert!(
1353 !is_process_running(0),
1354 "pid 0 must be rejected by the guard before the unsafe call"
1355 );
1356 }
1357
1358 #[test]
1359 fn very_large_pid_is_not_running() {
1360 // u32::MAX overflows i32 — try_from returns Err, guard returns false.
1361 assert!(
1362 !is_process_running(u32::MAX),
1363 "u32::MAX should fail i32 conversion and return false"
1364 );
1365 }
1366
1367 // EPERM (process exists, no permission to signal it) must not be
1368 // misread as "not running" during stale-daemon cleanup.
1369
1370 #[test]
1371 fn classify_kill_result_zero_is_alive() {
1372 assert_eq!(classify_kill_result(0, 0), PidLiveness::Alive);
1373 assert!(classify_kill_result(0, 0).is_running());
1374 }
1375
1376 #[test]
1377 fn classify_kill_result_esrch_is_dead() {
1378 assert_eq!(classify_kill_result(-1, libc::ESRCH), PidLiveness::Dead);
1379 assert!(!classify_kill_result(-1, libc::ESRCH).is_running());
1380 }
1381
1382 #[test]
1383 fn classify_kill_result_eperm_is_permission_denied_and_counts_as_running() {
1384 assert_eq!(
1385 classify_kill_result(-1, libc::EPERM),
1386 PidLiveness::PermissionDenied
1387 );
1388 assert!(
1389 classify_kill_result(-1, libc::EPERM).is_running(),
1390 "EPERM must be unknown-safe: treated as running, never as a basis \
1391 for stale cleanup to unlink a live daemon's rendezvous files"
1392 );
1393 }
1394
1395 #[test]
1396 fn pid_1_probe_is_running_regardless_of_permission_outcome() {
1397 // PID 1 (init/launchd) always exists. An unprivileged process gets
1398 // EPERM signaling it (never ESRCH); running as root would get 0
1399 // instead. Either way `is_process_running` must report true — this
1400 // is the live regression guard for EPERM being misread as dead;
1401 // `classify_kill_result` above is the pure-function unit coverage
1402 // for the same mapping, kept independent of process ownership so
1403 // it is never flaky in CI.
1404 assert!(
1405 is_process_running(1),
1406 "PID 1 always exists; EPERM must not read as dead"
1407 );
1408 }
1409
1410 #[test]
1411 fn env_truthy_recognises_set_values() {
1412 assert!(!env_truthy("__KHIVE_TEST_ABSENT_VAR_XYZ__"));
1413
1414 // env_truthy with a live value — set and unset atomically to avoid
1415 // cross-test pollution (not parallel-safe without serial_test, but these
1416 // are fast unit tests and the variable name is unique).
1417 let key = "__KHIVE_TEST_TRUTHY_ABC__";
1418 std::env::set_var(key, "1");
1419 assert!(env_truthy(key));
1420 std::env::set_var(key, "false");
1421 assert!(!env_truthy(key));
1422 std::env::set_var(key, "0");
1423 assert!(!env_truthy(key));
1424 std::env::remove_var(key);
1425 }
1426
1427 // `drain()` must wait for tracked background tasks (e.g. memory.recall's
1428 // serve-ledger append), not just in-flight connections, or a SIGTERM
1429 // lands mid-flight with no log and no row.
1430 //
1431 // `#[serial(background_tasks)]`: this test reads/asserts on the
1432 // process-wide `BACKGROUND_TASKS` static shared with the two counter
1433 // tests below. Under default parallel execution one test's increment
1434 // leaks into another's snapshot-then-assert window (reproduced: both
1435 // counter tests failed together, passed with `--test-threads=1`).
1436 // Serializing just this named group isolates them from each other
1437 // without forcing the whole test binary (including unrelated
1438 // `#[serial]` tests elsewhere in this crate) onto one thread.
1439 #[tokio::test]
1440 #[serial(background_tasks)]
1441 async fn drain_waits_for_tracked_background_tasks_before_returning() {
1442 let active = std::sync::atomic::AtomicUsize::new(0);
1443 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1444
1445 track_background_task(async move {
1446 let _ = rx.await;
1447 });
1448 assert!(
1449 background_task_count() >= 1,
1450 "track_background_task must make the in-flight task visible immediately"
1451 );
1452
1453 let drain_fut = drain(&active);
1454 tokio::pin!(drain_fut);
1455
1456 // Must NOT resolve while the tracked task is still pending.
1457 let too_early =
1458 tokio::time::timeout(std::time::Duration::from_millis(150), &mut drain_fut).await;
1459 assert!(
1460 too_early.is_err(),
1461 "drain() must not return while a tracked background task is still running"
1462 );
1463
1464 // Completing the task must let drain() proceed promptly.
1465 tx.send(())
1466 .expect("tracked task still awaiting the oneshot");
1467 let done = tokio::time::timeout(std::time::Duration::from_secs(5), drain_fut).await;
1468 assert!(
1469 done.is_ok(),
1470 "drain() must return once the tracked background task finishes"
1471 );
1472 }
1473
1474 // See the `#[serial(background_tasks)]` note on
1475 // `drain_waits_for_tracked_background_tasks_before_returning` above —
1476 // this test shares the same process-wide `BACKGROUND_TASKS` static and
1477 // races it (and the panic test below) under default parallelism.
1478 #[tokio::test]
1479 #[serial(background_tasks)]
1480 async fn track_background_task_count_returns_to_zero_after_completion() {
1481 // Sanity check on the counter's own bookkeeping, independent of drain().
1482 let before = background_task_count();
1483 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1484 track_background_task(async move {
1485 let _ = rx.await;
1486 });
1487 assert_eq!(background_task_count(), before + 1);
1488 tx.send(()).expect("still awaiting");
1489 // Yield until the spawned task's decrement has actually run.
1490 for _ in 0..100 {
1491 if background_task_count() == before {
1492 break;
1493 }
1494 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1495 }
1496 assert_eq!(background_task_count(), before);
1497 }
1498
1499 // See the `#[serial(background_tasks)]` note above — shares
1500 // `BACKGROUND_TASKS` with the other two tests in this group.
1501 #[tokio::test]
1502 #[serial(background_tasks)]
1503 async fn track_background_task_count_returns_to_baseline_after_panic() {
1504 // A panic inside the tracked future must still decrement the
1505 // counter (via BackgroundTaskGuard's Drop), not leak it forever.
1506 // `track_background_task` discards the spawned
1507 // task's `JoinHandle` (it is fire-and-forget by design — the caller
1508 // never awaits it), so this test does not await the panic directly;
1509 // tokio isolates the panic to the spawned task instead of aborting
1510 // the process, and we observe the recovery purely through the
1511 // shared counter returning to baseline after the guard's `Drop`
1512 // fires during that task's unwind.
1513 let before = background_task_count();
1514
1515 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
1516 track_background_task(async move {
1517 let _ = rx.await;
1518 panic!("intentional panic to exercise the Drop-guard decrement path");
1519 });
1520 assert_eq!(background_task_count(), before + 1);
1521
1522 tx.send(()).expect("still awaiting");
1523 for _ in 0..100 {
1524 if background_task_count() == before {
1525 break;
1526 }
1527 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1528 }
1529 assert_eq!(
1530 background_task_count(),
1531 before,
1532 "background task counter must return to baseline after the tracked future panics"
1533 );
1534 }
1535
1536 // ── active background phase names (ADR-103) ──────────────────────────
1537
1538 // `#[serial(active_phases)]`: these tests read/assert on the process-wide
1539 // `ACTIVE_PHASES` static. No other test in this crate touches it today,
1540 // but the group mirrors the `background_tasks` precedent above so a
1541 // future addition does not silently reintroduce the same interleaving
1542 // hazard that motivated it there.
1543 #[test]
1544 #[serial(active_phases)]
1545 fn register_active_phase_appears_and_disappears_with_the_guard() {
1546 assert!(
1547 !active_phase_names().contains(&"adr103_test_phase".to_string()),
1548 "must start absent (leaked from a prior failed run would poison this test)"
1549 );
1550
1551 let guard = register_active_phase("adr103_test_phase");
1552 assert!(active_phase_names().contains(&"adr103_test_phase".to_string()));
1553
1554 drop(guard);
1555 assert!(
1556 !active_phase_names().contains(&"adr103_test_phase".to_string()),
1557 "the phase name must drop out of the gauge once its guard is dropped"
1558 );
1559 }
1560
1561 #[test]
1562 #[serial(active_phases)]
1563 fn register_active_phase_counts_concurrent_occurrences_of_the_same_name() {
1564 let first = register_active_phase("adr103_concurrent_phase");
1565 let second = register_active_phase("adr103_concurrent_phase");
1566 assert!(active_phase_names().contains(&"adr103_concurrent_phase".to_string()));
1567
1568 drop(first);
1569 assert!(
1570 active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
1571 "one of two concurrent occurrences ending must not remove the name early"
1572 );
1573
1574 drop(second);
1575 assert!(
1576 !active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
1577 "the name must be removed only once every concurrent occurrence has ended"
1578 );
1579 }
1580
1581 // ── metrics-only frame (load/perf harness read-surface) ────────────────
1582
1583 /// Minimal `DaemonDispatch` for the metrics tests: `dispatch` just counts
1584 /// how many times it was called (so tests can assert the ops path was
1585 /// never reached) and `pool_for_checkpoint` returns whatever pool the
1586 /// test wired in (or `None`, matching an in-memory/poolless dispatcher).
1587 #[derive(Clone)]
1588 struct MockDispatch {
1589 namespace: String,
1590 config_id: String,
1591 dispatch_calls: Arc<std::sync::atomic::AtomicUsize>,
1592 pool: Option<Arc<ConnectionPool>>,
1593 /// When `Some(msg)`, `dispatch` returns `Err(msg)` instead of the
1594 /// default `Ok("{}")` — lets a test drive `handle_conn`'s real
1595 /// dispatch-error arm (khive#948 request_id echo coverage).
1596 dispatch_err: Option<String>,
1597 }
1598
1599 #[async_trait]
1600 impl DaemonDispatch for MockDispatch {
1601 async fn dispatch(
1602 &self,
1603 _ops: String,
1604 _presentation: Option<String>,
1605 _presentation_per_op: Option<Vec<Option<String>>>,
1606 _format: Option<String>,
1607 _format_per_op: Option<Vec<Option<String>>>,
1608 _from_wire: bool,
1609 _identity: Option<RequestIdentity>,
1610 ) -> Result<String, String> {
1611 self.dispatch_calls
1612 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1613 match &self.dispatch_err {
1614 Some(msg) => Err(msg.clone()),
1615 None => Ok("{}".to_string()),
1616 }
1617 }
1618
1619 async fn warm_all(&self) {}
1620
1621 fn namespace(&self) -> &str {
1622 &self.namespace
1623 }
1624
1625 fn config_id(&self) -> &str {
1626 &self.config_id
1627 }
1628
1629 fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
1630 self.pool.clone()
1631 }
1632 }
1633
1634 fn base_request_frame(config_id: &str) -> DaemonRequestFrame {
1635 DaemonRequestFrame {
1636 ops: String::new(),
1637 presentation: None,
1638 presentation_per_op: None,
1639 namespace: "local".to_string(),
1640 actor_id: None,
1641 visible_namespaces: Vec::new(),
1642 config_id: config_id.to_string(),
1643 protocol_version: PROTOCOL_VERSION,
1644 probe_only: false,
1645 metrics_only: false,
1646 format: None,
1647 format_per_op: None,
1648 from_wire: false,
1649 request_id: None,
1650 }
1651 }
1652
1653 /// Drive `handle_conn` over an in-process `UnixStream::pair()` (no real
1654 /// socket file needed) and decode the response frame it writes back.
1655 async fn round_trip(dispatcher: MockDispatch, req: &DaemonRequestFrame) -> DaemonResponseFrame {
1656 let (mut client, server) = UnixStream::pair().expect("unix stream pair");
1657 let payload = serde_json::to_vec(req).expect("encode request frame");
1658 let handle = tokio::spawn(async move {
1659 handle_conn(server, dispatcher).await;
1660 });
1661 write_frame(&mut client, &payload)
1662 .await
1663 .expect("write request frame");
1664 let raw = read_frame(&mut client).await.expect("read response frame");
1665 handle.await.expect("handle_conn task panicked");
1666 serde_json::from_slice(&raw).expect("decode response frame")
1667 }
1668
1669 /// Test 1: a `metrics_only: true` request
1670 /// returns `metrics: Some(_)` and never reaches the ops-dispatch path; a
1671 /// normal request (the default `metrics_only: false`) still dispatches
1672 /// exactly as before and carries no metrics. Also proves `metrics_only`
1673 /// bypasses the `config_id` equality reject (a gauge read is
1674 /// process-global, not namespaced to a particular client config).
1675 #[tokio::test]
1676 async fn metrics_only_frame_returns_snapshot_without_dispatching() {
1677 let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1678 let dispatcher = MockDispatch {
1679 namespace: "local".to_string(),
1680 config_id: "cfg-a".to_string(),
1681 dispatch_calls: Arc::clone(&dispatch_calls),
1682 pool: None,
1683 dispatch_err: None,
1684 };
1685
1686 let mut metrics_req = base_request_frame("cfg-a");
1687 metrics_req.metrics_only = true;
1688 let metrics_resp = round_trip(dispatcher.clone(), &metrics_req).await;
1689
1690 assert!(metrics_resp.ok, "metrics_only response must be ok=true");
1691 assert!(
1692 metrics_resp.metrics.is_some(),
1693 "metrics_only=true must return Some(snapshot)"
1694 );
1695 assert_eq!(
1696 dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
1697 0,
1698 "metrics_only must never reach the ops-dispatch path"
1699 );
1700
1701 // metrics_only bypasses the config_id equality reject.
1702 let mut mismatched_req = base_request_frame("some-other-config");
1703 mismatched_req.metrics_only = true;
1704 let mismatched_resp = round_trip(dispatcher.clone(), &mismatched_req).await;
1705 assert!(mismatched_resp.ok);
1706 assert!(mismatched_resp.metrics.is_some());
1707 assert!(!mismatched_resp.config_mismatch);
1708 assert_eq!(
1709 dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
1710 0,
1711 "a mismatched-config metrics_only request must still skip dispatch"
1712 );
1713
1714 // A normal request (default metrics_only=false) is unaffected: it
1715 // still dispatches and carries no metrics.
1716 let normal_req = base_request_frame("cfg-a");
1717 let normal_resp = round_trip(dispatcher, &normal_req).await;
1718 assert!(normal_resp.ok);
1719 assert!(normal_resp.metrics.is_none());
1720 assert_eq!(dispatch_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
1721 }
1722
1723 /// Test 2: `wal_pages` reflects a real
1724 /// checkpoint observation after writes, deterministically forced via a
1725 /// direct `checkpoint_once` call rather than waiting on the async
1726 /// periodic task.
1727 #[tokio::test]
1728 async fn metrics_snapshot_wal_pages_reflects_recent_write() {
1729 let dir = tempfile::tempdir().expect("tempdir");
1730 let path = dir.path().join("metrics_wal_test.db");
1731 let pool = Arc::new(
1732 ConnectionPool::new(khive_db::PoolConfig {
1733 path: Some(path),
1734 ..khive_db::PoolConfig::default()
1735 })
1736 .expect("pool open"),
1737 );
1738
1739 {
1740 let writer = pool.try_writer().expect("writer");
1741 writer
1742 .conn()
1743 .execute_batch(
1744 "CREATE TABLE t (x INTEGER); \
1745 INSERT INTO t VALUES (1); \
1746 INSERT INTO t VALUES (2);",
1747 )
1748 .expect("seed writes");
1749 }
1750
1751 let tick = khive_db::checkpoint_once(
1752 &pool,
1753 &CheckpointConfig::default(),
1754 &mut khive_db::checkpoint::TruncateState::default(),
1755 );
1756 assert!(
1757 matches!(tick, khive_db::CheckpointTick::Observed(_)),
1758 "checkpoint_once on a freshly-writer-held pool must observe, not skip: {tick:?}"
1759 );
1760
1761 let dispatcher = MockDispatch {
1762 namespace: "local".to_string(),
1763 config_id: "cfg-wal".to_string(),
1764 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1765 pool: Some(pool),
1766 dispatch_err: None,
1767 };
1768
1769 let snapshot = build_metrics_snapshot(&dispatcher);
1770 assert!(
1771 snapshot.wal_pages.is_some(),
1772 "wal_pages must be observed after a real checkpoint tick, got {snapshot:?}"
1773 );
1774 // The snapshot carries the checkpoint-pressure fields read-only
1775 // (no mutation path reachable through `MetricsSnapshot`/`DaemonRequestFrame`);
1776 // an observed tick (not a skip) must report a zero-length skip streak.
1777 assert_eq!(
1778 snapshot.wal_checkpoint_consecutive_skips, 0,
1779 "an observed (non-skipped) tick must report zero consecutive skips, got {snapshot:?}"
1780 );
1781 }
1782
1783 /// Test 3: the tx-pin oracle. The registry is process-global, so an
1784 /// unrelated transaction can depart between snapshots and exactly offset
1785 /// this test's registration. Keep an owned handle live and assert the
1786 /// resulting count floor instead of comparing two points in time.
1787 #[test]
1788 #[serial(tx_registry)]
1789 fn metrics_snapshot_reflects_open_transaction_registry() {
1790 let dispatcher = MockDispatch {
1791 namespace: "local".to_string(),
1792 config_id: "cfg-tx".to_string(),
1793 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1794 pool: None,
1795 dispatch_err: None,
1796 };
1797
1798 let departing_handle = khive_storage::tx_registry::register(Some(
1799 "daemon_metrics_snapshot_departing_test_tx".to_string(),
1800 ));
1801 let before = build_metrics_snapshot(&dispatcher).open_tx_count;
1802 assert!(before >= 1);
1803
1804 let handle = khive_storage::tx_registry::register(Some(
1805 "daemon_metrics_snapshot_owned_test_tx".to_string(),
1806 ));
1807 drop(departing_handle);
1808
1809 let during = build_metrics_snapshot(&dispatcher);
1810 assert!(
1811 during.open_tx_count >= 1,
1812 "open_tx_count must reflect the live owned transaction despite registry churn: \
1813 churn_baseline={before} during={}",
1814 during.open_tx_count
1815 );
1816 assert!(
1817 during.oldest_pinned_tx_micros.is_some(),
1818 "oldest_pinned_tx_micros must be Some while a transaction is open"
1819 );
1820
1821 drop(handle);
1822 assert!(
1823 !khive_storage::tx_registry::snapshot()
1824 .iter()
1825 .any(|(_, label)| label.as_deref()
1826 == Some("daemon_metrics_snapshot_owned_test_tx")),
1827 "the owned registry entry must disappear when its handle is dropped"
1828 );
1829 }
1830
1831 /// Test 4: write-queue depth is flag-gated
1832 /// on `PoolConfig::write_queue_enabled` (the `KHIVE_WRITE_QUEUE=1`
1833 /// setting), never on a specific depth value (racy under concurrency).
1834 #[tokio::test]
1835 async fn metrics_snapshot_write_queue_depth_flag_gated() {
1836 let dir = tempfile::tempdir().expect("tempdir");
1837
1838 let enabled_pool = Arc::new(
1839 ConnectionPool::new(khive_db::PoolConfig {
1840 path: Some(dir.path().join("wq_enabled.db")),
1841 write_queue_enabled: true,
1842 ..khive_db::PoolConfig::default()
1843 })
1844 .expect("pool open"),
1845 );
1846 let enabled_dispatcher = MockDispatch {
1847 namespace: "local".to_string(),
1848 config_id: "cfg-wq-on".to_string(),
1849 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1850 pool: Some(enabled_pool),
1851 dispatch_err: None,
1852 };
1853 let snapshot_on = build_metrics_snapshot(&enabled_dispatcher);
1854 assert!(
1855 snapshot_on.write_queue_depth.is_some(),
1856 "write_queue_depth must be Some when write_queue_enabled=true, got {snapshot_on:?}"
1857 );
1858 assert!(snapshot_on.write_queue_capacity.is_some());
1859
1860 let disabled_pool = Arc::new(
1861 ConnectionPool::new(khive_db::PoolConfig {
1862 path: Some(dir.path().join("wq_disabled.db")),
1863 write_queue_enabled: false,
1864 ..khive_db::PoolConfig::default()
1865 })
1866 .expect("pool open"),
1867 );
1868 let disabled_dispatcher = MockDispatch {
1869 namespace: "local".to_string(),
1870 config_id: "cfg-wq-off".to_string(),
1871 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1872 pool: Some(disabled_pool),
1873 dispatch_err: None,
1874 };
1875 let snapshot_off = build_metrics_snapshot(&disabled_dispatcher);
1876 assert!(
1877 snapshot_off.write_queue_depth.is_none(),
1878 "write_queue_depth must be None when write_queue_enabled=false, got {snapshot_off:?}"
1879 );
1880 assert!(snapshot_off.write_queue_capacity.is_none());
1881
1882 // No pool at all (in-memory/poolless dispatcher): also None.
1883 let no_pool_dispatcher = MockDispatch {
1884 namespace: "local".to_string(),
1885 config_id: "cfg-no-pool".to_string(),
1886 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1887 pool: None,
1888 dispatch_err: None,
1889 };
1890 let snapshot_no_pool = build_metrics_snapshot(&no_pool_dispatcher);
1891 assert!(snapshot_no_pool.write_queue_depth.is_none());
1892 assert!(snapshot_no_pool.write_queue_capacity.is_none());
1893 }
1894
1895 /// Test 5: serde default back-compat in
1896 /// both directions — a request JSON without `metrics_only` deserializes
1897 /// with it `false`, and a response JSON without `metrics` (an old
1898 /// daemon's shape) deserializes with it `None`.
1899 #[test]
1900 fn frame_serde_defaults_metrics_fields_when_absent() {
1901 let req_json = serde_json::json!({
1902 "ops": "",
1903 "presentation": null,
1904 "presentation_per_op": null,
1905 "namespace": "local",
1906 "actor_id": null,
1907 "visible_namespaces": [],
1908 "config_id": "cfg",
1909 "protocol_version": PROTOCOL_VERSION,
1910 "probe_only": false,
1911 "format": null,
1912 "format_per_op": null,
1913 "from_wire": false
1914 });
1915 let frame: DaemonRequestFrame =
1916 serde_json::from_value(req_json).expect("decode a metrics_only-absent request frame");
1917 assert!(
1918 !frame.metrics_only,
1919 "metrics_only must default to false when absent from the wire payload"
1920 );
1921 assert_eq!(
1922 frame.request_id, None,
1923 "request_id must default to None when absent from the wire payload (khive#948)"
1924 );
1925
1926 let resp_json = serde_json::json!({
1927 "ok": true,
1928 "result": null,
1929 "error": null,
1930 "namespace_mismatch": false,
1931 "config_mismatch": false,
1932 "served_config_id": "cfg",
1933 "version_mismatch": false,
1934 "daemon_protocol_version": PROTOCOL_VERSION
1935 });
1936 let resp: DaemonResponseFrame =
1937 serde_json::from_value(resp_json).expect("decode a metrics-absent response frame");
1938 assert!(
1939 resp.metrics.is_none(),
1940 "metrics must default to None when absent from the wire payload"
1941 );
1942 assert_eq!(
1943 resp.request_id, None,
1944 "request_id must default to None when absent from the wire payload (khive#948)"
1945 );
1946 }
1947
1948 /// khive#948: a request carrying `request_id: Some(n)` gets back a
1949 /// response with `request_id: Some(n)` on both the success and the
1950 /// error/denied dispatch arms — the echo must survive every branch of
1951 /// `handle_conn`, not only the happy path.
1952 #[tokio::test]
1953 async fn request_id_echoed_on_success_and_error_arms() {
1954 let dispatcher = MockDispatch {
1955 namespace: "local".to_string(),
1956 config_id: "cfg-a".to_string(),
1957 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1958 pool: None,
1959 dispatch_err: None,
1960 };
1961 let mut ok_req = base_request_frame("cfg-a");
1962 ok_req.request_id = Some(42);
1963 let ok_resp = round_trip(dispatcher, &ok_req).await;
1964 assert!(ok_resp.ok, "expected successful dispatch: {ok_resp:?}");
1965 assert_eq!(
1966 ok_resp.request_id,
1967 Some(42),
1968 "request_id must be echoed back on a successful dispatch response"
1969 );
1970
1971 // config_mismatch is a rejection arm that never reaches dispatch —
1972 // must still echo the id so the client can join the failure.
1973 let mismatched_dispatcher = MockDispatch {
1974 namespace: "local".to_string(),
1975 config_id: "cfg-a".to_string(),
1976 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1977 pool: None,
1978 dispatch_err: None,
1979 };
1980 let mut mismatch_req = base_request_frame("cfg-WRONG");
1981 mismatch_req.request_id = Some(99);
1982 let mismatch_resp = round_trip(mismatched_dispatcher, &mismatch_req).await;
1983 assert!(mismatch_resp.config_mismatch);
1984 assert_eq!(
1985 mismatch_resp.request_id,
1986 Some(99),
1987 "request_id must be echoed on the config_mismatch rejection arm too"
1988 );
1989
1990 // The real ops-dispatch error arm (`Err(e)` from `dispatcher.dispatch`)
1991 // must echo the id as well, not only the pre-dispatch rejection arms.
1992 let erroring_dispatcher = MockDispatch {
1993 namespace: "local".to_string(),
1994 config_id: "cfg-a".to_string(),
1995 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
1996 pool: None,
1997 dispatch_err: Some("simulated dispatch error".to_string()),
1998 };
1999 let mut err_req = base_request_frame("cfg-a");
2000 err_req.request_id = Some(7);
2001 let err_resp = round_trip(erroring_dispatcher, &err_req).await;
2002 assert!(!err_resp.ok, "expected a dispatch error: {err_resp:?}");
2003 assert_eq!(
2004 err_resp.request_id,
2005 Some(7),
2006 "request_id must be echoed on the real ops-dispatch error arm"
2007 );
2008 }
2009
2010 // ── owner-checked shutdown cleanup ────────────────────────────────────────
2011 //
2012 // A draining daemon must not unlink a socket/PID pair that a replacement
2013 // daemon has already bound. These tests exercise `shutdown_cleanup_if_owned`
2014 // directly (the pure decision the caller makes under the recovery lock)
2015 // rather than driving `run_daemon`'s real SIGTERM shutdown, which would
2016 // require sending a signal to the whole test process.
2017
2018 #[test]
2019 fn shutdown_cleanup_removes_paths_it_still_owns() {
2020 let dir = tempfile::tempdir().expect("tempdir");
2021 let sock = dir.path().join("khived.sock");
2022 let pid_file = dir.path().join("khived.pid");
2023
2024 let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
2025 std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
2026 let identity = socket_identity(&sock);
2027 assert!(
2028 identity.is_some(),
2029 "must read identity of a freshly bound socket"
2030 );
2031
2032 let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
2033
2034 assert!(
2035 cleaned,
2036 "cleanup must proceed when PID and socket still match"
2037 );
2038 assert!(!sock.exists(), "owned socket must be removed");
2039 assert!(!pid_file.exists(), "owned pid file must be removed");
2040 }
2041
2042 #[test]
2043 fn shutdown_cleanup_skips_when_pid_file_names_a_different_process() {
2044 let dir = tempfile::tempdir().expect("tempdir");
2045 let sock = dir.path().join("khived.sock");
2046 let pid_file = dir.path().join("khived.pid");
2047
2048 let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
2049 let identity = socket_identity(&sock);
2050 // A concurrent client's kill_and_respawn already replaced the PID file
2051 // with a different (replacement daemon's) PID before this daemon's
2052 // drain completed.
2053 std::fs::write(&pid_file, "1").expect("write foreign pid file");
2054
2055 let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
2056
2057 assert!(
2058 !cleaned,
2059 "cleanup must be skipped when the PID file no longer names this process"
2060 );
2061 assert!(sock.exists(), "replacement daemon's socket must survive");
2062 assert!(
2063 pid_file.exists(),
2064 "replacement daemon's pid file must survive"
2065 );
2066 }
2067
2068 #[test]
2069 fn shutdown_cleanup_skips_when_socket_was_rebound_by_a_replacement() {
2070 let dir = tempfile::tempdir().expect("tempdir");
2071 let sock = dir.path().join("khived.sock");
2072 let original_sock = dir.path().join("original.sock");
2073 let pid_file = dir.path().join("khived.pid");
2074
2075 // Bind two sockets at DIFFERENT paths, both alive at the same time,
2076 // so the OS cannot recycle an inode between them the way it could
2077 // across a bind/drop/rebind cycle at a single path (the flakiness a
2078 // prior version of this test hit on some filesystems). Both
2079 // identities are captured through the real production
2080 // `socket_identity()` path, not a synthetic/sentinel value, so a
2081 // regression where `socket_identity()` returns a constant identity
2082 // for every socket makes the `assert!` below fail loudly instead of
2083 // silently passing.
2084 let _original_listener =
2085 std::os::unix::net::UnixListener::bind(&original_sock).expect("bind original socket");
2086 let _replacement_listener =
2087 std::os::unix::net::UnixListener::bind(&sock).expect("bind replacement socket");
2088
2089 let original_identity = socket_identity(&original_sock);
2090 let replacement_identity = socket_identity(&sock);
2091 assert!(
2092 original_identity.is_some(),
2093 "must read identity of the original socket"
2094 );
2095 assert!(
2096 replacement_identity.is_some(),
2097 "must read identity of the replacement socket"
2098 );
2099 assert!(
2100 original_identity != replacement_identity,
2101 "two concurrently bound sockets must have distinct identities"
2102 );
2103
2104 std::fs::write(&pid_file, std::process::id().to_string())
2105 .expect("write pid file matching this process");
2106
2107 // `sock` (the replacement bind's path) is checked against
2108 // `original_identity` (a different, concurrently-alive socket's
2109 // identity) - the mismatch alone must be enough to block cleanup,
2110 // even though the pid file matches this process.
2111 let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, original_identity);
2112
2113 assert!(
2114 !cleaned,
2115 "cleanup must be skipped when the socket at this path is a different \
2116 inode than the one this daemon originally bound"
2117 );
2118 assert!(sock.exists(), "replacement daemon's socket must survive");
2119 assert!(
2120 pid_file.exists(),
2121 "replacement daemon's pid file must survive"
2122 );
2123 }
2124
2125 // ── the recovery lock actually serializes two boot sequences ─────────────
2126 //
2127 // Production wiring (`khive_mcp::serve::run` / `serve_server`) now acquires
2128 // this same lock *before* building a `KhiveMcpServer` (which runs
2129 // migrations and applies pack schema plans / FTS DDL) and holds it through
2130 // daemon bind+pid-write, via `run_daemon_with_boot_guard`. That closes the
2131 // cold-boot race only if `acquire_recovery_lock` genuinely provides mutual
2132 // exclusion across concurrent boot attempts — this test proves the
2133 // primitive itself: two "boot sequences" (each holding the lock across a
2134 // simulated schema-init critical section) must never run their critical
2135 // sections at the same time.
2136 #[test]
2137 #[serial]
2138 fn recovery_lock_serializes_two_concurrent_boot_sequences() {
2139 let dir = tempfile::tempdir().expect("tempdir");
2140 let lock_file = dir.path().join("khived.recovery.lock");
2141 std::env::set_var("KHIVE_LOCK", &lock_file);
2142
2143 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2144 let overlap_detected = Arc::new(std::sync::atomic::AtomicBool::new(false));
2145
2146 let run_one_boot =
2147 |active: Arc<std::sync::atomic::AtomicUsize>,
2148 overlap: Arc<std::sync::atomic::AtomicBool>| {
2149 move || {
2150 let _guard = acquire_recovery_lock().expect("acquire recovery lock");
2151 // Enter the "schema-init" critical section.
2152 if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) != 0 {
2153 overlap.store(true, std::sync::atomic::Ordering::SeqCst);
2154 }
2155 std::thread::sleep(std::time::Duration::from_millis(50));
2156 active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2157 // `_guard` drops here, releasing the lock.
2158 }
2159 };
2160
2161 let t1 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
2162 let t2 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
2163 t1.join().expect("boot thread 1 must not panic");
2164 t2.join().expect("boot thread 2 must not panic");
2165
2166 assert!(
2167 !overlap_detected.load(std::sync::atomic::Ordering::SeqCst),
2168 "two concurrent boot sequences must never hold the schema-init \
2169 critical section at the same time (#667)"
2170 );
2171
2172 std::env::remove_var("KHIVE_LOCK");
2173 }
2174
2175 // ── acquire_daemon_boot_guard treats lock failure as fatal ───────────────
2176 // (unlike best-effort acquire_recovery_lock, whose `None` on failure is
2177 // correct for its own best-effort callers).
2178
2179 #[test]
2180 #[serial]
2181 fn acquire_daemon_boot_guard_returns_guard_when_lock_available() {
2182 let dir = tempfile::tempdir().expect("tempdir");
2183 let lock_file = dir.path().join("khived.recovery.lock");
2184 std::env::set_var("KHIVE_LOCK", &lock_file);
2185
2186 let guard = acquire_daemon_boot_guard();
2187 assert!(
2188 guard.is_ok(),
2189 "daemon boot guard must succeed when the lock file can be opened and flocked"
2190 );
2191 drop(guard);
2192
2193 std::env::remove_var("KHIVE_LOCK");
2194 }
2195
2196 #[test]
2197 #[serial]
2198 fn acquire_daemon_boot_guard_fails_loudly_when_lock_file_cannot_be_opened() {
2199 let dir = tempfile::tempdir().expect("tempdir");
2200 // Point KHIVE_LOCK at a directory, not a file: opening a directory
2201 // with `write(true)` fails (EISDIR), so `acquire_recovery_lock`
2202 // returns `None` here — the exact failure mode
2203 // `acquire_daemon_boot_guard` must turn into a hard `Err` instead of
2204 // silently letting daemon-mode boot proceed unguarded.
2205 std::env::set_var("KHIVE_LOCK", dir.path());
2206
2207 let result = acquire_daemon_boot_guard();
2208 assert!(
2209 result.is_err(),
2210 "daemon boot guard must fail loudly, never silently proceed unguarded, \
2211 when the underlying recovery lock cannot be acquired"
2212 );
2213
2214 std::env::remove_var("KHIVE_LOCK");
2215 }
2216
2217 // ── write_pid_file_exclusive never truncates a winner's pid file ────────
2218
2219 #[test]
2220 fn write_pid_file_exclusive_creates_new_file_with_own_pid() {
2221 let dir = tempfile::tempdir().expect("tempdir");
2222 let pid_file = dir.path().join("khived.pid");
2223 write_pid_file_exclusive(&pid_file).expect("first writer must win");
2224 let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
2225 assert_eq!(contents, std::process::id().to_string());
2226 }
2227
2228 #[test]
2229 fn write_pid_file_exclusive_refuses_to_overwrite_an_existing_file() {
2230 let dir = tempfile::tempdir().expect("tempdir");
2231 let pid_file = dir.path().join("khived.pid");
2232 std::fs::write(&pid_file, "999999").expect("seed an existing pid file");
2233
2234 let err = write_pid_file_exclusive(&pid_file)
2235 .expect_err("must not silently overwrite an existing pid file");
2236 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
2237
2238 // The existing content must be completely untouched — proving this is
2239 // `create_new`, not the old `create(true).truncate(true)`.
2240 let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
2241 assert_eq!(
2242 contents, "999999",
2243 "an existing pid file must never be truncated by a losing writer"
2244 );
2245 }
2246
2247 // Real (not simulated) concurrency: two OS threads race to `create_new`
2248 // the exact same path, synchronized with a `Barrier` so they genuinely
2249 // overlap at the syscall rather than relying on a sleep-based ordering
2250 // guess. This is the deterministic race oracle for the convergence
2251 // requirement the atomic-creation primitive `write_pid_file_exclusive`
2252 // is built on: exactly one of two simultaneous daemon starters may claim
2253 // the pid file, and the loser must see `AlreadyExists`, never silently
2254 // clobber the winner's content.
2255 #[test]
2256 fn two_concurrent_writers_converge_on_exactly_one_pid_file_owner() {
2257 let dir = tempfile::tempdir().expect("tempdir");
2258 let pid_file = std::sync::Arc::new(dir.path().join("khived.pid"));
2259 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
2260
2261 let spawn_writer =
2262 |pid_file: std::sync::Arc<std::path::PathBuf>,
2263 barrier: std::sync::Arc<std::sync::Barrier>| {
2264 std::thread::spawn(move || {
2265 barrier.wait();
2266 write_pid_file_exclusive(&pid_file)
2267 })
2268 };
2269
2270 let t1 = spawn_writer(pid_file.clone(), barrier.clone());
2271 let t2 = spawn_writer(pid_file.clone(), barrier.clone());
2272 let r1 = t1.join().expect("writer 1 must not panic");
2273 let r2 = t2.join().expect("writer 2 must not panic");
2274
2275 let results = [&r1, &r2];
2276 let ok_count = results.iter().filter(|r| r.is_ok()).count();
2277 let already_exists_count = results
2278 .iter()
2279 .filter(|r| matches!(r, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists))
2280 .count();
2281 assert_eq!(
2282 ok_count, 1,
2283 "exactly one of two concurrent writers must win the pid file"
2284 );
2285 assert_eq!(
2286 already_exists_count, 1,
2287 "the other writer must observe AlreadyExists, never a silent overwrite"
2288 );
2289 assert!(pid_file.exists(), "the winner's pid file must exist");
2290 let contents = std::fs::read_to_string(&*pid_file).expect("read pid file");
2291 assert_eq!(
2292 contents,
2293 std::process::id().to_string(),
2294 "the surviving pid file must contain the winner's pid — both threads \
2295 share this process's pid, so an unexpected value would also prove a \
2296 lost/garbled write raced through"
2297 );
2298 }
2299}