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