Skip to main content

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}