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