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