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
13mod store_guard;
14mod store_identity;
15#[cfg(unix)]
16use store_guard::ensure_claimed_parent_identity;
17#[cfg(unix)]
18pub use store_guard::{acquire_daemon_store_guards, bind_daemon_store_files, claim_stores};
19pub use store_guard::{assert_daemon_store_identities, DaemonStoreGuard};
20#[cfg(unix)]
21pub use store_identity::claimed_daemon_store_identity;
22mod supervisor_marker;
23#[cfg(unix)]
24pub use supervisor_marker::supervisor_marker_path;
25
26#[cfg(unix)]
27use std::io::Write as _;
28#[cfg(unix)]
29use std::os::unix::fs::{MetadataExt, PermissionsExt};
30#[cfg(unix)]
31use std::os::unix::io::AsRawFd;
32use std::path::PathBuf;
33
34#[cfg(unix)]
35use async_trait::async_trait;
36#[cfg(unix)]
37use libc;
38use serde::{Deserialize, Serialize};
39#[cfg(unix)]
40use tokio::io::{AsyncReadExt, AsyncWriteExt};
41#[cfg(unix)]
42use tokio::net::{UnixListener, UnixStream};
43
44#[cfg(unix)]
45use crate::pack::RequestIdentity;
46#[cfg(unix)]
47use khive_db::{run_checkpoint_task, CheckpointConfig, CheckpointLifecycleOwner, ConnectionPool};
48
49mod load_limits;
50#[cfg(unix)]
51use load_limits::{admit_or_refuse_busy, ConnectionAdmission};
52pub use load_limits::{
53    recall_ledger_snapshot, track_recall_ledger_task, ConnectionCapSnapshot, RecallLedgerSnapshot,
54};
55
56/// Maximum frame size accepted in either direction.
57pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
58
59/// Wire protocol version for the daemon IPC framing.
60///
61/// Increment this constant whenever the request or response frame shape
62/// changes in a backward-incompatible way. The client sends its version
63/// in every request; the daemon rejects mismatches with an explicit error
64/// that names both sides so the operator knows exactly what to do
65/// (`make local` rebuilds the client binary).
66/// See `docs/api/daemon.md#protocol_version` for the version-by-version history.
67pub const PROTOCOL_VERSION: u32 = 8;
68
69/// ADR-049 Amendment 11's disclosed initial demand idle interval.
70pub const DEFAULT_DEMAND_IDLE_SECS: u64 = 1_800;
71
72/// A launch-time choice, never inferred from process ancestry or environment.
73#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum DaemonLifetime {
76    Demand,
77    #[default]
78    Persistent,
79}
80
81/// Immutable daemon options. Existing entry points use persistent mode.
82#[derive(Debug, Clone, Copy)]
83pub struct DaemonOptions {
84    pub lifetime: DaemonLifetime,
85    pub idle_interval: std::time::Duration,
86}
87
88impl Default for DaemonOptions {
89    fn default() -> Self {
90        Self {
91            lifetime: DaemonLifetime::Persistent,
92            idle_interval: std::time::Duration::from_secs(DEFAULT_DEMAND_IDLE_SECS),
93        }
94    }
95}
96
97/// Host-owned startup decisions disclosed by lifecycle diagnostics.
98#[derive(Debug, Clone, Default)]
99pub struct DaemonStartupReport {
100    pub skipped_components: Vec<String>,
101    /// A named unknown inventory or service obligation prevents retirement.
102    pub idle_ineligible_reasons: Vec<String>,
103}
104
105#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
106#[serde(rename_all = "snake_case")]
107pub enum DaemonLifecyclePhase {
108    Serving,
109    Draining,
110    Stopped,
111}
112
113#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum DaemonShutdownReason {
116    Idle,
117    Signal,
118}
119
120/// Additive diagnostics for one daemon incarnation.
121#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
122pub struct DaemonLifecycleSnapshot {
123    pub lifetime: DaemonLifetime,
124    pub instance_generation: String,
125    pub effective_idle_interval_ms: u64,
126    pub phase: DaemonLifecyclePhase,
127    pub shutdown_reason: Option<DaemonShutdownReason>,
128    pub skipped_components: Vec<String>,
129    pub idle_ineligible_reasons: Vec<String>,
130    pub ordinary_requests: usize,
131    pub idle_blockers: Vec<String>,
132}
133
134#[cfg(unix)]
135struct DaemonLifecycle {
136    options: DaemonOptions,
137    state: std::sync::Mutex<DaemonLifecycleState>,
138    /// Admission for new connections on the daemon socket.
139    connections: ConnectionAdmission,
140}
141
142#[cfg(unix)]
143struct DaemonLifecycleState {
144    snapshot: DaemonLifecycleSnapshot,
145    last_request_completion: Option<tokio::time::Instant>,
146}
147
148#[cfg(unix)]
149impl DaemonLifecycle {
150    fn new(options: DaemonOptions, report: DaemonStartupReport) -> Self {
151        Self {
152            options,
153            state: std::sync::Mutex::new(DaemonLifecycleState {
154                snapshot: DaemonLifecycleSnapshot {
155                    lifetime: options.lifetime,
156                    instance_generation: uuid::Uuid::new_v4().to_string(),
157                    effective_idle_interval_ms: options
158                        .idle_interval
159                        .as_millis()
160                        .min(u128::from(u64::MAX))
161                        as u64,
162                    phase: DaemonLifecyclePhase::Serving,
163                    shutdown_reason: None,
164                    skipped_components: report.skipped_components,
165                    idle_ineligible_reasons: report.idle_ineligible_reasons,
166                    ordinary_requests: 0,
167                    idle_blockers: Vec::new(),
168                },
169                last_request_completion: None,
170            }),
171            connections: ConnectionAdmission::from_env(),
172        }
173    }
174
175    fn snapshot(&self) -> DaemonLifecycleSnapshot {
176        self.state
177            .lock()
178            .unwrap_or_else(std::sync::PoisonError::into_inner)
179            .snapshot
180            .clone()
181    }
182
183    fn ready(&self) {
184        self.state
185            .lock()
186            .unwrap_or_else(std::sync::PoisonError::into_inner)
187            .last_request_completion = Some(tokio::time::Instant::now());
188    }
189
190    fn admit(self: &Arc<Self>) -> Option<OrdinaryRequestGuard> {
191        let mut state = self
192            .state
193            .lock()
194            .unwrap_or_else(std::sync::PoisonError::into_inner);
195        if state.snapshot.phase != DaemonLifecyclePhase::Serving {
196            return None;
197        }
198        state.snapshot.ordinary_requests += 1;
199        Some(OrdinaryRequestGuard(Arc::clone(self)))
200    }
201
202    /// The same mutex orders ordinary admission and the irreversible idle decision.
203    fn try_idle(&self, blockers: impl FnOnce() -> Vec<String>) -> bool {
204        let mut state = self
205            .state
206            .lock()
207            .unwrap_or_else(std::sync::PoisonError::into_inner);
208        if self.options.lifetime != DaemonLifetime::Demand
209            || state.snapshot.phase != DaemonLifecyclePhase::Serving
210            || state.snapshot.ordinary_requests != 0
211            || !state.snapshot.idle_ineligible_reasons.is_empty()
212            || state
213                .last_request_completion
214                .is_none_or(|last| last.elapsed() < self.options.idle_interval)
215        {
216            return false;
217        }
218        state.snapshot.idle_blockers = blockers();
219        if !state.snapshot.idle_blockers.is_empty() {
220            return false;
221        }
222        state.snapshot.phase = DaemonLifecyclePhase::Draining;
223        state.snapshot.shutdown_reason = Some(DaemonShutdownReason::Idle);
224        true
225    }
226
227    fn draining(&self, reason: DaemonShutdownReason) {
228        let mut state = self
229            .state
230            .lock()
231            .unwrap_or_else(std::sync::PoisonError::into_inner);
232        if state.snapshot.phase != DaemonLifecyclePhase::Stopped {
233            state.snapshot.phase = DaemonLifecyclePhase::Draining;
234            state.snapshot.shutdown_reason = Some(reason);
235        }
236    }
237
238    fn stopped(&self) {
239        self.state
240            .lock()
241            .unwrap_or_else(std::sync::PoisonError::into_inner)
242            .snapshot
243            .phase = DaemonLifecyclePhase::Stopped;
244    }
245}
246
247#[cfg(unix)]
248struct OrdinaryRequestGuard(Arc<DaemonLifecycle>);
249
250#[cfg(unix)]
251impl Drop for OrdinaryRequestGuard {
252    fn drop(&mut self) {
253        let mut state = self
254            .0
255            .state
256            .lock()
257            .unwrap_or_else(std::sync::PoisonError::into_inner);
258        state.snapshot.ordinary_requests -= 1;
259        // The guard covers response transport and cleanup, not just dispatch.
260        state.last_request_completion = Some(tokio::time::Instant::now());
261    }
262}
263
264/// Internal signal carried in a dispatch result until the daemon moves it to
265/// response-frame metadata. It must never be sent in `result`: older v8
266/// clients publish that string without inspecting its contents.
267#[doc(hidden)]
268pub const DAEMON_LEXICAL_TIMEOUT_MARKER: &str = "__khive_daemon_lexical_timeout";
269
270const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 10;
271/// An accepted local socket must finish its first frame within this window.
272/// Dispatch deadlines start only after decoding, so they cannot reap peers
273/// that connect and then stop sending request bytes.
274#[cfg(unix)]
275const INITIAL_FRAME_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
276
277#[cfg(unix)]
278fn next_accept_error_backoff(previous: Option<std::time::Duration>) -> std::time::Duration {
279    previous
280        .map(|delay| delay.saturating_mul(2))
281        .unwrap_or_else(|| std::time::Duration::from_millis(10))
282        .min(std::time::Duration::from_secs(1))
283}
284
285// ── paths ─────────────────────────────────────────────────────────────────────
286
287/// Base `.khive` directory used to anchor every advisory lock/socket/pid
288/// path below. Pure path computation — portable on every target, even
289/// though most of its callers (socket/pid paths) are unix-only.
290///
291/// Resolution order: `HOME`, then `USERPROFILE` (the conventional Windows
292/// home variable), then a platform-specific last resort. On non-unix targets
293/// the last resort is the OS temp directory (per-user on Windows), so the
294/// lock path can never be working-directory-relative there: two processes
295/// opening the same database from different working directories must resolve
296/// the same lock file. On unix the last resort stays the historical `"."` —
297/// a shared world-writable anchor such as `/tmp` would be worse, since a
298/// local attacker could pre-claim the directory and the socket/lock files
299/// under it before the daemon's first run.
300fn khive_dir() -> PathBuf {
301    khive_root_from(
302        std::env::var("HOME").ok(),
303        std::env::var("USERPROFILE").ok(),
304    )
305}
306
307/// Env-free core of [`khive_dir`], split out so the fallback chain is
308/// testable without mutating process-global environment variables.
309fn khive_root_from(home: Option<String>, userprofile: Option<String>) -> PathBuf {
310    home.filter(|v| !v.trim().is_empty())
311        .or_else(|| userprofile.filter(|v| !v.trim().is_empty()))
312        .map(PathBuf::from)
313        .unwrap_or_else(last_resort_root)
314        .join(".khive")
315}
316
317/// The directory for the SQLite volume lock files, from the single rule in
318/// [`khive_db::default_volume_lock_dir`]: `KHIVE_VOLUME_LOCK_DIR` when set,
319/// else `<home>/.khive/sqlite-volume-locks`. Unlike `khive_dir` it has no
320/// last-resort root: without a home directory the result is a configuration
321/// error, because a working-directory-relative lock directory would give two
322/// processes two different lock files.
323pub fn volume_lock_dir() -> Result<PathBuf, khive_db::SqliteError> {
324    khive_db::default_volume_lock_dir()
325}
326
327/// See [`khive_dir`] for why the two arms differ.
328#[cfg(unix)]
329fn last_resort_root() -> PathBuf {
330    PathBuf::from(".")
331}
332
333#[cfg(not(unix))]
334fn last_resort_root() -> PathBuf {
335    std::env::temp_dir()
336}
337
338/// Env var overriding the socket half of the daemon rendezvous.
339#[cfg(unix)]
340const SOCKET_PATH_ENV: &str = "KHIVE_SOCKET";
341
342/// Env var overriding the PID-file half of the daemon rendezvous.
343#[cfg(unix)]
344const PID_PATH_ENV: &str = "KHIVE_PID";
345
346/// Read a path override, treating an empty value as unset.
347///
348/// One predicate for "the operator set this variable", shared by the path
349/// resolvers and [`ensure_rendezvous_overrides_paired`]. A pairing check that
350/// disagreed with the resolvers about what counts as set would either refuse
351/// boots that resolve consistently, or admit the split rendezvous it exists
352/// to stop.
353#[cfg(unix)]
354fn path_override(key: &str) -> Option<PathBuf> {
355    match std::env::var(key) {
356        Ok(p) if !p.is_empty() => Some(PathBuf::from(p)),
357        _ => None,
358    }
359}
360
361#[cfg(unix)]
362fn default_socket_path() -> PathBuf {
363    khive_dir().join("khived.sock")
364}
365
366#[cfg(unix)]
367fn default_pid_path() -> PathBuf {
368    khive_dir().join("khived.pid")
369}
370
371/// Unix socket path the daemon binds and clients connect to.
372///
373/// Overridable via the `KHIVE_SOCKET` env var (for tests and ops), which must
374/// be set together with `KHIVE_PID`: the daemon refuses to boot when exactly
375/// one of the two is set.
376#[cfg(unix)]
377pub fn socket_path() -> PathBuf {
378    path_override(SOCKET_PATH_ENV).unwrap_or_else(default_socket_path)
379}
380
381/// PID file path written by the daemon.
382///
383/// Overridable via the `KHIVE_PID` env var, which must be set together with
384/// `KHIVE_SOCKET`: the daemon refuses to boot when exactly one of the two is
385/// set.
386#[cfg(unix)]
387pub fn pid_path() -> PathBuf {
388    path_override(PID_PATH_ENV).unwrap_or_else(default_pid_path)
389}
390
391/// Refuse to boot when exactly one of `KHIVE_SOCKET` / `KHIVE_PID` is set
392/// (#2656).
393///
394/// The socket and the PID file are the two halves of one rendezvous, but they
395/// resolve independently: with only `KHIVE_SOCKET` set a daemon binds a
396/// private socket while still claiming the shared PID file, and with only
397/// `KHIVE_PID` set it writes a private PID file while binding the shared
398/// socket. Either way [`cleanup_stale_daemon`] reads an incumbent's pid out of
399/// one instance's file and judges it by probing the other instance's socket,
400/// so both of its branches are wrong: a live incumbent produces a refusal
401/// naming a pid that has nothing to do with the socket being started, and a
402/// pid that is no longer running makes this process delete a rendezvous file
403/// another daemon's `shutdown_cleanup_if_owned` still expects to own.
404///
405/// Setting both variables (a fully private rendezvous) and setting neither
406/// (the default rendezvous) are both unchanged.
407#[cfg(unix)]
408fn ensure_rendezvous_overrides_paired() -> anyhow::Result<()> {
409    match (path_override(SOCKET_PATH_ENV), path_override(PID_PATH_ENV)) {
410        (Some(socket), None) => anyhow::bail!(
411            "refusing to start: {SOCKET_PATH_ENV} is set to {} but {PID_PATH_ENV} is not set. \
412             The socket and the PID file are two halves of one daemon rendezvous and must move \
413             together: with only {SOCKET_PATH_ENV} set, this daemon would bind a private socket \
414             while claiming the shared PID file at {}, which belongs to the default rendezvous \
415             served on {}. Set {PID_PATH_ENV} to a private path beside the socket, or unset \
416             {SOCKET_PATH_ENV} to share the default rendezvous.",
417            socket.display(),
418            default_pid_path().display(),
419            default_socket_path().display(),
420        ),
421        (None, Some(pid)) => anyhow::bail!(
422            "refusing to start: {PID_PATH_ENV} is set to {} but {SOCKET_PATH_ENV} is not set. \
423             The socket and the PID file are two halves of one daemon rendezvous and must move \
424             together: with only {PID_PATH_ENV} set, this daemon would write a private PID file \
425             while binding the shared socket at {}, the default rendezvous whose owner is \
426             recorded in {}. Set {SOCKET_PATH_ENV} to a private path beside the PID file, or \
427             unset {PID_PATH_ENV} to share the default rendezvous.",
428            pid.display(),
429            default_socket_path().display(),
430            default_pid_path().display(),
431        ),
432        _ => Ok(()),
433    }
434}
435
436/// Advisory lock file used to serialize stale-daemon recovery across concurrent
437/// clients (flock/`File::lock` on the file; released when the lock file
438/// handle is dropped). Path computation is portable; `kkernel exec`'s
439/// non-unix local-construction guard (`kkernel::exec::acquire_local_construction_guard`)
440/// shares this exact path with the unix daemon-boot guard so the two stay
441/// mutually exclusive.
442///
443/// Overridable via the `KHIVE_LOCK` env var (for tests).
444pub fn lock_path() -> PathBuf {
445    if let Ok(p) = std::env::var("KHIVE_LOCK") {
446        if !p.is_empty() {
447            return PathBuf::from(p);
448        }
449    }
450    khive_dir().join("khived.recovery.lock")
451}
452
453/// Advisory lock file used to serialize RECOVERY (kill+respawn) attempts
454/// across concurrent clients only — the daemon's own boot sequence never
455/// acquires this file ([`lock_path`] / [`acquire_daemon_boot_guard`] is the
456/// boot-side lock). A recoverer holding this lock across dead-confirmation
457/// → kill → spawn (khive-mcp's `kill_and_respawn`) therefore can never
458/// deadlock against a peer daemon's boot, unlike holding the shared boot
459/// lock for that whole span would.
460///
461/// Overridable via the `KHIVE_RECOVERER_LOCK` env var (for tests).
462#[cfg(unix)]
463pub fn recoverer_lock_path() -> PathBuf {
464    if let Ok(p) = std::env::var("KHIVE_RECOVERER_LOCK") {
465        if !p.is_empty() {
466            return PathBuf::from(p);
467        }
468    }
469    khive_dir().join("khived.recoverer.lock")
470}
471
472#[cfg(unix)]
473pub const SUPERVISOR_CLAIM_ENV: &str = "KHIVE_SUPERVISOR_CLAIM";
474
475#[cfg(unix)]
476fn read_supervisor_marker_claim() -> Option<(u32, String)> {
477    use std::io::Read;
478    use std::os::unix::fs::OpenOptionsExt;
479
480    let file = std::fs::OpenOptions::new()
481        .read(true)
482        .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
483        .open(supervisor_marker_path())
484        .ok()?;
485    if !file.metadata().ok()?.is_file() {
486        return None;
487    }
488    let mut marker = String::new();
489    file.take(4097).read_to_string(&mut marker).ok()?;
490    if marker.len() > 4096 {
491        return None;
492    }
493    let mut lines = marker.lines();
494    if lines.next()?.is_empty() {
495        return None;
496    }
497    let pid = lines.next()?.parse::<u32>().ok().filter(|pid| *pid > 0)?;
498    lines
499        .next()?
500        .parse::<u64>()
501        .ok()
502        .filter(|seconds| *seconds > 0)?;
503    let claim = lines.next()?.to_string();
504    if lines.next().is_some() {
505        return None;
506    }
507    let parsed = uuid::Uuid::parse_str(&claim).ok()?;
508    if parsed.get_version() != Some(uuid::Version::Random) || parsed.to_string() != claim {
509        return None;
510    }
511    Some((pid, claim))
512}
513
514#[cfg(unix)]
515fn current_supervisor_claim() -> Option<String> {
516    let claim = std::env::var(SUPERVISOR_CLAIM_ENV).ok()?;
517    let (pid, published_claim) = read_supervisor_marker_claim()?;
518    (pid == std::process::id() && claim == published_claim).then_some(claim)
519}
520
521#[cfg(unix)]
522fn open_lock_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
523    if let Some(parent) = path.parent() {
524        let _ = std::fs::create_dir_all(parent);
525    }
526    std::fs::OpenOptions::new()
527        .create(true)
528        .truncate(false)
529        .write(true)
530        .open(path)
531}
532
533#[cfg(unix)]
534fn acquire_flock_blocking(path: &std::path::Path, label: &str) -> Option<std::fs::File> {
535    let file = match open_lock_file(path) {
536        Ok(f) => f,
537        Err(e) => {
538            tracing::warn!(error = %e, path = ?path, "cannot open {label} lock file");
539            return None;
540        }
541    };
542    // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
543    let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
544    if rc != 0 {
545        tracing::warn!("flock LOCK_EX failed on {label} lock");
546        return None;
547    }
548    Some(file)
549}
550
551/// Acquire an exclusive advisory flock on the recovery/startup lock file.
552///
553/// The returned `File` holds the lock for its lifetime; dropping it releases
554/// it.  Used by both the client (serializing kill+spawn) and the daemon server
555/// (serializing cleanup+bind+pid-write) so the two critical sections are
556/// mutually exclusive across processes.
557#[cfg(unix)]
558pub fn acquire_recovery_lock() -> Option<std::fs::File> {
559    acquire_flock_blocking(&lock_path(), "recovery")
560}
561
562/// Attempt to acquire an exclusive advisory flock on `path`, retrying with a
563/// non-blocking `flock(LOCK_NB)` until `deadline` elapses. Bounded alternative
564/// to `acquire_recovery_lock`/`acquire_daemon_boot_guard`'s unbounded blocking
565/// flock — see `docs/api/daemon.md#try_acquire_flock_until` for why a caller
566/// merely detecting lock freedom needs a deadline instead.
567///
568/// - `Ok(Some(file))` — the lock was free within the deadline.
569/// - `Ok(None)` — `deadline` elapsed while the lock stayed held; an explicit
570///   "could not confirm" outcome, distinct from a hard I/O error.
571/// - `Err(_)` — the lock file could not be opened, or `flock` failed for a
572///   reason other than contention.
573///
574/// Blocking (paces retries with `std::thread::sleep`) — async callers must
575/// run this via `spawn_blocking`.
576#[cfg(unix)]
577fn try_acquire_flock_until(
578    path: &std::path::Path,
579    deadline: std::time::Instant,
580) -> std::io::Result<Option<std::fs::File>> {
581    let file = open_lock_file(path)?;
582    let poll_interval = std::time::Duration::from_millis(10);
583    loop {
584        // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
585        let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
586        if rc == 0 {
587            return Ok(Some(file));
588        }
589        let err = std::io::Error::last_os_error();
590        if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
591            return Err(err);
592        }
593        let now = std::time::Instant::now();
594        if now >= deadline {
595            return Ok(None);
596        }
597        std::thread::sleep(poll_interval.min(deadline - now));
598    }
599}
600
601/// Bounded, deadline-aware variant of [`acquire_daemon_boot_guard`]: attempts
602/// the SAME boot/recovery lock ([`lock_path`]) but gives up at `deadline`
603/// instead of blocking forever. For callers that need to detect "is a boot in
604/// progress right now" without risking an unbounded wait behind a wedged
605/// holder: e.g. khive-mcp's `confirm_genuinely_dead` re-probing rounds,
606/// where `DEAD_CONFIRM_ROUNDS` must bound elapsed time, not just probe count.
607#[cfg(unix)]
608pub fn try_acquire_daemon_boot_guard_until(
609    deadline: std::time::Instant,
610) -> std::io::Result<Option<DaemonBootGuard>> {
611    try_acquire_flock_until(&lock_path(), deadline)
612}
613
614/// Bounded, deadline-aware acquisition of the recoverer-only lock
615/// ([`recoverer_lock_path`]). See [`try_acquire_daemon_boot_guard_until`] for
616/// the shared rationale — a second recoverer waiting for a peer's dead
617/// confirmation/kill/spawn critical section must give up and report
618/// "uncertain" rather than block forever if that peer is itself wedged.
619#[cfg(unix)]
620pub fn try_acquire_recoverer_lock_until(
621    deadline: std::time::Instant,
622) -> std::io::Result<Option<std::fs::File>> {
623    try_acquire_flock_until(&recoverer_lock_path(), deadline)
624}
625
626/// Guard returned by [`acquire_daemon_boot_guard`], held across cold-boot
627/// schema initialization (migrations + pack schema plans / FTS DDL) through
628/// daemon bind + pid-write.
629#[cfg(unix)]
630pub type DaemonBootGuard = std::fs::File;
631
632/// Acquire the recovery/boot lock, treating failure as fatal.
633///
634/// Unlike [`acquire_recovery_lock`] (best-effort, `None` on failure: used by
635/// shutdown cleanup, where skipping unlink is safer than blocking forever),
636/// daemon-mode boot must hold this lock across migrations/FTS DDL through
637/// bind+pid-write. Silently continuing with no lock reopens the cold-boot FTS
638/// race this guard exists to close, so callers that are about to run
639/// daemon-mode boot (or wait for one to quiesce) must fail loudly instead of
640/// proceeding unguarded.
641#[cfg(unix)]
642pub fn acquire_daemon_boot_guard() -> anyhow::Result<DaemonBootGuard> {
643    acquire_recovery_lock()
644        .ok_or_else(|| anyhow::anyhow!("failed to acquire daemon boot/recovery lock"))
645}
646
647/// Identity of a bound Unix socket path, used to tell "the socket I bound" apart
648/// from "a same-path socket some other daemon bound after mine was removed".
649///
650/// A socket path can be recreated by a different process between the time
651/// this daemon captures its identity and the time it later checks it, so `dev`
652/// and `ino` (not the path) are what must match for cleanup to be safe.
653#[cfg(unix)]
654#[derive(Clone, Copy, PartialEq, Eq)]
655struct SocketIdentity {
656    dev: u64,
657    ino: u64,
658}
659
660#[cfg(unix)]
661fn socket_identity(path: &std::path::Path) -> Option<SocketIdentity> {
662    use std::os::unix::fs::MetadataExt;
663    let meta = std::fs::metadata(path).ok()?;
664    Some(SocketIdentity {
665        dev: meta.dev(),
666        ino: meta.ino(),
667    })
668}
669
670// ── connection principal ──────────────────────────────────────────────────────
671
672/// The uid on the other end of an accepted connection, read from the kernel.
673///
674/// This is the only identity on this socket the caller cannot choose. Every
675/// identity field on the request frame — `namespace`, `actor_id`,
676/// `visible_namespaces`, `config_id` — is supplied by the connecting process,
677/// so none of them can answer "who is this". A check reading self-asserted
678/// fields is not a weak gate, it is not a gate: anyone who wants to pass it
679/// asserts the passing values.
680///
681/// `getpeereid(2)` on macOS/BSD, `SO_PEERCRED` on Linux. Both report the peer's
682/// credentials as recorded by the kernel at connect time.
683#[cfg(unix)]
684pub(crate) fn peer_uid(stream: &UnixStream) -> std::io::Result<u32> {
685    use std::os::fd::AsRawFd;
686    let fd = stream.as_raw_fd();
687
688    #[cfg(any(target_os = "macos", target_os = "ios", target_vendor = "apple"))]
689    {
690        let mut uid: libc::uid_t = 0;
691        let mut gid: libc::gid_t = 0;
692        // SAFETY: `fd` is a live connected socket owned by `stream` for the
693        // duration of this call; both out-params are valid initialized locals.
694        let rc = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
695        if rc != 0 {
696            return Err(std::io::Error::last_os_error());
697        }
698        Ok(uid as u32)
699    }
700
701    #[cfg(target_os = "linux")]
702    {
703        let mut cred = libc::ucred {
704            pid: 0,
705            uid: 0,
706            gid: 0,
707        };
708        let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
709        // SAFETY: `fd` is a live connected socket owned by `stream`; `cred` is
710        // an initialized local of exactly `len` bytes, which is what
711        // SO_PEERCRED writes.
712        let rc = unsafe {
713            libc::getsockopt(
714                fd,
715                libc::SOL_SOCKET,
716                libc::SO_PEERCRED,
717                (&mut cred as *mut libc::ucred).cast::<libc::c_void>(),
718                &mut len,
719            )
720        };
721        if rc != 0 {
722            return Err(std::io::Error::last_os_error());
723        }
724        Ok(cred.uid)
725    }
726
727    #[cfg(not(any(
728        target_os = "linux",
729        target_os = "macos",
730        target_os = "ios",
731        target_vendor = "apple"
732    )))]
733    {
734        let _ = fd;
735        Err(std::io::Error::new(
736            std::io::ErrorKind::Unsupported,
737            "peer-credential capture is not implemented for this platform",
738        ))
739    }
740}
741
742/// Whether a connection from `uid` may be served by this daemon.
743///
744/// ADR-096 accepted per-request identity threading **for the single-principal
745/// owner-only socket only**, and rested that on three things: the `0600`
746/// socket, all connections being the same uid, and the database being already
747/// same-uid-accessible. The socket mode is asserted at bind. This asserts the
748/// second, which previously had no representation in the code at all — nothing
749/// read peer identity, so nothing could notice when it stopped being true.
750///
751/// **Principal is not attribution.** Many `actor_id`s over one socket is
752/// exactly what ADR-096 shipped and what every seat on a normal host does;
753/// refusing a second distinct actor would break the accepted design. The
754/// principal is the uid, and this refuses only a genuinely foreign one.
755///
756/// **There is deliberately no configuration escape hatch.** A flag permitting
757/// other uids would not weaken this assertion, it would delete it, in the way
758/// hardest to notice later: the check still exists, its tests still pass, and
759/// the deployment that matters has it off. A deployment that genuinely needs
760/// multiple uids needs a code change and a gated ADR — which is precisely the
761/// decision that should be impossible to make by accident.
762#[cfg(unix)]
763pub(crate) fn uid_is_permitted(peer: u32, daemon_euid: u32) -> bool {
764    peer == daemon_euid
765}
766
767// ── wire types ────────────────────────────────────────────────────────────────
768
769mod config_id;
770#[cfg(test)]
771use config_id::parse_config_id;
772pub use config_id::{
773    config_id_extra_embedder_exclusions, config_ids_compatible, first_config_mismatch_field,
774};
775
776/// Request frame sent from a client to the daemon.
777#[derive(Serialize, Deserialize, Default)]
778pub struct DaemonRequestFrame {
779    pub ops: String,
780    /// Parse and inspect the catalog without dispatch, identity, or storage access.
781    #[serde(default)]
782    pub plan: bool,
783    #[serde(skip_serializing_if = "Option::is_none")]
784    pub presentation: Option<String>,
785    #[serde(skip_serializing_if = "Option::is_none")]
786    pub presentation_per_op: Option<Vec<Option<String>>>,
787    /// The client's resolved storage/gate default namespace for this request.
788    ///
789    /// As of protocol version 3 (ADR-096) the daemon serves the request under
790    /// this namespace instead of rejecting on mismatch: a per-request
791    /// identity input, not a same-process-identity assertion.
792    pub namespace: String,
793    /// The client's resolved write-stamp / gate actor identity (ADR-057),
794    /// carried on the frame so the warm daemon stamps writes with the
795    /// *caller's* actor instead of its own baked `actor_id` (ADR-096). `None`
796    /// mints `ActorRef::anonymous()`, matching an unconfigured actor.
797    #[serde(default)]
798    pub actor_id: Option<String>,
799    /// Opaque process provenance resolved in the originating client process.
800    /// It is carried per request because a shared warm daemon's environment
801    /// does not identify the worker that submitted the operation. Protocol v4
802    /// makes this field part of dispatch semantics: a v3 daemon must reject the
803    /// request rather than execute it while silently discarding provenance.
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub process_ref: Option<String>,
806    /// The client's resolved extra read-visibility namespaces (ADR-007 Rule
807    /// 3b), carried on the frame so the warm daemon widens read scope to
808    /// match the caller's own configuration rather than its own baked
809    /// `visible_namespaces` (ADR-096). A non-`local` `actor_id` joins default
810    /// reads where the registry mints the token (ADR-007 Rev 4 Rule 3b), so
811    /// an empty list still includes that actor in default reads. Explicit
812    /// `namespace=` operations remain scoped to exactly that namespace.
813    #[serde(default)]
814    pub visible_namespaces: Vec<String>,
815    /// Fingerprint of the client's engine-coherence config: packs, db target,
816    /// embedders, backend routing, and construction-baked outbound policy.
817    /// Identity fields are carried separately in this frame. The daemon rejects
818    /// requests whose configuration differs, except when its extra-embedder set
819    /// is a superset of the client's and every other field matches. See
820    /// ADR-027 / ADR-049 / ADR-096.
821    #[serde(default)]
822    pub config_id: String,
823    /// IPC protocol version sent by the client. Pre-versioning clients omit
824    /// this field (deserializes to 0). The daemon compares against
825    /// [`PROTOCOL_VERSION`] and rejects mismatches with an explicit error.
826    #[serde(default)]
827    pub protocol_version: u32,
828    /// When `true`, the daemon returns an identity frame (ok=true, result=None)
829    /// immediately after identity validation — without calling the dispatcher.
830    /// Used by the client's under-lock recovery probe to confirm a daemon is
831    /// alive and identity-matching without dispatching any mutating verb.
832    /// Pre-probe clients omit this field (deserializes to false → normal dispatch).
833    #[serde(default)]
834    pub probe_only: bool,
835    /// When `true`, the daemon returns a point-in-time [`MetricsSnapshot`] of
836    /// its server-side gauges (a read-only measurement surface for the
837    /// load/perf harness) instead of dispatching any op. Handled before the
838    /// `config_id` equality reject: a gauge read is process-global and
839    /// namespace/config-agnostic, not a namespaced record operation.
840    /// READ-ONLY — this field is the only input the frame accepts for a
841    /// metrics request; there is no reset or mutation reachable over the
842    /// wire. Pre-metrics clients omit this field (deserializes to `false` →
843    /// normal dispatch, unaffected).
844    #[serde(default)]
845    pub metrics_only: bool,
846    /// Output format for this request (ADR-078). Forwarded to the daemon's
847    /// serialization seam. `None` means use the daemon's resolved default.
848    #[serde(default)]
849    #[serde(skip_serializing_if = "Option::is_none")]
850    pub format: Option<String>,
851    /// Per-operation output format overrides (ADR-078).
852    #[serde(default)]
853    #[serde(skip_serializing_if = "Option::is_none")]
854    pub format_per_op: Option<Vec<Option<String>>>,
855    /// Whether this request originated from the agent-facing MCP `request`
856    /// tool (the wire surface). When `true`, the daemon rejects
857    /// `Visibility::Subhandler` verbs: agents must not invoke internal
858    /// subhandlers. When `false` (the default, and the only value any
859    /// operator path sends), subhandlers are allowed: `kkernel exec` and
860    /// other in-process callers are trusted operator surfaces.
861    ///
862    /// This is the origin discriminator, not a daemon-vs-local one: operator
863    /// requests flow through the daemon by default too, so the gate cannot
864    /// key on transport.
865    #[serde(default)]
866    pub from_wire: bool,
867    /// Request-group correlation id (khive#948), echoed back unchanged on
868    /// [`DaemonResponseFrame::request_id`] and stamped into the dispatch's
869    /// audit event (`resource.request_id`) so a benchmark harness can join
870    /// its own pre-send sample to the server-side audit row for the same
871    /// request. Agent-facing MCP requests always carry one: the bridge keeps a
872    /// caller-supplied value or mints an opaque nonzero value when absent.
873    /// Operator-built/probe frames may still use `None`. Purely additive —
874    /// `#[serde(default)]` matches `metrics_only`/`format`/`format_per_op`
875    /// precedent, with no `PROTOCOL_VERSION` bump.
876    #[serde(default)]
877    #[serde(skip_serializing_if = "Option::is_none")]
878    pub request_id: Option<u64>,
879}
880
881/// A dispatch failure whose domain outcome remains available to the transport.
882#[derive(Debug, Clone)]
883pub struct DaemonDispatchError {
884    pub message: String,
885    pub error_detail: serde_json::Value,
886}
887
888/// Per-field container limit, asserted equal to the request parser's bound by MCP.
889pub const ERROR_DETAIL_NESTING_DEPTH_LIMIT: usize = 64;
890
891fn error_detail_value_within_limit(value: &serde_json::Value) -> bool {
892    let mut pending = vec![(value, 0_usize)];
893    while let Some((value, depth)) = pending.pop() {
894        match value {
895            serde_json::Value::Array(items) if depth < ERROR_DETAIL_NESTING_DEPTH_LIMIT => {
896                pending.extend(items.iter().map(|child| (child, depth + 1)));
897            }
898            serde_json::Value::Object(fields) if depth < ERROR_DETAIL_NESTING_DEPTH_LIMIT => {
899                pending.extend(fields.values().map(|child| (child, depth + 1)));
900            }
901            serde_json::Value::Array(_) | serde_json::Value::Object(_) => return false,
902            _ => {}
903        }
904    }
905    true
906}
907
908fn drop_error_detail_iteratively(value: serde_json::Value) {
909    let mut pending = vec![value];
910    while let Some(value) = pending.pop() {
911        match value {
912            serde_json::Value::Array(items) => pending.extend(items),
913            serde_json::Value::Object(fields) => pending.extend(fields.into_values()),
914            _ => {}
915        }
916    }
917}
918
919impl DaemonDispatchError {
920    /// Missing or unrecognized disposition from a legacy implementation is unknown.
921    pub fn new(message: impl Into<String>, error_detail: Option<serde_json::Value>) -> Self {
922        let message = message.into();
923        let mut fields = match error_detail {
924            Some(serde_json::Value::Object(fields)) => fields,
925            Some(data) => serde_json::Map::from_iter([("data".to_string(), data)]),
926            None => serde_json::Map::new(),
927        };
928        let disposition = match fields
929            .get("domain_disposition")
930            .and_then(serde_json::Value::as_str)
931        {
932            Some("committed") => crate::DomainDisposition::Committed,
933            Some("not_committed") => crate::DomainDisposition::NotCommitted,
934            _ => crate::DomainDisposition::Unknown,
935        };
936        if disposition != crate::DomainDisposition::Committed {
937            if let Some(result) = fields.remove("domain_result") {
938                drop_error_detail_iteratively(result);
939            }
940        }
941        let rejected: Vec<String> = fields
942            .iter()
943            .filter(|(_, value)| !error_detail_value_within_limit(value))
944            .map(|(name, _)| name.clone())
945            .collect();
946        let omitted_result = rejected.iter().any(|name| name == "domain_result");
947        let omitted_detail = !rejected.is_empty();
948        for name in rejected {
949            if let Some(value) = fields.remove(&name) {
950                drop_error_detail_iteratively(value);
951            }
952        }
953        let mut error_detail = serde_json::Value::Object(fields);
954        if error_detail["kind"].as_str().is_none() {
955            error_detail["kind"] = serde_json::json!("internal");
956        }
957        if error_detail["message"].as_str().is_none() {
958            error_detail["message"] = serde_json::json!(message);
959        }
960        error_detail["domain_disposition"] = serde_json::json!(disposition.as_str());
961        if omitted_detail {
962            error_detail["code"] = serde_json::json!(if omitted_result {
963                "result_too_deep"
964            } else {
965                "error_detail_too_deep"
966            });
967        }
968        Self {
969            message,
970            error_detail,
971        }
972    }
973}
974
975/// Response frame sent from the daemon back to a client.
976#[derive(Serialize, Deserialize, Debug)]
977pub struct DaemonResponseFrame {
978    pub ok: bool,
979    pub result: Option<String>,
980    pub error: Option<String>,
981    /// Additive failure metadata; legacy protocol-v4 peers still read `error` as text.
982    /// On a successful response, `{"lexical_timeout":true}` is a daemon-only
983    /// diagnostic that old clients ignore and new clients log locally.
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub error_detail: Option<serde_json::Value>,
986    pub namespace_mismatch: bool,
987    /// Set when the request's `config_id` does not match the daemon's. Like
988    /// `namespace_mismatch`, this signals the client to fall back to local
989    /// dispatch rather than execute under a different runtime/config.
990    #[serde(default)]
991    pub config_mismatch: bool,
992    /// The `config_id` the daemon dispatched under, echoed back so the client
993    /// can positively confirm the result came from a matching runtime. A
994    /// pre-`config_id` daemon omits this field (deserializes to `None`), which
995    /// the client treats as a mismatch and falls back to local dispatch — this
996    /// closes the upgrade window where a new restricted client could otherwise
997    /// trust a still-warm legacy daemon's broader registry.
998    #[serde(default)]
999    pub served_config_id: Option<String>,
1000    /// Set when the client's `protocol_version` does not match the daemon's
1001    /// [`PROTOCOL_VERSION`]. The client must treat this as a hard error and
1002    /// surface the human-readable `error` field rather than falling back to
1003    /// local dispatch (which would hide the version skew).
1004    #[serde(default)]
1005    pub version_mismatch: bool,
1006    /// The daemon's [`PROTOCOL_VERSION`], echoed in error responses so the
1007    /// client can include both sides in the diagnostic message. Pre-versioning
1008    /// daemons omit this field (deserializes to 0).
1009    #[serde(default)]
1010    pub daemon_protocol_version: u32,
1011    /// Populated when the request set `metrics_only: true`: a point-in-time
1012    /// snapshot of the daemon's server-side gauges. `None` on every other
1013    /// response, and on any response from a daemon that predates this field
1014    /// (client-side back-compat via `#[serde(default)]`, matching
1015    /// `served_config_id`'s upgrade-window handling above).
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub metrics: Option<MetricsSnapshot>,
1018    /// Echo of the request's `request_id` (khive#948), present whenever the
1019    /// frame that produced this response carried one — including on every
1020    /// error/denied arm, not only success, so a client can join a failure
1021    /// the same way it joins a success. `#[serde(default)]` so an older
1022    /// daemon's response (predating this field) deserializes to `None`
1023    /// rather than a parse error.
1024    #[serde(default)]
1025    pub request_id: Option<u64>,
1026}
1027
1028/// Move the private dispatch signal out of the result before any client can
1029/// observe it. Unmarked results retain their exact bytes. The marked result
1030/// was serialized from a JSON Value by the MCP server, so reserializing after
1031/// removal reproduces its public envelope. The marker's escaped frame cost
1032/// equals `error_detail:{"lexical_timeout":true}`, keeping the server's exact
1033/// frame-fit calculation valid after this move.
1034#[cfg(unix)]
1035fn take_daemon_lexical_timeout_marker(raw: String) -> (String, Option<serde_json::Value>) {
1036    if !raw.contains(DAEMON_LEXICAL_TIMEOUT_MARKER) {
1037        return (raw, None);
1038    }
1039    let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&raw) else {
1040        return (raw, None);
1041    };
1042    let Some(fields) = value.as_object_mut() else {
1043        return (raw, None);
1044    };
1045    if !fields
1046        .get("results")
1047        .is_some_and(serde_json::Value::is_array)
1048    {
1049        return (raw, None);
1050    }
1051    let Some(marker) = fields.remove(DAEMON_LEXICAL_TIMEOUT_MARKER) else {
1052        return (raw, None);
1053    };
1054    let detail =
1055        (marker.as_bool() == Some(true)).then(|| serde_json::json!({"lexical_timeout": true}));
1056    (
1057        serde_json::to_string(&value).expect("serde_json::Value is serializable"),
1058        detail,
1059    )
1060}
1061
1062/// One checkpoint store in this daemon's fixed topology. IDs are process-local:
1063/// `main` or `secondary:<index>` in dispatcher order. The basename is display-only,
1064/// not an identity; no directory path is exposed. Restart/topology changes reset
1065/// the interpretation of interval deltas.
1066#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
1067#[serde(default)]
1068pub struct CheckpointStoreMetrics {
1069    pub store_id: String,
1070    pub role: String,
1071    pub database: Option<String>,
1072    #[serde(flatten)]
1073    pub timing: khive_db::checkpoint::CheckpointTiming,
1074}
1075
1076/// Point-in-time snapshot of the daemon's server-side gauges — the
1077/// load/perf harness read-surface (measurement substrate, not a product feature).
1078///
1079/// Every field here is a **server-side** gauge reachable from `handle_conn`
1080/// without any mutation: [`khive_storage::tx_registry`] (ADR-091 Plank 0,
1081/// process-global singleton), the main pool's backend-keyed routine WAL
1082/// sample and process TRUNCATE counters (`khive_db::checkpoint`), and the
1083/// ADR-067 Component A write queue depth/latest writer-stage sample. There is
1084/// no reset reachable through this type or through [`DaemonRequestFrame`] —
1085/// gauges out, nothing in.
1086#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
1087pub struct MetricsSnapshot {
1088    /// Launch mode and the current lifecycle of this daemon incarnation.
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub lifecycle: Option<DaemonLifecycleSnapshot>,
1091    /// Last-observed WAL page count from the periodic checkpoint tick.
1092    /// `None` when the checkpoint task has never ticked in this process
1093    /// (for example, an in-memory dispatcher with no pool, or a daemon that
1094    /// just started and hasn't hit its first tick yet).
1095    pub wal_pages: Option<u64>,
1096    /// Logical frames present in the WAL at the main backend's most recent
1097    /// periodic PASSIVE checkpoint. Kept separate from physical allocation.
1098    #[serde(default)]
1099    pub wal_log_frames: Option<u64>,
1100    /// Frames backfilled by that same periodic PASSIVE pass.
1101    #[serde(default)]
1102    pub wal_checkpointed_frames: Option<u64>,
1103    /// Logical frames still pending after that pass (`log - checkpointed`).
1104    #[serde(default)]
1105    pub wal_pending_frames: Option<u64>,
1106    /// Physical `-wal` sidecar bytes captured at the same routine tick.
1107    #[serde(default)]
1108    pub wal_physical_bytes: Option<u64>,
1109    /// Wall-clock timestamp of the routine WAL sample, for staleness checks.
1110    #[serde(default)]
1111    pub wal_observed_at_unix_ms: Option<u64>,
1112    /// Cumulative actual routine PASSIVE calls for each store this daemon
1113    /// checkpoints. Microseconds preserve sub-millisecond costs; max is since
1114    /// process start, not an interval maximum. Counters saturate rather than wrap.
1115    #[serde(default)]
1116    pub wal_checkpoint_stores: Vec<CheckpointStoreMetrics>,
1117    /// Total WAL TRUNCATE escalation attempts (ADR-091 Plank 2) made in this
1118    /// process's lifetime, regardless of whether they succeeded in reclaiming
1119    /// pages.
1120    pub wal_truncate_attempts: u64,
1121    /// Current consecutive-failure count for TRUNCATE attempts that failed to
1122    /// bring the WAL back below `warn_pages`; resets to 0 the next time an
1123    /// attempt clears it.
1124    pub wal_truncate_consecutive_failures: u64,
1125    /// Total checkpoint ticks skipped because the dedicated checkpoint
1126    /// connection was unavailable (ADR-091 checkpoint-pressure telemetry),
1127    /// across this process's lifetime. `#[serde(default)]` so an older client
1128    /// decoding a newer daemon's snapshot (or vice versa) does not fail.
1129    #[serde(default)]
1130    pub wal_checkpoint_skipped_ticks: u64,
1131    /// Current consecutive-skip run length; 0 once the next tick is observed.
1132    #[serde(default)]
1133    pub wal_checkpoint_consecutive_skips: u64,
1134    /// WAL page count last known at the time of the most recent skip, if any
1135    /// skip has occurred yet in this process.
1136    #[serde(default)]
1137    pub wal_checkpoint_last_skip_wal_pages: Option<u64>,
1138    /// Age, in microseconds, of the oldest currently-open transaction
1139    /// registry entry (ADR-091 Plank 0). `None` when no transaction is
1140    /// currently open.
1141    pub oldest_pinned_tx_micros: Option<u64>,
1142    /// Diagnostic label of the oldest currently-open transaction registry
1143    /// entry, if any and if it was registered with one.
1144    pub oldest_pinned_tx_label: Option<String>,
1145    /// Number of currently open transaction registry entries.
1146    pub open_tx_count: usize,
1147    /// Current write-queue backlog depth (ADR-067 Component A): requests
1148    /// enqueued but not yet accepted by the `WriterTask` drain loop. `None`
1149    /// unless the write queue is enabled (`KHIVE_WRITE_QUEUE=1`) and a
1150    /// file-backed pool is available.
1151    pub write_queue_depth: Option<usize>,
1152    /// The write queue's configured bounded capacity
1153    /// (`PoolConfig::write_queue_capacity`), gated the same as
1154    /// `write_queue_depth`.
1155    pub write_queue_capacity: Option<usize>,
1156    /// Latest completed writer-task span: bounded-channel admission/backlog.
1157    #[serde(default)]
1158    pub write_last_queue_wait_micros: Option<u64>,
1159    /// Latest completed writer-task span: `BEGIN IMMEDIATE` acquisition.
1160    #[serde(default)]
1161    pub write_last_transaction_acquire_micros: Option<u64>,
1162    /// Latest completed writer-task span: application transaction body.
1163    #[serde(default)]
1164    pub write_last_body_micros: Option<u64>,
1165    /// Latest completed writer-task span: SQLite COMMIT/fsync phase.
1166    #[serde(default)]
1167    pub write_last_commit_micros: Option<u64>,
1168    /// Whole latest writer-task request span, retained for compatibility and
1169    /// comparison with the decomposed stages.
1170    #[serde(default)]
1171    pub write_last_total_micros: Option<u64>,
1172    /// Wall-clock timestamp of the writer-stage sample.
1173    #[serde(default)]
1174    pub write_last_observed_at_unix_ms: Option<u64>,
1175    /// Connection cap of the daemon socket this snapshot was served from.
1176    /// `None` from a daemon that predates the cap and when no listener owns
1177    /// the connection.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub connections: Option<ConnectionCapSnapshot>,
1180    /// Bounds and counts of the best-effort recall serve-ledger tasks.
1181    /// `None` from a daemon that predates the bound.
1182    #[serde(default, skip_serializing_if = "Option::is_none")]
1183    pub recall_ledger: Option<RecallLedgerSnapshot>,
1184}
1185
1186// ── framing ───────────────────────────────────────────────────────────────────
1187
1188/// Read one length-prefixed frame (4-byte BE u32 length + JSON bytes).
1189#[cfg(unix)]
1190pub async fn read_frame<R>(stream: &mut R) -> std::io::Result<Vec<u8>>
1191where
1192    R: tokio::io::AsyncRead + Unpin,
1193{
1194    let mut len_buf = [0u8; 4];
1195    stream.read_exact(&mut len_buf).await?;
1196    let len = u32::from_be_bytes(len_buf) as usize;
1197    if len > MAX_FRAME_BYTES {
1198        return Err(std::io::Error::new(
1199            std::io::ErrorKind::InvalidData,
1200            format!("daemon frame of {len} bytes exceeds {MAX_FRAME_BYTES} cap"),
1201        ));
1202    }
1203    let mut buf = vec![0u8; len];
1204    stream.read_exact(&mut buf).await?;
1205    Ok(buf)
1206}
1207
1208#[cfg(unix)]
1209fn initial_frame_timeout_error() -> std::io::Error {
1210    std::io::Error::new(
1211        std::io::ErrorKind::TimedOut,
1212        "daemon initial request frame read timed out",
1213    )
1214}
1215
1216#[cfg(unix)]
1217async fn read_initial_frame<R>(
1218    stream: &mut R,
1219    deadline: tokio::time::Instant,
1220) -> std::io::Result<Vec<u8>>
1221where
1222    R: tokio::io::AsyncRead + Unpin,
1223{
1224    // Tokio polls the inner future before checking its timer. A frame already
1225    // buffered when a delayed connection task first runs would otherwise pass
1226    // even though its acceptance-time deadline has expired.
1227    if tokio::time::Instant::now() >= deadline {
1228        return Err(initial_frame_timeout_error());
1229    }
1230    let raw = tokio::time::timeout_at(deadline, read_frame(stream))
1231        .await
1232        .map_err(|_| initial_frame_timeout_error())??;
1233    if tokio::time::Instant::now() >= deadline {
1234        return Err(initial_frame_timeout_error());
1235    }
1236    Ok(raw)
1237}
1238
1239/// Write one length-prefixed frame.
1240#[cfg(unix)]
1241pub async fn write_frame<W>(stream: &mut W, payload: &[u8]) -> std::io::Result<()>
1242where
1243    W: tokio::io::AsyncWrite + Unpin,
1244{
1245    if payload.len() > MAX_FRAME_BYTES {
1246        return Err(std::io::Error::new(
1247            std::io::ErrorKind::InvalidData,
1248            format!(
1249                "daemon frame of {} bytes exceeds {MAX_FRAME_BYTES} cap",
1250                payload.len()
1251            ),
1252        ));
1253    }
1254    let len = (payload.len() as u32).to_be_bytes();
1255    stream.write_all(&len).await?;
1256    stream.write_all(payload).await?;
1257    stream.flush().await?;
1258    Ok(())
1259}
1260
1261// ── dispatch trait ────────────────────────────────────────────────────────────
1262
1263/// Transport-agnostic dispatch interface for the daemon server.
1264///
1265/// The MCP crate implements this by dispatching through the shared request body
1266/// while honoring [`DaemonRequestFrame::from_wire`] (so subhandler visibility is
1267/// gated by request origin, not by transport); any future transport can do the
1268/// same.
1269#[cfg(unix)]
1270#[async_trait]
1271pub trait DaemonDispatch: Clone + Send + Sync + 'static {
1272    /// Named retained resources or unknown inventory that prevents idle exit.
1273    /// An implementor must explicitly account for its resources before retiring.
1274    fn idle_retirement_blockers(&self) -> Vec<String> {
1275        vec!["dispatcher_resource_inventory_unknown".to_owned()]
1276    }
1277
1278    /// Describe syntax and loaded catalog membership without dispatching.
1279    fn plan(&self, ops: &str) -> String;
1280
1281    /// Dispatch a verb-DSL request string and return the rendered result.
1282    ///
1283    /// `from_wire` carries the origin discriminator from
1284    /// [`DaemonRequestFrame::from_wire`]: when `true`, the implementor enforces
1285    /// verb visibility (rejects `Visibility::Subhandler` verbs); when `false`,
1286    /// the request is from a trusted operator surface and subhandlers pass.
1287    ///
1288    /// `identity` is the per-request identity context threaded from the frame
1289    /// (ADR-096): `Some(..)` when serving a request forwarded over the
1290    /// daemon socket (built from `frame.namespace` / `frame.actor_id` /
1291    /// `frame.visible_namespaces` by the connection handler), `None` for any
1292    /// other dispatch path. Implementors should mint the storage/gate token from
1293    /// `identity` when present and fall back to their own construction-baked
1294    /// identity when absent, so pure local (non-daemon) dispatch is unchanged.
1295    #[allow(clippy::too_many_arguments)]
1296    async fn dispatch(
1297        &self,
1298        ops: String,
1299        presentation: Option<String>,
1300        presentation_per_op: Option<Vec<Option<String>>>,
1301        format: Option<String>,
1302        format_per_op: Option<Vec<Option<String>>>,
1303        from_wire: bool,
1304        identity: Option<RequestIdentity>,
1305    ) -> Result<String, String>;
1306
1307    /// Read-deadline ceiling for one request. The default is the operator
1308    /// ceiling; an implementor that understands the request may grant a
1309    /// longer bounded allowance (a long poll's declared wait plus a margin).
1310    fn request_read_timeout(&self, _ops: &str) -> std::time::Duration {
1311        khive_storage::request_read_timeout_from_env()
1312    }
1313
1314    /// Preserve structured dispatch errors without breaking string-only implementors.
1315    #[allow(clippy::too_many_arguments)]
1316    async fn dispatch_with_error_detail(
1317        &self,
1318        ops: String,
1319        presentation: Option<String>,
1320        presentation_per_op: Option<Vec<Option<String>>>,
1321        format: Option<String>,
1322        format_per_op: Option<Vec<Option<String>>>,
1323        from_wire: bool,
1324        identity: Option<RequestIdentity>,
1325    ) -> Result<String, DaemonDispatchError> {
1326        self.dispatch(
1327            ops,
1328            presentation,
1329            presentation_per_op,
1330            format,
1331            format_per_op,
1332            from_wire,
1333            identity,
1334        )
1335        .await
1336        .map_err(|message| DaemonDispatchError::new(message, None))
1337    }
1338
1339    /// Warm every pack's in-memory state (ANN indexes, etc.).
1340    async fn warm_all(&self);
1341
1342    /// The namespace this dispatcher was configured for.
1343    fn namespace(&self) -> &str;
1344
1345    /// Fingerprint of this dispatcher's resolved runtime config (packs, db
1346    /// target, embedders). Used to reject forwarded requests from clients whose
1347    /// config differs, so a restricted client cannot dispatch through a broader
1348    /// daemon.
1349    fn config_id(&self) -> &str;
1350
1351    /// Return the pool to use for background WAL checkpointing, if available.
1352    ///
1353    /// Implementors backed by a file-based SQLite database should return
1354    /// `Some(pool_arc)`. In-memory or test dispatchers that have no pool
1355    /// return `None` and the checkpoint task is not spawned.
1356    ///
1357    /// The default implementation returns `None`.
1358    fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
1359        None
1360    }
1361
1362    /// File-backed backend pools beyond [`Self::pool_for_checkpoint`]'s pool
1363    /// (ADR-091 Amendment 3): one checkpoint task is spawned per entry here,
1364    /// in addition to the one spawned for the primary pool, so a
1365    /// multi-backend deployment gets PASSIVE/TRUNCATE checkpointing and
1366    /// sidecar enumeration on every file-backed backend it wired, not only
1367    /// the main one.
1368    ///
1369    /// The default implementation returns an empty `Vec` — an implementor
1370    /// with only one backend (or none) needs no override.
1371    fn secondary_pools_for_checkpoint(&self) -> Vec<Arc<ConnectionPool>> {
1372        Vec::new()
1373    }
1374
1375    /// Return the audit `EventStore` the checkpoint task should append
1376    /// ADR-094 lifecycle events (`CheckpointOutcomeRecorded`) to, if any.
1377    ///
1378    /// Mirrors [`Self::pool_for_checkpoint`]'s default-`None` shape: an
1379    /// implementor with no configured event store (or no pool at all) simply
1380    /// gets a checkpoint task that never appends events — the checkpoint
1381    /// task itself remains fully functional either way.
1382    ///
1383    /// The default implementation returns `None`.
1384    fn event_store_for_checkpoint(&self) -> Option<Arc<dyn khive_storage::EventStore>> {
1385        None
1386    }
1387}
1388
1389#[cfg(unix)]
1390struct CheckpointTaskSpec {
1391    pool: Arc<ConnectionPool>,
1392    lifecycle_owner: Option<CheckpointLifecycleOwner>,
1393    is_main: bool,
1394}
1395
1396/// Build checkpoint-task fan-out and designate one lifecycle owner.
1397///
1398/// The main checkpoint task owns lifecycle emission when it exists. If the
1399/// main backend is in-memory and therefore has no checkpoint task, the first
1400/// file-backed secondary owns emission instead. All remaining tasks are
1401/// explicit non-owners.
1402#[cfg(unix)]
1403fn checkpoint_task_specs(
1404    main_pool: Option<Arc<ConnectionPool>>,
1405    secondary_pools: Vec<Arc<ConnectionPool>>,
1406    event_store: Option<Arc<dyn khive_storage::EventStore>>,
1407    namespace: String,
1408) -> Vec<CheckpointTaskSpec> {
1409    let mut tasks = Vec::with_capacity(usize::from(main_pool.is_some()) + secondary_pools.len());
1410    if let Some(pool) = main_pool {
1411        tasks.push(CheckpointTaskSpec {
1412            pool,
1413            lifecycle_owner: None,
1414            is_main: true,
1415        });
1416    }
1417    tasks.extend(secondary_pools.into_iter().map(|pool| CheckpointTaskSpec {
1418        pool,
1419        lifecycle_owner: None,
1420        is_main: false,
1421    }));
1422
1423    if let (Some(task), Some(event_store)) = (tasks.first_mut(), event_store) {
1424        task.lifecycle_owner = Some(CheckpointLifecycleOwner::new(event_store, namespace));
1425    }
1426    tasks
1427}
1428
1429// ── tracked background tasks ─────────────────────────────────────────────────
1430//
1431// Pack handlers (e.g. memory.recall's ADR-081 serve-ledger append) fire
1432// fire-and-forget `tokio::spawn`ed work off the response path so the caller
1433// never waits on a cross-pack dispatch or a SQL write. Left untracked, that
1434// work is invisible to `drain()`: a SIGTERM landing between the response
1435// returning and the spawned task completing can abort it mid-flight with no
1436// log and no row. `track_background_task` gives such spawns a process-wide
1437// presence that `drain()` waits on, exactly like the `active` counter does
1438// for in-flight connections: the caller still only pays for the spawn +
1439// counter increment, never the task's own work.
1440/// Set once by the boot path that takes the daemon role, and never cleared: a
1441/// process that is not the warm daemon has no path to becoming one except exec.
1442static WARM_INDEX_HOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1443
1444/// Declare this process the warm index host. Called by the serve path as soon as
1445/// the daemon role is decided, before any runtime is built, so nothing warms
1446/// under the wrong answer.
1447pub fn mark_warm_index_host() {
1448    WARM_INDEX_HOST.store(true, std::sync::atomic::Ordering::Release);
1449}
1450
1451/// Whether this process is the warm index host.
1452///
1453/// Building an ANN index from the full corpus is minutes of CPU and hundreds of
1454/// megabytes of segment rewrite, and it pays for itself only across a process
1455/// that outlives the request. A short-lived client that does it pays the whole
1456/// cost, discards the result at exit, and publishes a checkpoint that every
1457/// other reader on the root must then re-read. Consumers use this to decide
1458/// whether to build or to serve degraded and let the daemon build.
1459pub fn is_warm_index_host() -> bool {
1460    WARM_INDEX_HOST.load(std::sync::atomic::Ordering::Acquire)
1461}
1462
1463static BACKGROUND_TASKS: std::sync::OnceLock<Arc<std::sync::atomic::AtomicUsize>> =
1464    std::sync::OnceLock::new();
1465
1466fn background_tasks() -> &'static Arc<std::sync::atomic::AtomicUsize> {
1467    BACKGROUND_TASKS.get_or_init(|| Arc::new(std::sync::atomic::AtomicUsize::new(0)))
1468}
1469
1470/// Decrements the shared background-task counter from `Drop`, so the count
1471/// comes back down whether the tracked future returns normally, panics, or
1472/// is cancelled — a plain post-`await` `fetch_sub` only covers the return
1473/// path and leaks the count forever on a panic, since unwinding skips every
1474/// statement after the panic point.
1475// ── outstanding background-task names ────────────────────────────────────────
1476//
1477// Names of the tasks the background counter is currently holding, so a drain
1478// timeout can say which ones held it open instead of printing a bare count.
1479// Registered and released at exactly the points the counter is incremented and
1480// decremented, and in the order that keeps the counter authoritative: the name
1481// goes in after the increment and comes out before the decrement, so a reported
1482// name always belongs to a task the counter already holds. The reverse ordering
1483// would let the warning name a task that had already finished, which is the one
1484// reading that would send someone looking in the wrong place.
1485static BACKGROUND_TASK_NAMES: std::sync::OnceLock<
1486    std::sync::Mutex<std::collections::HashMap<&'static str, usize>>,
1487> = std::sync::OnceLock::new();
1488
1489fn background_task_names_registry(
1490) -> &'static std::sync::Mutex<std::collections::HashMap<&'static str, usize>> {
1491    BACKGROUND_TASK_NAMES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
1492}
1493
1494/// Name recorded for tasks spawned through the unnamed entry points, which
1495/// stay on the public API of a published crate.
1496pub const UNNAMED_BACKGROUND_TASK: &str = "unnamed";
1497
1498fn register_background_task_name(name: &'static str) {
1499    let mut names = background_task_names_registry()
1500        .lock()
1501        .unwrap_or_else(std::sync::PoisonError::into_inner);
1502    *names.entry(name).or_insert(0) += 1;
1503}
1504
1505fn release_background_task_name(name: &'static str) {
1506    let mut names = background_task_names_registry()
1507        .lock()
1508        .unwrap_or_else(std::sync::PoisonError::into_inner);
1509    if let Some(count) = names.get_mut(name) {
1510        *count -= 1;
1511        if *count == 0 {
1512            names.remove(name);
1513        }
1514    }
1515}
1516
1517/// Names of the in-flight tracked background tasks, sorted and deduplicated.
1518/// A diagnostic beside [`background_task_count`], never a substitute for it:
1519/// the count is what drain waits on.
1520pub fn background_task_names() -> Vec<String> {
1521    let names = background_task_names_registry()
1522        .lock()
1523        .unwrap_or_else(std::sync::PoisonError::into_inner);
1524    let mut out: Vec<String> = names.keys().map(|name| (*name).to_string()).collect();
1525    out.sort();
1526    out
1527}
1528
1529#[cfg(unix)]
1530fn idle_retirement_blockers<D: DaemonDispatch>(dispatcher: &D) -> Vec<String> {
1531    let mut blockers = dispatcher.idle_retirement_blockers();
1532    if !khive_storage::tx_registry::snapshot().is_empty() {
1533        blockers.push("open_sql_transaction".to_owned());
1534    }
1535    blockers.extend(
1536        active_phase_names()
1537            .into_iter()
1538            .map(|name| format!("active_phase:{name}")),
1539    );
1540    let count = background_task_count();
1541    let names = background_task_names_registry()
1542        .lock()
1543        .unwrap_or_else(std::sync::PoisonError::into_inner);
1544    if names.values().sum::<usize>() != count {
1545        blockers.push("tracked_worker_inventory_unsettled".to_owned());
1546    }
1547    // Only these inspected loops maintain replaceable caches/checkpoint state.
1548    // Their tracked lifetime still participates in the final drain.
1549    for name in names.keys() {
1550        if !matches!(
1551            *name,
1552            "wal_checkpoint" | "memory_ann_rotation_watch" | "knowledge_ann_rotation_watch"
1553        ) {
1554            blockers.push(format!("unsettled_worker:{name}"));
1555        }
1556    }
1557    blockers.sort();
1558    blockers.dedup();
1559    blockers
1560}
1561
1562#[cfg(unix)]
1563async fn wait_for_idle<D: DaemonDispatch>(dispatcher: &D, lifecycle: &DaemonLifecycle) {
1564    if lifecycle.options.lifetime == DaemonLifetime::Persistent {
1565        std::future::pending::<()>().await;
1566    }
1567    loop {
1568        if lifecycle.try_idle(|| idle_retirement_blockers(dispatcher)) {
1569            return;
1570        }
1571        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1572    }
1573}
1574
1575struct BackgroundTaskGuard {
1576    counter: Arc<std::sync::atomic::AtomicUsize>,
1577    name: &'static str,
1578}
1579
1580impl Drop for BackgroundTaskGuard {
1581    fn drop(&mut self) {
1582        release_background_task_name(self.name);
1583        self.counter
1584            .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
1585    }
1586}
1587
1588/// Spawn a task that daemon shutdown's `drain()` waits for and return its join
1589/// handle. Retaining the handle lets boot coordinators form an explicit barrier;
1590/// dropping it deliberately detaches the task while the background counter still
1591/// keeps daemon drain aware of its lifetime. The decrement happens via
1592/// `BackgroundTaskGuard`'s `Drop`, including panic and cancellation paths.
1593pub fn spawn_tracked_task<F, T>(fut: F) -> tokio::task::JoinHandle<T>
1594where
1595    F: std::future::Future<Output = T> + Send + 'static,
1596    T: Send + 'static,
1597{
1598    spawn_named_tracked_task(UNNAMED_BACKGROUND_TASK, fut)
1599}
1600
1601/// [`spawn_tracked_task`] with a name that a drain timeout can print.
1602///
1603/// The name is a short static string describing the task, never a formatted or
1604/// caller-supplied value: it is read by an operator staring at a shutdown that
1605/// would not finish, so it is a label for a call site, not a record of one
1606/// occurrence.
1607pub fn spawn_named_tracked_task<F, T>(name: &'static str, fut: F) -> tokio::task::JoinHandle<T>
1608where
1609    F: std::future::Future<Output = T> + Send + 'static,
1610    T: Send + 'static,
1611{
1612    background_tasks().fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1613    register_background_task_name(name);
1614    let guard = BackgroundTaskGuard {
1615        counter: background_tasks().clone(),
1616        name,
1617    };
1618    tokio::spawn(async move {
1619        let _guard = guard;
1620        fut.await
1621    })
1622}
1623
1624/// Spawn a fire-and-forget task through [`spawn_tracked_task`].
1625///
1626/// Callers that need a boot or shutdown barrier should retain and await the
1627/// returned handle from [`spawn_tracked_task`] instead of detaching it here.
1628pub fn track_background_task<F>(fut: F)
1629where
1630    F: std::future::Future<Output = ()> + Send + 'static,
1631{
1632    track_named_background_task(UNNAMED_BACKGROUND_TASK, fut);
1633}
1634
1635/// [`track_background_task`] with a name that a drain timeout can print. See
1636/// [`spawn_named_tracked_task`] for what belongs in the name.
1637pub fn track_named_background_task<F>(name: &'static str, fut: F)
1638where
1639    F: std::future::Future<Output = ()> + Send + 'static,
1640{
1641    drop(spawn_named_tracked_task(name, fut));
1642}
1643
1644/// Current count of in-flight tasks started via [`track_background_task`].
1645/// Exposed for tests; `drain()` reads the shared counter directly.
1646pub fn background_task_count() -> usize {
1647    background_tasks().load(std::sync::atomic::Ordering::SeqCst)
1648}
1649
1650/// Process-wide daemon shutdown signal (ADR-119).
1651///
1652/// Cancelled exactly once, when the daemon's unified shutdown future resolves
1653/// — before `drain()` begins waiting on tracked tasks — so long-running
1654/// daemon components supervised outside this module observe shutdown through
1655/// the same path the daemon itself does, rather than inventing their own.
1656/// Clones share the underlying token; child tokens derived from it are
1657/// cancelled transitively.
1658///
1659/// In non-daemon processes the token simply never fires.
1660pub fn daemon_shutdown_token() -> tokio_util::sync::CancellationToken {
1661    static TOKEN: std::sync::OnceLock<tokio_util::sync::CancellationToken> =
1662        std::sync::OnceLock::new();
1663    TOKEN
1664        .get_or_init(tokio_util::sync::CancellationToken::new)
1665        .clone()
1666}
1667
1668// ── active background phase names (ADR-103) ──────────────────────────────────
1669//
1670// A lightweight, best-effort process-wide gauge of which named background
1671// phases (e.g. `ann_warm`) are in flight right now, read by `comm.health`'s
1672// resource self-report so a caller can see "what is the daemon doing" at a
1673// glance without correlating timestamps across the event log itself. Counted
1674// per name rather than boolean, since more than one occurrence of the same
1675// named phase can legitimately overlap (e.g. two embedding models warming
1676// concurrently) — the name only drops out of the reported set once every
1677// concurrent occurrence has ended.
1678static ACTIVE_PHASES: std::sync::OnceLock<
1679    std::sync::Mutex<std::collections::HashMap<String, usize>>,
1680> = std::sync::OnceLock::new();
1681
1682fn active_phases() -> &'static std::sync::Mutex<std::collections::HashMap<String, usize>> {
1683    ACTIVE_PHASES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
1684}
1685
1686/// RAII guard for one occurrence of a named background phase. Increments the
1687/// phase's count on creation (see [`register_active_phase`]); decrements on
1688/// `Drop`, so the count comes back down whether the guarded work returns
1689/// normally, panics, or is cancelled — the same rationale as
1690/// `BackgroundTaskGuard` above.
1691pub struct PhaseGuard {
1692    name: String,
1693}
1694
1695impl Drop for PhaseGuard {
1696    fn drop(&mut self) {
1697        let mut map = active_phases()
1698            .lock()
1699            .unwrap_or_else(std::sync::PoisonError::into_inner);
1700        if let Some(count) = map.get_mut(&self.name) {
1701            *count -= 1;
1702            if *count == 0 {
1703                map.remove(&self.name);
1704            }
1705        }
1706    }
1707}
1708
1709/// Register one occurrence of a named background phase as currently active.
1710/// Returns a guard: drop it (or let it fall out of scope) when the phase
1711/// ends. Best-effort process-wide gauge only, read by `comm.health` — never
1712/// load-bearing for correctness.
1713pub fn register_active_phase(name: &str) -> PhaseGuard {
1714    let mut map = active_phases()
1715        .lock()
1716        .unwrap_or_else(std::sync::PoisonError::into_inner);
1717    *map.entry(name.to_string()).or_insert(0) += 1;
1718    PhaseGuard {
1719        name: name.to_string(),
1720    }
1721}
1722
1723/// Currently active background-phase names, sorted for deterministic output.
1724/// Empty when no tracked phase is in flight.
1725pub fn active_phase_names() -> Vec<String> {
1726    let map = active_phases()
1727        .lock()
1728        .unwrap_or_else(std::sync::PoisonError::into_inner);
1729    let mut names: Vec<String> = map.keys().cloned().collect();
1730    names.sort();
1731    names
1732}
1733
1734// ── server ────────────────────────────────────────────────────────────────────
1735
1736/// Build a point-in-time [`MetricsSnapshot`] of this process's server-side
1737/// gauges. Called only from `handle_conn`'s `metrics_only` arm — a
1738/// process-global, read-only assembly with no side effects of its own.
1739/// See `docs/api/daemon.md#build_metrics_snapshot` for where each gauge is sourced
1740/// from and why.
1741#[cfg(unix)]
1742fn build_metrics_snapshot<D: DaemonDispatch>(dispatcher: &D) -> MetricsSnapshot {
1743    let open_tx_count = khive_storage::tx_registry::snapshot().len();
1744    // ADR-091 Amendment 3: deliberately the process-wide aggregate, not a
1745    // backend-attributed view — this gauge reports "oldest pinned tx in this
1746    // process" across every wired backend, not an attribution claim about
1747    // which database it belongs to. Attribution consumers (the session sweep,
1748    // the per-backend checkpoint tasks) use the scoped `oldest_for` views in
1749    // khive-db instead.
1750    let (oldest_pinned_tx_micros, oldest_pinned_tx_label) =
1751        match khive_storage::tx_registry::oldest() {
1752            Some((_id, age, label)) => (Some(age.as_micros() as u64), label),
1753            None => (None, None),
1754        };
1755
1756    let checkpoint_pool = dispatcher.pool_for_checkpoint();
1757    let mut secondary_index = 0;
1758    let wal_checkpoint_stores = checkpoint_task_specs(
1759        checkpoint_pool.clone(),
1760        dispatcher.secondary_pools_for_checkpoint(),
1761        None,
1762        String::new(),
1763    )
1764    .into_iter()
1765    .map(|task| {
1766        let (store_id, role) = if task.is_main {
1767            ("main".to_string(), "main".to_string())
1768        } else {
1769            let store_id = format!("secondary:{secondary_index}");
1770            secondary_index += 1;
1771            (store_id, "secondary".to_string())
1772        };
1773        CheckpointStoreMetrics {
1774            store_id,
1775            role,
1776            database: task
1777                .pool
1778                .canonical_path()
1779                .and_then(std::path::Path::file_name)
1780                .map(|name| name.to_string_lossy().into_owned()),
1781            timing: khive_db::checkpoint::checkpoint_timing(&task.pool),
1782        }
1783    })
1784    .collect();
1785    let routine_wal = checkpoint_pool
1786        .as_deref()
1787        .and_then(khive_db::checkpoint::routine_wal_observation);
1788    let writer_stages = checkpoint_pool
1789        .as_deref()
1790        .and_then(khive_db::writer_task::last_writer_stage_observation);
1791    let (write_queue_depth, write_queue_capacity) = checkpoint_pool
1792        .as_ref()
1793        .and_then(|pool| pool.writer_task_handle().ok().flatten())
1794        .map(|handle| (Some(handle.queue_depth()), Some(handle.capacity())))
1795        .unwrap_or((None, None));
1796
1797    MetricsSnapshot {
1798        lifecycle: None,
1799        wal_pages: routine_wal.as_ref().map(|sample| sample.log_frames),
1800        wal_log_frames: routine_wal.as_ref().map(|sample| sample.log_frames),
1801        wal_checkpointed_frames: routine_wal
1802            .as_ref()
1803            .map(|sample| sample.checkpointed_frames),
1804        wal_pending_frames: routine_wal.as_ref().map(|sample| sample.pending_frames),
1805        wal_physical_bytes: routine_wal
1806            .as_ref()
1807            .and_then(|sample| sample.physical_wal_bytes),
1808        wal_observed_at_unix_ms: routine_wal
1809            .as_ref()
1810            .map(|sample| sample.observed_at_unix_ms),
1811        wal_checkpoint_stores,
1812        wal_truncate_attempts: khive_db::checkpoint::truncate_attempts(),
1813        wal_truncate_consecutive_failures: khive_db::checkpoint::truncate_consecutive_failures(),
1814        wal_checkpoint_skipped_ticks: khive_db::checkpoint::checkpoint_skipped_ticks(),
1815        wal_checkpoint_consecutive_skips: khive_db::checkpoint::checkpoint_consecutive_skips(),
1816        wal_checkpoint_last_skip_wal_pages: khive_db::checkpoint::checkpoint_last_skip_wal_pages(),
1817        oldest_pinned_tx_micros,
1818        oldest_pinned_tx_label,
1819        open_tx_count,
1820        write_queue_depth,
1821        write_queue_capacity,
1822        write_last_queue_wait_micros: writer_stages
1823            .as_ref()
1824            .map(|sample| sample.queue_wait_micros),
1825        write_last_transaction_acquire_micros: writer_stages
1826            .as_ref()
1827            .map(|sample| sample.transaction_acquire_micros),
1828        write_last_body_micros: writer_stages.as_ref().map(|sample| sample.body_micros),
1829        write_last_commit_micros: writer_stages.as_ref().map(|sample| sample.commit_micros),
1830        write_last_total_micros: writer_stages.as_ref().map(|sample| sample.total_micros),
1831        write_last_observed_at_unix_ms: writer_stages
1832            .as_ref()
1833            .map(|sample| sample.observed_at_unix_ms),
1834        connections: None,
1835        recall_ledger: Some(recall_ledger_snapshot()),
1836    }
1837}
1838
1839#[cfg(unix)]
1840async fn write_response_frame<W>(stream: &mut W, payload: &[u8]) -> std::io::Result<()>
1841where
1842    W: tokio::io::AsyncWrite + Unpin,
1843{
1844    tokio::time::timeout(INITIAL_FRAME_READ_TIMEOUT, write_frame(stream, payload))
1845        .await
1846        .map_err(|_| {
1847            std::io::Error::new(
1848                std::io::ErrorKind::TimedOut,
1849                "daemon response write timed out",
1850            )
1851        })?
1852}
1853
1854#[cfg(unix)]
1855async fn wait_for_peer_disconnect(read: &mut tokio::net::unix::OwnedReadHalf) {
1856    let mut byte = [0u8; 1];
1857    // One request is admitted per connection. EOF, a read error, or any
1858    // subsequent byte all make this connection no longer a valid response
1859    // peer, so the first completed read is the entire observation.
1860    let _ = read.read(&mut byte).await;
1861}
1862
1863#[cfg(all(unix, test))]
1864async fn handle_conn<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
1865    handle_conn_with_shutdown(
1866        stream,
1867        dispatcher,
1868        None,
1869        tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
1870    )
1871    .await;
1872}
1873
1874#[cfg(all(unix, feature = "fault-injection"))]
1875#[doc(hidden)]
1876pub async fn handle_conn_for_test<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
1877    handle_conn_with_shutdown(
1878        stream,
1879        dispatcher,
1880        None,
1881        tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
1882    )
1883    .await;
1884}
1885
1886#[cfg(unix)]
1887fn plan_frame_companion(raw: &[u8]) -> Option<&'static str> {
1888    let value: serde_json::Value = serde_json::from_slice(raw).ok()?;
1889    if value.get("plan").and_then(serde_json::Value::as_bool) != Some(true)
1890        || value
1891            .get("protocol_version")
1892            .and_then(serde_json::Value::as_u64)
1893            != Some(u64::from(PROTOCOL_VERSION))
1894    {
1895        return None;
1896    }
1897    [
1898        "presentation",
1899        "presentation_per_op",
1900        "format",
1901        "format_per_op",
1902        "request_id",
1903    ]
1904    .into_iter()
1905    .find(|field| value.get(*field).is_some())
1906}
1907
1908#[cfg(all(
1909    unix,
1910    any(test, feature = "fault-injection", feature = "test-internals")
1911))]
1912async fn handle_conn_with_shutdown<D: DaemonDispatch>(
1913    stream: UnixStream,
1914    dispatcher: D,
1915    shutdown: Option<tokio::sync::watch::Receiver<bool>>,
1916    initial_frame_deadline: tokio::time::Instant,
1917) {
1918    handle_conn_with_lifecycle(stream, dispatcher, shutdown, initial_frame_deadline, None).await;
1919}
1920
1921#[cfg(unix)]
1922async fn handle_conn_with_lifecycle<D: DaemonDispatch>(
1923    mut stream: UnixStream,
1924    dispatcher: D,
1925    shutdown: Option<tokio::sync::watch::Receiver<bool>>,
1926    initial_frame_deadline: tokio::time::Instant,
1927    lifecycle: Option<Arc<DaemonLifecycle>>,
1928) {
1929    let mut ordinary_admission = None;
1930    let production_shutdown = shutdown.is_some();
1931    // A handover is delivered over the probed connection. The production
1932    // listener enforces same-uid admission, and direct handler tests cannot
1933    // self-signal because they do not supply the daemon shutdown receiver.
1934    let handover_peer_allowed = peer_uid(&stream)
1935        .ok()
1936        .is_some_and(|uid| uid == unsafe { libc::geteuid() } as u32);
1937    let (local_shutdown_tx, local_shutdown_rx) = tokio::sync::watch::channel(false);
1938    let shutdown = shutdown.unwrap_or(local_shutdown_rx);
1939    // Keeps the fallback receiver open in direct/test calls. Production owns
1940    // a sender at the daemon-run scope and passes its receiver above.
1941    let _local_shutdown_tx = local_shutdown_tx;
1942    let raw = match read_initial_frame(&mut stream, initial_frame_deadline).await {
1943        Ok(r) => r,
1944        Err(e) => {
1945            tracing::debug!(error = %e, "failed to read daemon request frame");
1946            return;
1947        }
1948    };
1949    #[derive(Deserialize)]
1950    struct SupervisorRequestEnvelope {
1951        #[serde(flatten)]
1952        frame: DaemonRequestFrame,
1953        #[serde(default)]
1954        supervisor_handover: bool,
1955    }
1956    let decoded: Result<SupervisorRequestEnvelope, _> = serde_json::from_slice(&raw);
1957    if decoded.as_ref().ok().is_none_or(|item| item.frame.plan) {
1958        if let Some(field) = plan_frame_companion(&raw) {
1959            let response = DaemonResponseFrame {
1960                ok: false,
1961                result: None,
1962                error: Some(format!(
1963                    "invalid_params: plan=true cannot be combined with {field}"
1964                )),
1965                error_detail: Some(serde_json::json!({
1966                    "kind": "protocol",
1967                    "code": "invalid_params",
1968                    "message": format!("plan=true cannot be combined with {field}"),
1969                    "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
1970                })),
1971                namespace_mismatch: false,
1972                config_mismatch: false,
1973                served_config_id: Some(dispatcher.config_id().to_string()),
1974                version_mismatch: false,
1975                daemon_protocol_version: PROTOCOL_VERSION,
1976                metrics: None,
1977                request_id: None,
1978            };
1979            if let Ok(payload) = serde_json::to_vec(&response) {
1980                if let Err(error) = write_response_frame(&mut stream, &payload).await {
1981                    tracing::debug!(%error, "failed to write plan envelope refusal");
1982                }
1983            }
1984            return;
1985        }
1986    }
1987    let (frame, handover_requested) = match decoded {
1988        Ok(item) => (item.frame, item.supervisor_handover),
1989        Err(e) => {
1990            tracing::debug!(error = %e, "failed to decode daemon request frame");
1991            return;
1992        }
1993    };
1994    let supervisor_probe = frame.probe_only;
1995    let handover_accepted = handover_requested
1996        && supervisor_probe
1997        && production_shutdown
1998        && handover_peer_allowed
1999        && frame.protocol_version == PROTOCOL_VERSION
2000        && !frame.plan
2001        && !frame.metrics_only
2002        && frame.ops.is_empty()
2003        && read_supervisor_marker_claim().is_some_and(|(pid, _)| pid != std::process::id());
2004    let (mut peer_read, mut peer_write) = stream.into_split();
2005
2006    let served_config_id = Some(dispatcher.config_id().to_string());
2007    let resp = if frame.protocol_version != PROTOCOL_VERSION {
2008        let msg = format!(
2009            "daemon protocol mismatch: client={} daemon={} — \
2010             rebuild/update the client binary (make local)",
2011            frame.protocol_version, PROTOCOL_VERSION,
2012        );
2013        tracing::warn!(
2014            client_version = frame.protocol_version,
2015            daemon_version = PROTOCOL_VERSION,
2016            "daemon protocol version mismatch"
2017        );
2018        DaemonResponseFrame {
2019            ok: false,
2020            result: None,
2021            error: Some(msg.clone()),
2022            error_detail: Some(serde_json::json!({
2023                "kind": "protocol",
2024                "code": "version_mismatch",
2025                "message": msg,
2026                "domain_disposition": crate::DomainDisposition::Unknown.as_str(),
2027            })),
2028            namespace_mismatch: false,
2029            config_mismatch: false,
2030            served_config_id,
2031            // A client below this protocol is a bridge that predates the binary this
2032            // daemon was spawned from. Through protocol 5 the bridge treats an
2033            // explicit `version_mismatch` from a higher-numbered daemon as a terminal
2034            // error it repeats on every request, and re-execs itself onto the on-disk
2035            // binary only for the implicit shape: an unequal `daemon_protocol_version`
2036            // with the flag clear. Answering older clients in that shape, still
2037            // refused and still carrying the code in `error_detail`, lets every
2038            // pre-swap bridge replace itself on its first request instead of staying
2039            // refused until a person reconnects the session. A client above this
2040            // protocol keeps the explicit flag. Remove once no live bridge predates
2041            // the two-direction re-exec in khive-mcp (an inode census of `kkernel mcp`
2042            // processes before the swap): bridges built with it no longer read the
2043            // flag, but a bump while older bridges still run must keep this shape so
2044            // they replace themselves too.
2045            version_mismatch: frame.protocol_version > PROTOCOL_VERSION,
2046            daemon_protocol_version: PROTOCOL_VERSION,
2047            metrics: None,
2048            request_id: frame.request_id,
2049        }
2050    } else if handover_requested {
2051        if handover_accepted {
2052            DaemonResponseFrame {
2053                ok: true,
2054                result: None,
2055                error: None,
2056                error_detail: None,
2057                namespace_mismatch: false,
2058                config_mismatch: false,
2059                served_config_id,
2060                version_mismatch: false,
2061                daemon_protocol_version: PROTOCOL_VERSION,
2062                metrics: None,
2063                request_id: frame.request_id,
2064            }
2065        } else {
2066            DaemonResponseFrame {
2067                ok: false,
2068                result: None,
2069                error: Some("supervisor handover refused".to_string()),
2070                error_detail: Some(serde_json::json!({
2071                    "kind": "protocol",
2072                    "code": "supervisor_handover_refused",
2073                    "message": "supervisor handover refused",
2074                    "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
2075                })),
2076                namespace_mismatch: false,
2077                config_mismatch: false,
2078                served_config_id,
2079                version_mismatch: false,
2080                daemon_protocol_version: PROTOCOL_VERSION,
2081                metrics: None,
2082                request_id: frame.request_id,
2083            }
2084        }
2085    } else if frame.metrics_only && !frame.plan {
2086        // Process-global gauge read: namespace/config-agnostic, so this is
2087        // handled BEFORE the `config_id` equality reject below (unlike every
2088        // other arm) — a metrics probe must work regardless of which
2089        // client's config is asking, since it never touches the dispatcher's
2090        // packs/db/embed registry. READ-ONLY: builds a snapshot and returns
2091        // immediately, never reaching the ops-dispatch arm.
2092        DaemonResponseFrame {
2093            ok: true,
2094            result: None,
2095            error: None,
2096            error_detail: None,
2097            namespace_mismatch: false,
2098            config_mismatch: false,
2099            served_config_id,
2100            version_mismatch: false,
2101            daemon_protocol_version: PROTOCOL_VERSION,
2102            metrics: Some({
2103                let mut metrics = build_metrics_snapshot(&dispatcher);
2104                metrics.lifecycle = lifecycle.as_ref().map(|state| {
2105                    let mut snapshot = state.snapshot();
2106                    snapshot.idle_blockers = idle_retirement_blockers(&dispatcher);
2107                    snapshot
2108                });
2109                metrics.connections = lifecycle.as_ref().map(|state| state.connections.snapshot());
2110                metrics
2111            }),
2112            request_id: frame.request_id,
2113        }
2114    // There is no `frame.namespace != dispatcher.namespace()` reject here.
2115    // The daemon accepts and serves the request under the frame's own
2116    // identity (namespace / actor / visible_namespaces, built into a
2117    // `RequestIdentity` below) over its one shared warm registry, rather
2118    // than rejecting a differently-attributed same-uid connection to a cold
2119    // local-dispatch fallback. `config_id`: which governs packs/db/embed
2120    // coherence for the shared warm engine: remains a hard reject for every
2121    // field other than a daemon-side superset of the client's extra embedders.
2122    } else if !config_ids_compatible(&frame.config_id, dispatcher.config_id()) {
2123        DaemonResponseFrame {
2124            ok: false,
2125            result: None,
2126            error: None,
2127            error_detail: Some(serde_json::json!({
2128                "kind": "protocol",
2129                "code": "config_mismatch",
2130                "message": "daemon configuration does not match the request",
2131                "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
2132            })),
2133            namespace_mismatch: false,
2134            config_mismatch: true,
2135            served_config_id,
2136            version_mismatch: false,
2137            daemon_protocol_version: PROTOCOL_VERSION,
2138            metrics: None,
2139            request_id: frame.request_id,
2140        }
2141    } else if frame.plan {
2142        DaemonResponseFrame {
2143            ok: true,
2144            result: Some(dispatcher.plan(&frame.ops)),
2145            error: None,
2146            error_detail: None,
2147            namespace_mismatch: false,
2148            config_mismatch: false,
2149            served_config_id,
2150            version_mismatch: false,
2151            daemon_protocol_version: PROTOCOL_VERSION,
2152            metrics: None,
2153            request_id: None,
2154        }
2155    } else if frame.probe_only {
2156        // Probe-only request: identity checks passed; return immediately without
2157        // dispatching any verb. The client uses this to confirm the daemon is
2158        // alive and identity-matching without triggering any mutation.
2159        DaemonResponseFrame {
2160            ok: true,
2161            result: None,
2162            error: None,
2163            error_detail: None,
2164            namespace_mismatch: false,
2165            config_mismatch: false,
2166            served_config_id,
2167            version_mismatch: false,
2168            daemon_protocol_version: PROTOCOL_VERSION,
2169            metrics: None,
2170            request_id: frame.request_id,
2171        }
2172    } else {
2173        if let Some(lifecycle) = &lifecycle {
2174            ordinary_admission = lifecycle.admit();
2175            if ordinary_admission.is_none() {
2176                let refusal = DaemonResponseFrame {
2177                    ok: false,
2178                    result: None,
2179                    error: Some("daemon is draining; request was not admitted".to_owned()),
2180                    error_detail: Some(serde_json::json!({
2181                        "kind": "runtime", "code": "daemon_draining",
2182                        "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
2183                    })),
2184                    request_id: frame.request_id,
2185                    daemon_protocol_version: PROTOCOL_VERSION,
2186                    namespace_mismatch: false,
2187                    config_mismatch: false,
2188                    served_config_id: Some(dispatcher.config_id().to_owned()),
2189                    version_mismatch: false,
2190                    metrics: None,
2191                };
2192                if let Ok(payload) = serde_json::to_vec(&refusal) {
2193                    let _ = write_response_frame(&mut peer_write, &payload).await;
2194                }
2195                return;
2196            }
2197        }
2198        // Build the per-request identity context from the frame so the
2199        // implementor mints the storage/gate token from the CALLER's
2200        // identity, not the dispatcher's own construction-baked scalars.
2201        // This is always `Some` here: every frame that reaches this arm
2202        // carries a `namespace` (required on the wire) plus whatever
2203        // `actor_id`/`visible_namespaces` the client resolved (defaulting to
2204        // `None`/`vec![]` for an older, field-absent payload, which is
2205        // exactly the prior anonymous/no-extra-visibility behavior).
2206        // The caller's actor namespace joins default reads where the registry
2207        // mints the token (ADR-007 Rev 4 Rule 3b), the one seam every identity
2208        // path shares; the frame's list is forwarded as sent.
2209        let identity = RequestIdentity {
2210            namespace: frame.namespace.clone(),
2211            actor_id: frame.actor_id.clone(),
2212            visible_namespaces: frame.visible_namespaces.clone(),
2213            process_ref: frame.process_ref.clone(),
2214            request_id: frame.request_id,
2215        };
2216        tracing::debug!(
2217            request_id = frame.request_id,
2218            "daemon RequestIdentity constructed"
2219        );
2220        let (read_cancel_tx, read_cancel_rx) = tokio::sync::watch::channel(false);
2221        // The connection's own ceiling nests inside the dispatcher's, and a
2222        // nested scope keeps the earlier deadline, so the allowance must be
2223        // granted here or a long poll times out at the operator ceiling.
2224        let read_timeout = dispatcher.request_read_timeout(&frame.ops);
2225        let excluded_embedder_names =
2226            config_id_extra_embedder_exclusions(&frame.config_id, dispatcher.config_id())
2227                .expect("compatible configuration ids must expose their extra embedder sets");
2228        let dispatch = crate::runtime::scope_request_embedder_exclusions(
2229            excluded_embedder_names,
2230            khive_storage::scope_request_read_cancellation(
2231                shutdown,
2232                khive_storage::scope_request_read_cancellation(
2233                    read_cancel_rx,
2234                    khive_storage::scope_request_read_deadline(
2235                        read_timeout,
2236                        dispatcher.dispatch_with_error_detail(
2237                            frame.ops,
2238                            frame.presentation,
2239                            frame.presentation_per_op,
2240                            frame.format,
2241                            frame.format_per_op,
2242                            frame.from_wire,
2243                            Some(identity),
2244                        ),
2245                    ),
2246                ),
2247            ),
2248        );
2249        tokio::pin!(dispatch);
2250        let dispatch_result = tokio::select! {
2251            result = &mut dispatch => result,
2252            _ = wait_for_peer_disconnect(&mut peer_read) => {
2253                let _ = read_cancel_tx.send(true);
2254                dispatch.await
2255            }
2256        };
2257        match dispatch_result {
2258            Ok(result) => {
2259                let (result, detail) = take_daemon_lexical_timeout_marker(result);
2260                DaemonResponseFrame {
2261                    ok: true,
2262                    result: Some(result),
2263                    error: None,
2264                    error_detail: detail,
2265                    namespace_mismatch: false,
2266                    config_mismatch: false,
2267                    served_config_id,
2268                    version_mismatch: false,
2269                    daemon_protocol_version: PROTOCOL_VERSION,
2270                    metrics: None,
2271                    request_id: frame.request_id,
2272                }
2273            }
2274            Err(error) => {
2275                let error = DaemonDispatchError::new(error.message, Some(error.error_detail));
2276                DaemonResponseFrame {
2277                    ok: false,
2278                    result: None,
2279                    error: Some(error.message),
2280                    error_detail: Some(error.error_detail),
2281                    namespace_mismatch: false,
2282                    config_mismatch: false,
2283                    served_config_id,
2284                    version_mismatch: false,
2285                    daemon_protocol_version: PROTOCOL_VERSION,
2286                    metrics: None,
2287                    request_id: frame.request_id,
2288                }
2289            }
2290        }
2291    };
2292
2293    let payload = if supervisor_probe {
2294        serde_json::to_value(&resp).and_then(|mut value| {
2295            if let Some(claim) = current_supervisor_claim() {
2296                value["supervisor_claim"] = serde_json::Value::String(claim);
2297            }
2298            if handover_accepted {
2299                value["supervisor_handover_accepted"] = serde_json::Value::Bool(true);
2300            }
2301            serde_json::to_vec(&value)
2302        })
2303    } else {
2304        serde_json::to_vec(&resp)
2305    };
2306    let mut handover_ack_written = false;
2307    match payload {
2308        Ok(payload) => {
2309            if payload.len() > MAX_FRAME_BYTES {
2310                // The serialized response exceeds the IPC frame cap.  Send a
2311                // small explicit error frame so the client can distinguish a
2312                // per-request payload-size failure from a daemon crash.  A
2313                // client that receives this error frame will NOT trigger
2314                // stale-daemon kill/respawn (ParseFailure requires a read_frame
2315                // error, not an ok=false result).
2316                tracing::warn!(
2317                    bytes = payload.len(),
2318                    limit = MAX_FRAME_BYTES,
2319                    "daemon response exceeds MAX_FRAME_BYTES; sending explicit error frame"
2320                );
2321                let message = format!(
2322                    "response too large: {} bytes exceeds {} byte IPC cap",
2323                    payload.len(),
2324                    MAX_FRAME_BYTES,
2325                );
2326                // One frame may aggregate successful, failed, and aborted operations.
2327                let err_resp = DaemonResponseFrame {
2328                    ok: false,
2329                    result: None,
2330                    error: Some(message.clone()),
2331                    error_detail: Some(serde_json::json!({
2332                        "kind": "transport",
2333                        "code": "response_frame_size_limit",
2334                        "message": message,
2335                        "domain_disposition": crate::DomainDisposition::Unknown.as_str(),
2336                    })),
2337                    namespace_mismatch: false,
2338                    config_mismatch: false,
2339                    served_config_id: resp.served_config_id,
2340                    version_mismatch: false,
2341                    daemon_protocol_version: PROTOCOL_VERSION,
2342                    metrics: None,
2343                    request_id: resp.request_id,
2344                };
2345                if let Ok(err_payload) = serde_json::to_vec(&err_resp) {
2346                    if let Err(e) = write_response_frame(&mut peer_write, &err_payload).await {
2347                        tracing::debug!(error = %e, "failed to write oversized-response error frame");
2348                    }
2349                }
2350            } else {
2351                match write_response_frame(&mut peer_write, &payload).await {
2352                    Ok(()) => handover_ack_written = true,
2353                    Err(e) => tracing::debug!(error = %e, "failed to write daemon response frame"),
2354                }
2355            }
2356        }
2357        Err(e) => tracing::warn!(error = %e, "failed to serialize daemon response frame"),
2358    }
2359    if handover_accepted && handover_ack_written {
2360        // Signal this process, not a PID observed earlier over a socket.
2361        // Production installed its SIGTERM handler before binding the socket.
2362        if unsafe { libc::raise(libc::SIGTERM) } != 0 {
2363            tracing::error!(error = %std::io::Error::last_os_error(), "self-directed handover signal failed");
2364        }
2365    }
2366    drop(ordinary_admission);
2367}
2368
2369/// An accepted connection owns a drain slot from the accept loop until its
2370/// handler future is dropped. The claim happens before spawning so shutdown
2371/// cannot observe zero in the gap between `accept()` and the task's first poll.
2372#[cfg(unix)]
2373struct ActiveConnectionGuard {
2374    active: Arc<std::sync::atomic::AtomicUsize>,
2375}
2376
2377#[cfg(unix)]
2378impl ActiveConnectionGuard {
2379    fn claim(active: Arc<std::sync::atomic::AtomicUsize>) -> Self {
2380        active.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2381        Self { active }
2382    }
2383}
2384
2385#[cfg(unix)]
2386impl Drop for ActiveConnectionGuard {
2387    fn drop(&mut self) {
2388        self.active
2389            .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2390    }
2391}
2392
2393#[cfg(unix)]
2394fn spawn_connection_task<F>(
2395    active: Arc<std::sync::atomic::AtomicUsize>,
2396    future: F,
2397) -> tokio::task::JoinHandle<()>
2398where
2399    F: std::future::Future<Output = ()> + Send + 'static,
2400{
2401    let guard = ActiveConnectionGuard::claim(active);
2402    tokio::spawn(async move {
2403        let _guard = guard;
2404        future.await;
2405    })
2406}
2407
2408/// Run the daemon: bind the socket, warm in the background, serve request
2409/// frames until SIGTERM/SIGINT.
2410///
2411/// Fatally acquires its own startup lock, which only protects
2412/// cleanup→pid-claim→bind — `dispatcher` has already run migrations and
2413/// applied pack schema plans while constructing itself, unguarded. Production
2414/// boot must go through [`run_daemon_with_boot_guard`] instead, which extends
2415/// the same lock back over construction. This entry point is for callers
2416/// (and tests) that build the dispatcher and start serving as one atomic
2417/// step with no separate boot-guard window to protect.
2418#[cfg(unix)]
2419pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
2420    let boot_guard = Some(acquire_daemon_boot_guard()?);
2421    run_daemon_with_boot_guard_inner(
2422        dispatcher,
2423        boot_guard,
2424        false,
2425        DaemonOptions::default(),
2426        |_| DaemonStartupReport::default(),
2427    )
2428    .await
2429}
2430
2431/// Run a real daemon server for an in-process multi-launch test.
2432///
2433/// Separate production daemon candidates have distinct PIDs, so the boot fence
2434/// recognizes a live incumbent and makes later candidates exit. Parallel
2435/// test launchers share one OS process and therefore one PID; this explicit
2436/// fault-injection entry point preserves the production fence semantics by
2437/// allowing a live same-PID incumbent to win. Ordinary daemon startup
2438/// continues to treat a same-PID rendezvous as stale, protecting PID-reuse
2439/// cleanup behavior.
2440///
2441/// A losing candidate still follows the ordinary daemon-exit path and cancels
2442/// the process-wide component shutdown token. Callers must therefore use a
2443/// component-free dispatcher; this seam validates socket/PID ownership, not
2444/// multi-candidate component lifecycle.
2445#[cfg(all(unix, any(test, feature = "fault-injection")))]
2446#[doc(hidden)]
2447pub async fn run_daemon_in_process_test<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
2448    let boot_guard = Some(acquire_daemon_boot_guard()?);
2449    run_daemon_with_boot_guard_inner(
2450        dispatcher,
2451        boot_guard,
2452        true,
2453        DaemonOptions::default(),
2454        |_| DaemonStartupReport::default(),
2455    )
2456    .await
2457}
2458
2459#[cfg(unix)]
2460#[derive(Clone, Copy, PartialEq, Eq)]
2461enum RendezvousPathRole {
2462    Socket,
2463    PidFile,
2464}
2465
2466#[cfg(unix)]
2467impl RendezvousPathRole {
2468    fn env_name(self) -> &'static str {
2469        match self {
2470            Self::Socket => SOCKET_PATH_ENV,
2471            Self::PidFile => PID_PATH_ENV,
2472        }
2473    }
2474
2475    fn directory_name(self) -> &'static str {
2476        match self {
2477            Self::Socket => "socket directory",
2478            Self::PidFile => "PID-file directory",
2479        }
2480    }
2481
2482    fn path_name(self) -> &'static str {
2483        match self {
2484            Self::Socket => "socket path",
2485            Self::PidFile => "PID-file path",
2486        }
2487    }
2488
2489    fn path_component_name(self) -> &'static str {
2490        match self {
2491            Self::Socket => "socket-path",
2492            Self::PidFile => "PID-file-path",
2493        }
2494    }
2495}
2496
2497/// Vet the socket's parent directory, re-permissioning it only when it is the
2498/// directory khive owns by convention.
2499///
2500/// `KHIVE_SOCKET` takes an arbitrary path, and the previous unconditional
2501/// chmod-0700 of its parent was wrong in both directions: pointed at a shared
2502/// parent like `/tmp` it either failed outright for an ordinary user, or —
2503/// worse — succeeded when privileged and stripped access for every other
2504/// process on the machine. A directory we did not create is never modified.
2505///
2506/// What the directory must actually prevent is a *takeover of the socket
2507/// path*: the connection gate is the 0600 socket plus the accept-time
2508/// peer-uid check, but both defend the daemon's own socket — neither helps
2509/// once another local user can put *their* listener at the path clients
2510/// resolve. There are two ways a shared directory allows that, and the
2511/// sticky bit closes neither: a writer can *pre-bind* the predictable path
2512/// before this daemon starts (the sticky bit restricts unlinking, not
2513/// creating), and the directory's *owner* can unlink and rebind even in a
2514/// 1777 directory (sticky exempts the directory owner). So a caller-chosen
2515/// directory is served only when it is trusted end to end: owned by this
2516/// daemon's euid or root, and not writable by group or other at all. The
2517/// umask-default 0755 stays acceptable; shared sticky directories like
2518/// `/tmp` do not.
2519#[cfg(unix)]
2520pub(crate) fn ensure_socket_dir_is_trusted(parent: &std::path::Path) -> anyhow::Result<()> {
2521    // SAFETY: `geteuid` is always successful and takes no arguments.
2522    let daemon_euid = unsafe { libc::geteuid() } as u32;
2523    ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::Socket, daemon_euid, true)
2524}
2525
2526/// Vet the parent directory of a PID file before reading, locking, or writing
2527/// it. The file's lock only protects the inode currently named by its path;
2528/// every directory component must therefore be as swap-resistant as the
2529/// socket rendezvous.
2530#[cfg(unix)]
2531pub fn ensure_pid_file_dir_is_trusted(pid_file: &std::path::Path) -> anyhow::Result<()> {
2532    let parent = pid_file
2533        .parent()
2534        .filter(|parent| !parent.as_os_str().is_empty())
2535        .unwrap_or_else(|| std::path::Path::new("."));
2536    // SAFETY: `geteuid` is always successful and takes no arguments.
2537    let daemon_euid = unsafe { libc::geteuid() } as u32;
2538    ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::PidFile, daemon_euid, false)
2539}
2540
2541#[cfg(unix)]
2542fn ensure_rendezvous_dir_is_trusted(
2543    parent: &std::path::Path,
2544    role: RendezvousPathRole,
2545    daemon_euid: u32,
2546    repair_owned_default: bool,
2547) -> anyhow::Result<()> {
2548    let env_name = role.env_name();
2549    let directory_name = role.directory_name();
2550
2551    if repair_owned_default && parent == khive_dir() {
2552        std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
2553            anyhow::anyhow!(
2554                "refusing to start: cannot chmod 0700 {}: {e}. The khive directory must be \
2555                 owner-only as the {directory_name} for {env_name}; it is part of the \
2556                 same-uid guarantee this daemon enforces.",
2557                parent.display()
2558            )
2559        })?;
2560        return ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role);
2561    }
2562
2563    // Fail closed on the stat itself: not being able to read the metadata is
2564    // not the same as the directory passing.
2565    let meta = std::fs::metadata(parent).map_err(|e| {
2566        anyhow::anyhow!(
2567            "refusing to start: cannot stat {directory_name} {} for {env_name}: {e}. \
2568             It gates rendezvous-path safety, and unreadable metadata is not a passing state.",
2569            parent.display()
2570        )
2571    })?;
2572
2573    use std::os::unix::fs::MetadataExt;
2574    let owner = meta.uid();
2575    if owner != daemon_euid && owner != 0 {
2576        anyhow::bail!(
2577            "refusing to start: {directory_name} {} for {env_name} is owned by uid {owner}, \
2578             not this daemon's uid ({daemon_euid}) or root. A directory owner can replace \
2579             the rendezvous path regardless of mode bits. Point {env_name} at a directory \
2580             you own, or unset it for the default.",
2581            parent.display()
2582        );
2583    }
2584
2585    let mode = meta.permissions().mode();
2586    if mode & 0o022 != 0 {
2587        anyhow::bail!(
2588            "refusing to start: {directory_name} {} for {env_name} is mode {:04o} — writable \
2589             by group or other, so another local user could replace the rendezvous path. \
2590             Use a directory only you can write, or unset {env_name} for the default. \
2591             This daemon is not changing the permissions of a directory it does not own.",
2592            parent.display(),
2593            mode & 0o7777
2594        );
2595    }
2596
2597    ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role)
2598}
2599
2600/// Socket-role form of [`ensure_rendezvous_path_is_swap_resistant`], used by the
2601/// socket-path tests.
2602#[cfg(all(unix, test))]
2603fn ensure_socket_path_is_swap_resistant(
2604    parent: &std::path::Path,
2605    daemon_euid: u32,
2606) -> anyhow::Result<()> {
2607    ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, RendezvousPathRole::Socket)
2608}
2609
2610/// Walk the socket directory path exactly as the kernel will traverse it at
2611/// bind time — component by component, following symlinks — and refuse any
2612/// node another local user could swap after this validation.
2613///
2614/// Checking only the canonicalized result is not enough: bind and every
2615/// client traverse the *original* path, so a symlink component owned by
2616/// another user can resolve somewhere trusted while it is being validated
2617/// and be retargeted before the socket is bound. Validating the nodes the
2618/// traversal actually visits closes that gap: a symlink component is
2619/// acceptable only when its owner — the only party besides root who can
2620/// retarget it — is this daemon's euid or root, and every directory
2621/// component is held to the ancestor rule below.
2622///
2623/// The directory rule is deliberately weaker than the immediate parent's.
2624/// The parent hosts the socket file, where the threat is *creation* of the
2625/// predictable path — the sticky bit does not restrict creating, so no
2626/// sticky exception is sound there. An ancestor only threatens via
2627/// *rename/unlink of an existing entry we own*, which the sticky bit does
2628/// restrict: in a sticky directory, only the entry's owner, the directory's
2629/// owner, or root may rename it. So a root-owned `/tmp` (1777) is an
2630/// acceptable ancestor of a user-owned 0700 socket directory, while a
2631/// non-sticky group/other-writable ancestor, or one owned by a third uid
2632/// (who could rename the entry, or chmod the directory first), is refused.
2633/// Every stat failure fails closed.
2634#[cfg(unix)]
2635fn ensure_rendezvous_path_is_swap_resistant(
2636    parent: &std::path::Path,
2637    daemon_euid: u32,
2638    role: RendezvousPathRole,
2639) -> anyhow::Result<()> {
2640    use std::os::unix::fs::MetadataExt;
2641
2642    let env_name = role.env_name();
2643    let directory_name = role.directory_name();
2644    let path_name = role.path_name();
2645    let component_name = role.path_component_name();
2646
2647    let absolute = if parent.is_absolute() {
2648        parent.to_path_buf()
2649    } else {
2650        std::env::current_dir()
2651            .map_err(|e| {
2652                anyhow::anyhow!(
2653                    "refusing to start: cannot resolve the working directory to absolutize \
2654                     {directory_name} {} for {env_name}: {e}.",
2655                    parent.display()
2656                )
2657            })?
2658            .join(parent)
2659    };
2660
2661    fn push_components(stack: &mut Vec<std::ffi::OsString>, path: &std::path::Path) {
2662        let components: Vec<_> = path
2663            .components()
2664            .map(|c| c.as_os_str().to_os_string())
2665            .collect();
2666        stack.extend(components.into_iter().rev());
2667    }
2668
2669    let mut stack: Vec<std::ffi::OsString> = Vec::new();
2670    push_components(&mut stack, &absolute);
2671    let mut resolved = std::path::PathBuf::new();
2672    let mut symlinks_followed = 0u32;
2673
2674    while let Some(component) = stack.pop() {
2675        if component == "/" {
2676            resolved = std::path::PathBuf::from("/");
2677            continue;
2678        }
2679        if component == "." {
2680            continue;
2681        }
2682        if component == ".." {
2683            resolved.pop();
2684            continue;
2685        }
2686        let candidate = resolved.join(&component);
2687        let meta = std::fs::symlink_metadata(&candidate).map_err(|e| {
2688            anyhow::anyhow!(
2689                "refusing to start: cannot stat {component_name} component {} for {env_name}: \
2690                 {e}. An unreadable component is not a passing one.",
2691                candidate.display()
2692            )
2693        })?;
2694        let owner = meta.uid();
2695
2696        if meta.file_type().is_symlink() {
2697            symlinks_followed += 1;
2698            if symlinks_followed > 40 {
2699                anyhow::bail!(
2700                    "refusing to start: {path_name} for {env_name} resolves through more than \
2701                     40 symlinks at {} — treating this as a loop.",
2702                    candidate.display()
2703                );
2704            }
2705            if owner != daemon_euid && owner != 0 {
2706                anyhow::bail!(
2707                    "refusing to start: {component_name} symlink component {} for {env_name} \
2708                     is owned by uid {owner}, not this daemon's uid ({daemon_euid}) or root — \
2709                     its owner could retarget it after this check and re-root the {path_name}. \
2710                     Point {env_name} somewhere trusted end to end, or unset it for the default.",
2711                    candidate.display()
2712                );
2713            }
2714            let target = std::fs::read_link(&candidate).map_err(|e| {
2715                anyhow::anyhow!(
2716                    "refusing to start: cannot read {component_name} symlink component {} \
2717                     for {env_name}: {e}.",
2718                    candidate.display()
2719                )
2720            })?;
2721            push_components(&mut stack, &target);
2722            continue;
2723        }
2724
2725        if meta.is_dir() {
2726            let mode = meta.permissions().mode();
2727            let sticky = mode & 0o1000 != 0;
2728            if owner != daemon_euid && owner != 0 {
2729                anyhow::bail!(
2730                    "refusing to start: {component_name} ancestor {} for {env_name} is owned by \
2731                     uid {owner}, not this daemon's uid ({daemon_euid}) or root — its owner \
2732                     could rename the next path component and re-root the {path_name}. Point \
2733                     {env_name} somewhere trusted end to end, or unset it for the default.",
2734                    candidate.display()
2735                );
2736            }
2737            if mode & 0o022 != 0 && !sticky {
2738                anyhow::bail!(
2739                    "refusing to start: {component_name} ancestor {} for {env_name} is mode \
2740                     {:04o} — writable by group or other without the sticky bit, so another \
2741                     local user could rename the next path component and re-root the {path_name}. \
2742                     Point {env_name} somewhere trusted end to end, or unset it for the default.",
2743                    candidate.display(),
2744                    mode & 0o7777
2745                );
2746            }
2747            resolved = candidate;
2748            continue;
2749        }
2750
2751        anyhow::bail!(
2752            "refusing to start: {component_name} component {} for {env_name} is neither a \
2753             directory nor a symlink — the {path_name} cannot traverse it.",
2754            candidate.display()
2755        );
2756    }
2757
2758    Ok(())
2759}
2760
2761/// Run the daemon using a startup lock acquired by the caller *before*
2762/// building `dispatcher`, so a second process racing to boot (e.g. two
2763/// `kkernel mcp --daemon` spawns before either has bound its socket) cannot
2764/// run migrations/FTS DDL concurrently against the same database file.
2765/// `boot_guard` is only `None` on non-unix targets, where there is no
2766/// advisory boot lock to hold in the first place; every unix daemon-mode
2767/// caller passes `Some`.
2768///
2769/// The guard is held across cleanup → pid-claim → bind, then dropped. The
2770/// caller must not still be holding a *different* handle to the same lock
2771/// file when this function is entered — see the "Deadlock note" on the
2772/// `_startup_lock` binding below for why that would self-deadlock on `flock`.
2773#[cfg(unix)]
2774pub async fn run_daemon_with_boot_guard<D: DaemonDispatch>(
2775    dispatcher: D,
2776    boot_guard: Option<std::fs::File>,
2777) -> anyhow::Result<()> {
2778    run_daemon_with_boot_guard_inner(
2779        dispatcher,
2780        boot_guard,
2781        false,
2782        DaemonOptions::default(),
2783        |_| DaemonStartupReport::default(),
2784    )
2785    .await
2786}
2787
2788/// Run the daemon and start host-owned background work only after the socket
2789/// is bound, permissions are restricted, and this process owns the PID file.
2790/// The callback runs once while the startup lock and teardown guard are held;
2791/// setup failures never invoke it. Work started by the callback must use the
2792/// daemon shutdown token and tracked-task drain contract.
2793#[cfg(unix)]
2794pub async fn run_daemon_with_boot_guard_and_start<D, F>(
2795    dispatcher: D,
2796    boot_guard: Option<std::fs::File>,
2797    start: F,
2798) -> anyhow::Result<()>
2799where
2800    D: DaemonDispatch,
2801    F: FnOnce(&D) + Send,
2802{
2803    run_daemon_with_options_and_boot_guard_and_start(
2804        dispatcher,
2805        boot_guard,
2806        DaemonOptions::default(),
2807        |dispatcher| {
2808            start(dispatcher);
2809            DaemonStartupReport::default()
2810        },
2811    )
2812    .await
2813}
2814
2815/// Start with an explicit launch mode and collect the host's startup inventory.
2816#[cfg(unix)]
2817pub async fn run_daemon_with_options_and_boot_guard_and_start<D, F>(
2818    dispatcher: D,
2819    boot_guard: Option<std::fs::File>,
2820    options: DaemonOptions,
2821    start: F,
2822) -> anyhow::Result<()>
2823where
2824    D: DaemonDispatch,
2825    F: FnOnce(&D) -> DaemonStartupReport + Send,
2826{
2827    anyhow::ensure!(
2828        !options.idle_interval.is_zero(),
2829        "daemon idle interval must be positive"
2830    );
2831    run_daemon_with_boot_guard_inner(dispatcher, boot_guard, false, options, start).await
2832}
2833
2834#[cfg(unix)]
2835async fn run_daemon_with_boot_guard_inner<D, F>(
2836    dispatcher: D,
2837    boot_guard: Option<std::fs::File>,
2838    allow_same_process_incumbent: bool,
2839    options: DaemonOptions,
2840    start: F,
2841) -> anyhow::Result<()>
2842where
2843    D: DaemonDispatch,
2844    F: FnOnce(&D) -> DaemonStartupReport + Send,
2845{
2846    // Cancel on every exit, including setup failure and unwinding from the
2847    // post-ownership startup callback. The guard precedes all fallible work
2848    // so even a process that never becomes the daemon relinquishes its
2849    // process-lifetime shutdown token; restarting requires exec.
2850    struct ComponentTeardown;
2851    impl Drop for ComponentTeardown {
2852        fn drop(&mut self) {
2853            daemon_shutdown_token().cancel();
2854        }
2855    }
2856    let _component_teardown = ComponentTeardown;
2857
2858    // Placed after the teardown guard so this refusal keeps the ADR-119
2859    // contract every other pre-bind error path has, and before the paths are
2860    // resolved so a split rendezvous never reaches cleanup/bind/pid-write.
2861    ensure_rendezvous_overrides_paired()?;
2862
2863    let sock = socket_path();
2864    let pid_file = pid_path();
2865    let socket_parent = sock.parent();
2866    let pid_parent = pid_file.parent();
2867
2868    if let Some(parent) = socket_parent {
2869        std::fs::create_dir_all(parent)?;
2870        ensure_socket_dir_is_trusted(parent)?;
2871    }
2872    // Identical parent paths traverse the same components, so the socket
2873    // check above also vets the PID-file parent. Aliased paths are checked
2874    // independently because each original path is traversed by file access.
2875    if pid_parent != socket_parent {
2876        ensure_pid_file_dir_is_trusted(&pid_file)?;
2877    }
2878
2879    // Hold the startup lock across cleanup → pid-claim → bind so a concurrent
2880    // client's kill_and_respawn (which also holds this lock) cannot remove the
2881    // rendezvous paths during setup. The PID file's own lock is retained after
2882    // this shared startup lock is released, including while the listener drains
2883    // during shutdown.
2884    //
2885    // Deadlock note: the client holds this lock only during kill+spawn and
2886    // releases it before the spawned daemon process starts (the lock guard is
2887    // dropped when kill_and_respawn returns, before the readiness probe loop).
2888    // The daemon holds exactly one handle to this lock for its whole boot
2889    // sequence (received as `boot_guard`, extended from before `dispatcher`
2890    // was constructed) — never a second, independently-acquired handle in the
2891    // same process, which would self-deadlock on `flock`.
2892    let _startup_lock = boot_guard;
2893
2894    // A second daemon must refuse loudly rather than exit successfully while
2895    // another daemon owns this rendezvous. Only `Stale` lets the caller proceed.
2896    match cleanup_stale_daemon(
2897        &sock,
2898        &pid_file,
2899        allow_same_process_incumbent,
2900        dispatcher.config_id(),
2901    )
2902    .await
2903    {
2904        Incumbent::Serving(incumbent_pid) => {
2905            tracing::error!(
2906                pid = incumbent_pid,
2907                socket = ?sock,
2908                "refusing to start: a khived instance is already serving this socket"
2909            );
2910            anyhow::bail!(
2911                "refusing to start: khived is already running as pid {incumbent_pid}, \
2912                 serving socket {}. Stop that instance first if you intend to replace it.",
2913                sock.display()
2914            );
2915        }
2916        Incumbent::Live(incumbent_pid) => {
2917            tracing::error!(
2918                pid = incumbent_pid,
2919                socket = ?sock,
2920                "refusing to start: a live process owns the PID file but no khived answered"
2921            );
2922            anyhow::bail!(
2923                "refusing to start: pid {incumbent_pid} owns the daemon PID file and is alive, \
2924                 but nothing answered the khived protocol on {}. It may be draining. Nothing \
2925                 was removed; stop that process first if you intend to replace it.",
2926                sock.display()
2927            );
2928        }
2929        Incumbent::Stale => {}
2930    }
2931
2932    // Install signal streams before publishing either rendezvous file. A
2933    // supervisor may stop us as soon as connect/pid checks succeed, before
2934    // the accept loop or shutdown future has been polled. Keep these streams
2935    // alive so a signal during the rest of startup reaches normal cleanup.
2936    let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2937    let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2938
2939    let pid_file_guard = match write_pid_file_exclusive(&pid_file) {
2940        Ok(guard) => guard,
2941        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2942            // A PID file appeared between cleanup and our claim. Never touch
2943            // the winner's files; defer only if it already answers as khived.
2944            if pid_file_names_a_reachable_daemon(
2945                &pid_file,
2946                &sock,
2947                allow_same_process_incumbent,
2948                dispatcher.config_id(),
2949            )
2950            .await
2951            {
2952                tracing::info!(
2953                    "a replacement khived already claimed the pid/socket rendezvous; exiting"
2954                );
2955                return Ok(());
2956            }
2957            anyhow::bail!(
2958                "failed to claim daemon pid file at {pid_file:?}: it already exists \
2959                 and does not name a reachable daemon"
2960            );
2961        }
2962        Err(e) => return Err(e.into()),
2963    };
2964
2965    let listener = match UnixListener::bind(&sock) {
2966        Ok(listener) => listener,
2967        Err(e) => {
2968            remove_pid_file_if_owned(&pid_file, &pid_file_guard);
2969            return Err(e.into());
2970        }
2971    };
2972    // Fail closed, same reason as the directory above. If this chmod fails the
2973    // socket is world-reachable in a way the accepted design never covered, so
2974    // the bound listener is dropped and the entry removed rather than served.
2975    if let Err(e) = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)) {
2976        drop(listener);
2977        let _ = std::fs::remove_file(&sock);
2978        remove_pid_file_if_owned(&pid_file, &pid_file_guard);
2979        return Err(anyhow::anyhow!(
2980            "refusing to start: cannot chmod 0600 {}: {e}. The daemon socket must be owner-only \
2981             — it is half of the single-principal guarantee this daemon enforces.",
2982            sock.display()
2983        ));
2984    }
2985    // Captured while still holding the startup lock, immediately after
2986    // bind, so shutdown cleanup can later prove "this is still the same socket
2987    // I bound" rather than trusting the path alone.
2988    let bound_identity = socket_identity(&sock);
2989
2990    let lifecycle = Arc::new(DaemonLifecycle::new(options, start(&dispatcher)));
2991
2992    // Release the shared startup lock now that the listener is bound. The
2993    // locked PID file continues to identify this daemon through shutdown.
2994    drop(_startup_lock);
2995    tracing::info!(
2996        socket = ?sock,
2997        pid = std::process::id(),
2998        source_revision = crate::BUILD_INFO.source_revision,
2999        build_time = crate::BUILD_INFO.build_time,
3000        "khived listening"
3001    );
3002
3003    {
3004        let warm = dispatcher.clone();
3005        track_named_background_task("daemon_warmup", async move {
3006            warm.warm_all().await;
3007        });
3008    }
3009
3010    // The checkpoint task's own strong-count-based exit is unreachable
3011    // whenever `event_store_for_checkpoint()` returns `Some` (the ordinary
3012    // production shape), because the `SqlEventStore` it wraps retains its
3013    // own clone of the same pool. An explicit watch channel replaces that
3014    // mechanism: the sender is held for the remainder of this function's
3015    // scope and signalled as the first action once shutdown is observed,
3016    // below.
3017    let (checkpoint_shutdown_tx, checkpoint_shutdown_rx) = tokio::sync::watch::channel(());
3018    // ADR-091 Amendment 3: one checkpoint task per file-backed backend the
3019    // dispatcher wired — the primary pool plus every entry
3020    // `secondary_pools_for_checkpoint` returns — sharing this one shutdown
3021    // channel (the sender broadcasts to every receiver clone), so the single
3022    // send below stops every spawned task before `drain()`.
3023    let checkpoint_tasks = checkpoint_task_specs(
3024        dispatcher.pool_for_checkpoint(),
3025        dispatcher.secondary_pools_for_checkpoint(),
3026        dispatcher.event_store_for_checkpoint(),
3027        dispatcher.namespace().to_string(),
3028    );
3029    if !checkpoint_tasks.is_empty() {
3030        let cfg = CheckpointConfig::from_env();
3031        let checkpoint_task_count = checkpoint_tasks.len();
3032        for task in checkpoint_tasks {
3033            track_named_background_task(
3034                "wal_checkpoint",
3035                run_checkpoint_task(
3036                    task.pool,
3037                    cfg.clone(),
3038                    task.lifecycle_owner,
3039                    checkpoint_shutdown_rx.clone(),
3040                    task.is_main,
3041                ),
3042            );
3043        }
3044        tracing::info!(checkpoint_task_count, "WAL checkpoint task(s) started");
3045    }
3046
3047    let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3048    let connection_tasks = Arc::new(std::sync::Mutex::new(
3049        Vec::<tokio::task::JoinHandle<()>>::new(),
3050    ));
3051    let (request_shutdown_tx, request_shutdown_rx) = tokio::sync::watch::channel(false);
3052
3053    let shutdown = async {
3054        tokio::select! {
3055            _ = sigterm.recv() => tracing::info!("received SIGTERM"),
3056            _ = sigint.recv() => tracing::info!("received SIGINT"),
3057        }
3058        // Tokio retains its process-wide handlers after the streams are dropped.
3059        // This daemon cannot restart without exec; a repeat signal must terminate
3060        // even if shutdown is blocked in synchronous recovery-lock acquisition.
3061        for signal in [libc::SIGTERM, libc::SIGINT] {
3062            // SAFETY: setting SIG_DFL for these valid signals needs no handler
3063            // pointer or shared Rust state and applies to the whole process.
3064            if unsafe { libc::signal(signal, libc::SIG_DFL) } == libc::SIG_ERR {
3065                return Err(std::io::Error::last_os_error());
3066            }
3067        }
3068        Ok::<(), std::io::Error>(())
3069    };
3070    tokio::pin!(shutdown);
3071    lifecycle.ready();
3072
3073    // SAFETY: `geteuid` is always successful and takes no arguments.
3074    let daemon_euid = unsafe { libc::geteuid() } as u32;
3075
3076    let reason = tokio::select! {
3077        _ = async {
3078            let mut accept_error_backoff = None;
3079            let mut last_accept_error_log: Option<std::time::Instant> = None;
3080            loop {
3081                match listener.accept().await {
3082                    Ok((mut stream, _)) => {
3083                        let initial_frame_deadline =
3084                            tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT;
3085                        accept_error_backoff = None;
3086                        last_accept_error_log = None;
3087                        // Refuse a foreign uid before any frame is read.
3088                        // Fails CLOSED: an error reading peer credentials is
3089                        // "cannot prove same-uid", which is the same answer as
3090                        // "is not same-uid" — never a pass.
3091                        //
3092                        // Scope: this is a same-UID check, which is strictly
3093                        // weaker than same-PRINCIPAL. Several distinct actors
3094                        // running under one uid all pass here, so this does not
3095                        // by itself establish that the daemon serves a single
3096                        // principal, and nothing downstream refuses or degrades
3097                        // on observing more than one. Do not describe it as a
3098                        // multi-principal guard — it bounds the process boundary,
3099                        // not the identity one.
3100                        match peer_uid(&stream) {
3101                            Ok(peer) if uid_is_permitted(peer, daemon_euid) => {}
3102                            Ok(peer) => {
3103                                tracing::error!(
3104                                    peer_uid = peer,
3105                                    daemon_euid,
3106                                    "refusing connection from a foreign uid: this daemon accepts \
3107                                     only peers running as its own uid"
3108                                );
3109                                drop(stream);
3110                                continue;
3111                            }
3112                            Err(e) => {
3113                                tracing::error!(
3114                                    error = %e,
3115                                    "refusing connection: cannot read peer credentials, so \
3116                                     same-uid cannot be proven"
3117                                );
3118                                drop(stream);
3119                                continue;
3120                            }
3121                        }
3122                        // One permit per connection, taken before the task is
3123                        // spawned. Past the cap the peer is answered with a busy
3124                        // error and the stream is dropped; connections already
3125                        // admitted keep their permits and are not affected.
3126                        let Some(permit) = admit_or_refuse_busy(
3127                            &lifecycle.connections,
3128                            &mut stream,
3129                            dispatcher.config_id(),
3130                        )
3131                        .await
3132                        else {
3133                            continue;
3134                        };
3135                        // Keep the acceptance-time deadline across the
3136                        // credential check and connection-task scheduling.
3137                        let d = dispatcher.clone();
3138                        let shutdown = request_shutdown_rx.clone();
3139                        let lifecycle = Arc::clone(&lifecycle);
3140                        let handle = spawn_connection_task(Arc::clone(&active), async move {
3141                            // Released when the handler ends, whether it returns,
3142                            // panics or is aborted at shutdown.
3143                            let _permit = permit;
3144                            handle_conn_with_lifecycle(
3145                                stream,
3146                                d,
3147                                Some(shutdown),
3148                                initial_frame_deadline,
3149                                Some(lifecycle),
3150                            )
3151                            .await;
3152                        });
3153                        let mut tasks = connection_tasks
3154                            .lock()
3155                            .unwrap_or_else(std::sync::PoisonError::into_inner);
3156                        tasks.retain(|task| !task.is_finished());
3157                        tasks.push(handle);
3158                    }
3159                    Err(e) => {
3160                        let delay = next_accept_error_backoff(accept_error_backoff);
3161                        accept_error_backoff = Some(delay);
3162                        let capacity_exhausted = matches!(
3163                            e.raw_os_error(),
3164                            Some(libc::EMFILE) | Some(libc::ENFILE)
3165                        );
3166                        if last_accept_error_log.is_none_or(|last| {
3167                            last.elapsed() >= std::time::Duration::from_secs(30)
3168                        }) {
3169                            tracing::error!(
3170                                error = %e,
3171                                capacity_exhausted,
3172                                retry_ms = delay.as_millis(),
3173                                "daemon accept failed; retrying with bounded backoff"
3174                            );
3175                            last_accept_error_log = Some(std::time::Instant::now());
3176                        }
3177                        tokio::time::sleep(delay).await;
3178                    }
3179                }
3180            }
3181        } => DaemonShutdownReason::Signal,
3182        result = &mut shutdown => { result?; DaemonShutdownReason::Signal },
3183        _ = wait_for_idle(&dispatcher, &lifecycle) => DaemonShutdownReason::Idle,
3184    };
3185
3186    lifecycle.draining(reason);
3187
3188    // A listening backlog is not admitted work. Close it before draining so
3189    // new clients cannot finish writing to a socket nobody will accept.
3190    drop(listener);
3191
3192    // Signal the checkpoint task to exit before draining, so `drain()`
3193    // actually waits on it via `track_background_task` rather than the
3194    // task outliving the drain window (or the process) unsignalled.
3195    let _ = checkpoint_shutdown_tx.send(());
3196
3197    // Per-run signal: read scopes stop promptly, admitted writes ignore it and
3198    // retain the rest of the configured drain window to commit or roll back.
3199    if reason == DaemonShutdownReason::Signal {
3200        let _ = request_shutdown_tx.send(true);
3201    }
3202
3203    // Same ordering contract for ADR-119 daemon components: cancel before
3204    // drain, so each component's supervisor (itself a tracked task) can run
3205    // its bounded shutdown inside the drain wait.
3206    daemon_shutdown_token().cancel();
3207
3208    let drained = if reason == DaemonShutdownReason::Idle {
3209        tokio::select! {
3210            _ = drain_for_idle(&active, drain_timeout()) => true,
3211            result = &mut shutdown => {
3212                result?;
3213                lifecycle.draining(DaemonShutdownReason::Signal);
3214                let _ = request_shutdown_tx.send(true);
3215                drain(&active).await
3216            }
3217        }
3218    } else {
3219        drain(&active).await
3220    };
3221    let tasks = {
3222        let mut retained = connection_tasks
3223            .lock()
3224            .unwrap_or_else(std::sync::PoisonError::into_inner);
3225        std::mem::take(&mut *retained)
3226    };
3227    finish_connection_tasks(tasks, drained).await;
3228
3229    // A concurrent client's `kill_and_respawn` may have already decided
3230    // this daemon looked stale, killed it, and spawned a replacement that
3231    // bound the same socket/PID paths while this daemon was draining above.
3232    // Reacquire the recovery lock (the same one that serializes startup) and
3233    // only unlink if the PID file still names this process AND the socket at
3234    // `sock` is still the exact one this daemon bound — otherwise a
3235    // replacement daemon owns those paths now and unlinking would delete its
3236    // live socket/PID out from under it.
3237    match acquire_recovery_lock() {
3238        Some(_shutdown_lock) => {
3239            shutdown_cleanup_if_owned(&sock, &pid_file, bound_identity);
3240        }
3241        None => {
3242            tracing::warn!(
3243                "could not acquire recovery lock for shutdown cleanup; \
3244                 skipping unlink to avoid deleting a replacement daemon's paths"
3245            );
3246        }
3247    }
3248    lifecycle.stopped();
3249    tracing::info!("khived stopped");
3250    Ok(())
3251}
3252
3253/// Remove `sock`/`pid_file` only if they still belong to this process: the PID
3254/// file must name `std::process::id()` AND the socket currently at `sock` must
3255/// still be the exact one identified by `bound_identity` (dev/ino, not path).
3256///
3257/// Returns `true` if cleanup ran, `false` if it was skipped because a
3258/// replacement daemon already owns those paths. The caller must hold
3259/// the recovery lock across this call — the same lock daemon startup holds
3260/// across cleanup+bind+pid-write — so no replacement can bind between this
3261/// function's checks and its unlinks.
3262#[cfg(unix)]
3263fn shutdown_cleanup_if_owned(
3264    sock: &std::path::Path,
3265    pid_file: &std::path::Path,
3266    bound_identity: Option<SocketIdentity>,
3267) -> bool {
3268    let pid_is_ours = std::fs::read_to_string(pid_file)
3269        .ok()
3270        .and_then(|s| s.trim().parse::<u32>().ok())
3271        == Some(std::process::id());
3272    let socket_is_ours = bound_identity.is_some() && socket_identity(sock) == bound_identity;
3273    if pid_is_ours && socket_is_ours {
3274        let _ = std::fs::remove_file(sock);
3275        let _ = std::fs::remove_file(pid_file);
3276        true
3277    } else {
3278        tracing::warn!(
3279            socket = ?sock,
3280            pid_file = ?pid_file,
3281            "skipping shutdown cleanup — a replacement daemon already owns this socket/PID"
3282        );
3283        false
3284    }
3285}
3286
3287// ── helpers ───────────────────────────────────────────────────────────────────
3288
3289/// Liveness verdict for a `kill(pid, 0)` probe.
3290#[cfg(unix)]
3291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3292enum PidLiveness {
3293    /// errno 0 — signal delivery succeeded, the process exists and this
3294    /// caller may signal it.
3295    Alive,
3296    /// ESRCH (or any other non-EPERM errno) — no such process.
3297    Dead,
3298    /// EPERM — the process exists but this caller lacks permission to
3299    /// signal it. Unknown-safe: treated as running so stale-daemon cleanup
3300    /// never unlinks a live daemon's socket/PID file just because it is
3301    /// owned by a different user/uid.
3302    PermissionDenied,
3303}
3304
3305#[cfg(unix)]
3306impl PidLiveness {
3307    fn is_running(self) -> bool {
3308        !matches!(self, PidLiveness::Dead)
3309    }
3310}
3311
3312/// Maps a `kill(pid, 0)` outcome (return code + errno) to a [`PidLiveness`].
3313/// Pure and side-effect-free so the errno mapping can be unit tested without
3314/// a real process probe.
3315#[cfg(unix)]
3316fn classify_kill_result(rc: i32, errno: i32) -> PidLiveness {
3317    if rc == 0 {
3318        return PidLiveness::Alive;
3319    }
3320    match errno {
3321        libc::EPERM => PidLiveness::PermissionDenied,
3322        _ => PidLiveness::Dead,
3323    }
3324}
3325
3326#[cfg(unix)]
3327fn is_process_running(pid: u32) -> bool {
3328    let Ok(pid) = i32::try_from(pid) else {
3329        return false;
3330    };
3331    if pid <= 0 {
3332        return false;
3333    }
3334    // SAFETY: signal 0 is an existence/permission probe with no side effects.
3335    let rc = unsafe { libc::kill(pid, 0) };
3336    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
3337    classify_kill_result(rc, errno).is_running()
3338}
3339
3340/// Whether a PID may identify an incumbent from this candidate's point of view.
3341///
3342/// Production rejects the current PID so a stale rendezvous left by a prior
3343/// process whose PID was reused cannot protect an unrelated socket. The
3344/// in-process daemon harness opts in to the same-PID case because all of its
3345/// otherwise independent boot candidates necessarily share one OS process.
3346#[cfg(unix)]
3347fn pid_can_name_incumbent(pid: u32, current_pid: u32, allow_same_process_incumbent: bool) -> bool {
3348    allow_same_process_incumbent || pid != current_pid
3349}
3350
3351/// Bounded timeout for the protocol-identity probe used by duplicate-daemon
3352/// detection. Short enough that a hung or foreign listener does not stall
3353/// startup; long enough for a live khived under normal load to answer a
3354/// `probe_only` frame.
3355#[cfg(unix)]
3356const DUPLICATE_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
3357
3358/// Whether the listener at `sock` actually speaks the khived wire protocol
3359/// **as a khived this process can defer to** — identified by a configuration
3360/// compatible with `expected_config_id`.
3361///
3362/// A live PID plus an accepting Unix socket is not proof of khived: any
3363/// unrelated process that happens to have bound the same path also answers
3364/// `connect()`. Nor is any well-formed [`DaemonResponseFrame`] proof: a
3365/// `config_mismatch`/`version_mismatch` response, a `metrics_only` snapshot
3366/// response, or a legacy pre-probe daemon that falls through to normal
3367/// dispatch on the empty `ops` string, all deserialize cleanly without being
3368/// the unambiguous "yes, alive and identity-matching" answer this check
3369/// needs — the daemon's `metrics_only` arm in particular echoes the same
3370/// `ok=true, result=None, error=None`, all-mismatch-flags-false, matching
3371/// protocol version and `served_config_id` shape as the probe-ack arm, and
3372/// is distinguished only by carrying `metrics: Some(...)`. This sends a
3373/// bounded `probe_only` frame (the same identity probe the client-side
3374/// recovery path uses, `crates/khive-mcp/src/daemon.rs::probe_daemon_identity`)
3375/// carrying this process's own `config_id`, and requires a compatible
3376/// probe-branch shape back: `ok=true`, `result=None`, `error=None`,
3377/// `metrics=None`, `request_id=None` (this probe frame never sets one), no
3378/// mismatch flags, matching protocol version, and matching
3379/// `served_config_id` — mirroring the client probe's `is_probe_ack` check so
3380/// both sides of the protocol agree on what "alive" means. Connect, write,
3381/// and read are all inside the one bounded timeout: `UnixStream::connect`
3382/// itself awaits write readiness, so a listener with a saturated accept
3383/// backlog could otherwise hold this call open past the advertised bound.
3384/// A connect that succeeds but never answers, times out, or answers with
3385/// non-protocol bytes, a mismatched identity, a `metrics_only` snapshot, or
3386/// any other non-probe-shaped response is not treated as the same khived
3387/// and falls through to the stale-socket recovery path instead.
3388#[cfg(unix)]
3389async fn socket_speaks_khived_protocol(sock: &std::path::Path, expected_config_id: &str) -> bool {
3390    let probe = DaemonRequestFrame {
3391        probe_only: true,
3392        protocol_version: PROTOCOL_VERSION,
3393        config_id: expected_config_id.to_string(),
3394        ..Default::default()
3395    };
3396    let Ok(payload) = serde_json::to_vec(&probe) else {
3397        return false;
3398    };
3399    let response = tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, async {
3400        let mut stream = UnixStream::connect(sock).await.ok()?;
3401        write_frame(&mut stream, &payload).await.ok()?;
3402        let raw = read_frame(&mut stream).await.ok()?;
3403        serde_json::from_slice::<DaemonResponseFrame>(&raw).ok()
3404    })
3405    .await
3406    .ok()
3407    .flatten();
3408
3409    let Some(resp) = response else {
3410        return false;
3411    };
3412    let is_probe_ack = resp.ok
3413        && resp.result.is_none()
3414        && resp.error.is_none()
3415        && resp.metrics.is_none()
3416        && resp.request_id.is_none();
3417    is_probe_ack
3418        && !resp.version_mismatch
3419        && !resp.namespace_mismatch
3420        && !resp.config_mismatch
3421        && resp.daemon_protocol_version == PROTOCOL_VERSION
3422        && resp
3423            .served_config_id
3424            .as_deref()
3425            .is_some_and(|served| config_ids_compatible(expected_config_id, served))
3426}
3427
3428/// Whether connecting to an existing socket path is definitely unreachable.
3429/// Timeouts and other errors remain ambiguous so cleanup fails closed.
3430#[cfg(unix)]
3431async fn socket_is_unreachable(sock: &std::path::Path) -> bool {
3432    match tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, UnixStream::connect(sock)).await {
3433        Ok(Err(error)) => matches!(
3434            error.kind(),
3435            std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
3436        ),
3437        _ => false,
3438    }
3439}
3440
3441/// What owns the daemon PID file, from the point of view of a process that wants
3442/// to start. A live owner is never cleaned up: a draining incumbent closes its
3443/// listener before it releases writers, so an unanswered socket is ambiguous and
3444/// deleting its PID file is how two daemons end up on one store.
3445#[cfg(unix)]
3446enum Incumbent {
3447    /// A live process that answered the khived protocol on the socket.
3448    Serving(u32),
3449    /// A live PID still has an active or ambiguous rendezvous. Nothing removed.
3450    Live(u32),
3451    /// Nothing live owns the store; the socket and PID file were removed.
3452    Stale,
3453}
3454
3455/// Check whether `pid_file`/`sock` already name a live daemon and, if not,
3456/// remove the stale rendezvous files so the caller may bind fresh.
3457///
3458/// A live protocol responder, reachable socket, or live holder of the PID-file
3459/// lock means the caller must refuse to start. An unlocked PID with no listener
3460/// is a reused PID and may be reclaimed.
3461#[cfg(unix)]
3462async fn cleanup_stale_daemon(
3463    sock: &std::path::Path,
3464    pid_file: &std::path::Path,
3465    allow_same_process_incumbent: bool,
3466    expected_config_id: &str,
3467) -> Incumbent {
3468    let mut stale_pid_file_guard = None;
3469    if let Ok(pid_str) = std::fs::read_to_string(pid_file) {
3470        if let Ok(pid) = pid_str.trim().parse::<u32>() {
3471            if pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
3472                && is_process_running(pid)
3473            {
3474                if sock.exists() && socket_speaks_khived_protocol(sock, expected_config_id).await {
3475                    return Incumbent::Serving(pid);
3476                }
3477                if sock.exists() && !socket_is_unreachable(sock).await {
3478                    return Incumbent::Live(pid);
3479                }
3480                match try_acquire_pid_file_lock(pid_file) {
3481                    Ok(Some(guard)) => stale_pid_file_guard = Some(guard),
3482                    Ok(None) => return Incumbent::Live(pid),
3483                    Err(e) => {
3484                        tracing::warn!(
3485                            error = %e,
3486                            path = ?pid_file,
3487                            "cannot check daemon PID-file lock"
3488                        );
3489                        return Incumbent::Live(pid);
3490                    }
3491                }
3492            }
3493        }
3494    }
3495    if sock.exists() {
3496        if let Err(e) = std::fs::remove_file(sock) {
3497            tracing::warn!(error = %e, path = ?sock, "failed to remove stale socket");
3498        }
3499    }
3500    if pid_file.exists() {
3501        if let Err(e) = std::fs::remove_file(pid_file) {
3502            tracing::warn!(error = %e, path = ?pid_file, "failed to remove stale PID file");
3503        }
3504    }
3505    drop(stale_pid_file_guard);
3506    Incumbent::Stale
3507}
3508
3509/// Create and lock `pid_file` exclusively (`O_EXCL`) and write this process's PID.
3510///
3511/// Uses `create_new(true)` rather than `create(true).truncate(true)` so
3512/// this can never silently overwrite a PID file another process created —
3513/// the held file lock also identifies a starting or draining daemon when its
3514/// socket is not yet reachable.
3515#[cfg(unix)]
3516fn write_pid_file_exclusive(pid_file: &std::path::Path) -> std::io::Result<std::fs::File> {
3517    use std::os::unix::fs::OpenOptionsExt;
3518
3519    let mut opts = std::fs::OpenOptions::new();
3520    opts.write(true).create_new(true).mode(0o600);
3521    let mut f = opts.open(pid_file)?;
3522    // SAFETY: flock is a POSIX advisory lock with no memory side effects.
3523    let rc = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
3524    if rc != 0 {
3525        return Err(std::io::Error::last_os_error());
3526    }
3527    f.write_all(std::process::id().to_string().as_bytes())?;
3528    Ok(f)
3529}
3530
3531/// Try to lock an existing PID file without creating it. `Some(file)` means
3532/// there is no daemon lock holder; `None` means a daemon still owns the file.
3533#[cfg(unix)]
3534fn try_acquire_pid_file_lock(pid_file: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
3535    let file = std::fs::OpenOptions::new()
3536        .read(true)
3537        .write(true)
3538        .open(pid_file)?;
3539    // SAFETY: flock is a POSIX advisory lock with no memory side effects.
3540    let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
3541    if rc == 0 {
3542        return Ok(Some(file));
3543    }
3544    let error = std::io::Error::last_os_error();
3545    if error.kind() == std::io::ErrorKind::WouldBlock
3546        || error.raw_os_error() == Some(libc::EWOULDBLOCK)
3547    {
3548        Ok(None)
3549    } else {
3550        Err(error)
3551    }
3552}
3553
3554/// Remove the PID file on setup failure only while its path still names the
3555/// file this start attempt created.
3556#[cfg(unix)]
3557fn remove_pid_file_if_owned(pid_file: &std::path::Path, guard: &std::fs::File) {
3558    let Ok(owned) = guard.metadata() else {
3559        return;
3560    };
3561    let Ok(current) = std::fs::metadata(pid_file) else {
3562        return;
3563    };
3564    if owned.dev() == current.dev() && owned.ino() == current.ino() {
3565        if let Err(e) = std::fs::remove_file(pid_file) {
3566            tracing::warn!(error = %e, path = ?pid_file, "failed to remove unbound PID file");
3567        }
3568    }
3569}
3570
3571/// Return `true` if `pid_file` currently names an eligible live process that
3572/// still answers on `sock` — i.e. a daemon already owns this rendezvous and it
3573/// is safe to defer to it rather than treat the `AlreadyExists` PID-file
3574/// collision as a boot failure. Eligibility requires a different PID in
3575/// production; the explicit in-process harness may allow the current PID.
3576#[cfg(unix)]
3577async fn pid_file_names_a_reachable_daemon(
3578    pid_file: &std::path::Path,
3579    sock: &std::path::Path,
3580    allow_same_process_incumbent: bool,
3581    expected_config_id: &str,
3582) -> bool {
3583    let Ok(pid_str) = std::fs::read_to_string(pid_file) else {
3584        return false;
3585    };
3586    let Ok(pid) = pid_str.trim().parse::<u32>() else {
3587        return false;
3588    };
3589    pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
3590        && is_process_running(pid)
3591        && sock.exists()
3592        && socket_speaks_khived_protocol(sock, expected_config_id).await
3593}
3594
3595#[cfg(unix)]
3596async fn drain(active: &std::sync::atomic::AtomicUsize) -> bool {
3597    drain_with_timeout(active, drain_timeout()).await
3598}
3599
3600/// Voluntary retirement keeps admitted workers and rendezvous ownership alive.
3601#[cfg(unix)]
3602async fn drain_for_idle(active: &std::sync::atomic::AtomicUsize, timeout: std::time::Duration) {
3603    let deadline = tokio::time::Instant::now() + timeout;
3604    let mut warned = false;
3605    while active.load(std::sync::atomic::Ordering::SeqCst) + background_task_count() != 0 {
3606        if !warned && tokio::time::Instant::now() >= deadline {
3607            tracing::warn!(
3608                "idle drain interval elapsed; retaining workers and rendezvous until settled"
3609            );
3610            warned = true;
3611        }
3612        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3613    }
3614}
3615
3616#[cfg(unix)]
3617async fn drain_with_timeout(
3618    active: &std::sync::atomic::AtomicUsize,
3619    timeout: std::time::Duration,
3620) -> bool {
3621    use std::sync::atomic::Ordering;
3622    // One sequentially-consistent order spans connection handoff and tracked
3623    // task publication. Drain must not observe an ended connection and a
3624    // stale pre-publication background count as two simultaneous zeroes.
3625    let remaining = || active.load(Ordering::SeqCst) + background_task_count();
3626    if remaining() == 0 {
3627        return true;
3628    }
3629    let deadline = tokio::time::Instant::now() + timeout;
3630    while remaining() > 0 {
3631        if tokio::time::Instant::now() >= deadline {
3632            tracing::warn!(
3633                remaining_connections = active.load(Ordering::SeqCst),
3634                remaining_background_tasks = background_task_count(),
3635                outstanding_background_tasks = %background_task_names().join(", "),
3636                "drain timeout reached; forcing shutdown"
3637            );
3638            return false;
3639        }
3640        tokio::select! {
3641            _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
3642            _ = tokio::time::sleep_until(deadline) => {}
3643        }
3644    }
3645    true
3646}
3647
3648#[cfg(unix)]
3649async fn finish_connection_tasks(tasks: Vec<tokio::task::JoinHandle<()>>, drained: bool) {
3650    if !drained {
3651        for task in &tasks {
3652            if !task.is_finished() {
3653                task.abort();
3654            }
3655        }
3656    }
3657    for task in tasks {
3658        let _ = task.await;
3659    }
3660}
3661
3662/// The bound `drain()` waits for tracked background tasks at daemon shutdown
3663/// (`KHIVE_DRAIN_TIMEOUT_SECS`, default 10s). Public so component supervision
3664/// can clamp per-component shutdown timeouts against it — a component timeout
3665/// longer than the drain bound could never complete its abort/state
3666/// transition before the daemon returns.
3667pub fn drain_timeout() -> std::time::Duration {
3668    let secs = std::env::var("KHIVE_DRAIN_TIMEOUT_SECS")
3669        .ok()
3670        .and_then(|v| v.parse::<u64>().ok())
3671        .unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS);
3672    std::time::Duration::from_secs(secs)
3673}
3674
3675/// Returns `true` for non-empty env values that are not `"0"` or `"false"`.
3676#[cfg(unix)]
3677pub fn env_truthy(key: &str) -> bool {
3678    std::env::var(key)
3679        .map(|v| {
3680            let v = v.trim();
3681            !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
3682        })
3683        .unwrap_or(false)
3684}
3685
3686include!("daemon_khive_root_tests.rs");
3687
3688/// Serve one already-admitted test connection through the production frame handler.
3689///
3690/// This seam owns no socket path, PID, boot guard, background components, or
3691/// process-wide shutdown state. The caller owns and joins the connection task.
3692/// It deliberately does not exercise listener admission or daemon lifecycle.
3693#[cfg(all(unix, any(test, feature = "test-internals")))]
3694#[doc(hidden)]
3695pub async fn serve_connection_for_test<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
3696    handle_conn_with_shutdown(
3697        stream,
3698        dispatcher,
3699        None,
3700        tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
3701    )
3702    .await;
3703}
3704
3705#[cfg(all(test, unix))]
3706mod tests {
3707    include!("daemon/plan_tests.rs");
3708    mod shutdown_signals {
3709        include!("daemon/shutdown_signal_tests.rs");
3710    }
3711    mod connection_limit_tests;
3712    use super::*;
3713    use serial_test::serial;
3714
3715    #[test]
3716    fn lexical_timeout_detail_hides_marker_from_old_clients_without_changing_frame_fit() {
3717        let public = serde_json::json!({
3718            "results": [{"ok": true, "tool": "knowledge.search", "result": "| name |\n|---|\n| first |\n"}],
3719            "summary": {"total": 1, "succeeded": 1, "failed": 0}
3720        });
3721        let public_raw = public.to_string();
3722        let mut marked = public;
3723        marked[DAEMON_LEXICAL_TIMEOUT_MARKER] = serde_json::json!(true);
3724        let marked_raw = marked.to_string();
3725        let (result, detail) = take_daemon_lexical_timeout_marker(marked_raw.clone());
3726        assert_eq!(result, public_raw);
3727        assert_eq!(detail, Some(serde_json::json!({"lexical_timeout": true})));
3728
3729        let frame = |result, error_detail| DaemonResponseFrame {
3730            ok: true,
3731            result: Some(result),
3732            error: None,
3733            error_detail,
3734            namespace_mismatch: false,
3735            config_mismatch: false,
3736            served_config_id: Some("test".to_string()),
3737            version_mismatch: false,
3738            daemon_protocol_version: PROTOCOL_VERSION,
3739            metrics: None,
3740            request_id: Some(u64::MAX),
3741        };
3742        let internal_len = serde_json::to_vec(&frame(marked_raw, None)).unwrap().len();
3743        let sent = frame(result, detail);
3744        assert_eq!(sent.result.as_deref(), Some(public_raw.as_str()));
3745        assert!(!sent
3746            .result
3747            .as_deref()
3748            .unwrap()
3749            .contains(DAEMON_LEXICAL_TIMEOUT_MARKER));
3750        assert_eq!(serde_json::to_vec(&sent).unwrap().len(), internal_len);
3751
3752        let untouched = " {\"results\":[],\"summary\":{}} ".to_string();
3753        assert_eq!(
3754            take_daemon_lexical_timeout_marker(untouched.clone()),
3755            (untouched, None)
3756        );
3757    }
3758
3759    #[tokio::test]
3760    async fn incomplete_initial_frames_release_the_connection_deadline() {
3761        for prefix in [&[][..], &[0, 0][..], &[0, 0, 0, 5][..]] {
3762            let (mut peer, mut server) = tokio::io::duplex(64);
3763            peer.write_all(prefix).await.expect("send partial frame");
3764            let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(10);
3765            let error = read_initial_frame(&mut server, deadline)
3766                .await
3767                .expect_err("an idle peer cannot hold a daemon connection indefinitely");
3768            assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
3769        }
3770
3771        let (mut peer, mut server) = tokio::io::duplex(64);
3772        write_frame(&mut peer, b"{}")
3773            .await
3774            .expect("send full frame");
3775        assert_eq!(
3776            read_initial_frame(
3777                &mut server,
3778                tokio::time::Instant::now() + std::time::Duration::from_secs(1),
3779            )
3780            .await
3781            .expect("complete frame remains readable"),
3782            b"{}"
3783        );
3784    }
3785
3786    #[test]
3787    fn repeated_accept_failures_back_off_and_cap_at_one_second() {
3788        let mut previous = None;
3789        for expected_ms in [10, 20, 40, 80, 160, 320, 640, 1000, 1000] {
3790            let next = next_accept_error_backoff(previous);
3791            assert_eq!(next.as_millis(), expected_ms);
3792            previous = Some(next);
3793        }
3794        assert_eq!(next_accept_error_backoff(None).as_millis(), 10);
3795    }
3796
3797    #[derive(Debug)]
3798    struct DrainBlockingBlobStore {
3799        started: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
3800        release: Arc<tokio::sync::Semaphore>,
3801    }
3802
3803    #[async_trait]
3804    impl khive_storage::BlobStore for DrainBlockingBlobStore {
3805        async fn put(
3806            &self,
3807            _bytes: Vec<u8>,
3808        ) -> khive_storage::StorageResult<khive_storage::ContentRef> {
3809            panic!("put is not used by the hydration drain test")
3810        }
3811
3812        async fn get_bounded_verified(
3813            &self,
3814            _content_ref: &khive_storage::ContentRef,
3815            _max_bytes: u64,
3816        ) -> khive_storage::StorageResult<Vec<u8>> {
3817            if let Some(started) = self
3818                .started
3819                .lock()
3820                .unwrap_or_else(std::sync::PoisonError::into_inner)
3821                .take()
3822            {
3823                let _ = started.send(());
3824            }
3825            self.release
3826                .clone()
3827                .acquire_owned()
3828                .await
3829                .expect("test release semaphore remains open")
3830                .forget();
3831            Ok(b"late result".to_vec())
3832        }
3833
3834        async fn exists(
3835            &self,
3836            _content_ref: &khive_storage::ContentRef,
3837        ) -> khive_storage::StorageResult<bool> {
3838            panic!("exists is not used by the hydration drain test")
3839        }
3840
3841        async fn size(
3842            &self,
3843            _content_ref: &khive_storage::ContentRef,
3844        ) -> khive_storage::StorageResult<Option<u64>> {
3845            panic!("size is not used by the hydration drain test")
3846        }
3847
3848        async fn delete(
3849            &self,
3850            _content_ref: &khive_storage::ContentRef,
3851        ) -> khive_storage::StorageResult<bool> {
3852            panic!("delete is not used by the hydration drain test")
3853        }
3854    }
3855
3856    struct AppendCompletionEventStore {
3857        inner: Arc<dyn khive_storage::EventStore>,
3858        first_append: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
3859    }
3860
3861    #[async_trait]
3862    impl khive_storage::EventStore for AppendCompletionEventStore {
3863        async fn append_event(
3864            &self,
3865            event: khive_storage::Event,
3866        ) -> khive_storage::StorageResult<()> {
3867            self.inner.append_event(event).await?;
3868            if let Some(completed) = self
3869                .first_append
3870                .lock()
3871                .unwrap_or_else(std::sync::PoisonError::into_inner)
3872                .take()
3873            {
3874                let _ = completed.send(());
3875            }
3876            Ok(())
3877        }
3878
3879        async fn append_events(
3880            &self,
3881            events: Vec<khive_storage::Event>,
3882        ) -> khive_storage::StorageResult<khive_storage::BatchWriteSummary> {
3883            self.inner.append_events(events).await
3884        }
3885
3886        async fn get_event(
3887            &self,
3888            id: uuid::Uuid,
3889        ) -> khive_storage::StorageResult<Option<khive_storage::Event>> {
3890            self.inner.get_event(id).await
3891        }
3892
3893        async fn query_events(
3894            &self,
3895            filter: khive_storage::EventFilter,
3896            page: khive_storage::PageRequest,
3897        ) -> khive_storage::StorageResult<khive_storage::Page<khive_storage::Event>> {
3898            self.inner.query_events(filter, page).await
3899        }
3900
3901        async fn count_events(
3902            &self,
3903            filter: khive_storage::EventFilter,
3904        ) -> khive_storage::StorageResult<u64> {
3905            self.inner.count_events(filter).await
3906        }
3907    }
3908
3909    #[tokio::test]
3910    #[serial(checkpoint_skip_metrics)]
3911    async fn secondary_only_checkpoint_topology_emits_lifecycle_outcome() {
3912        let main_backend = khive_db::StorageBackend::memory().expect("in-memory main backend");
3913        let inner_event_store = main_backend.events().expect("main event store");
3914        let (first_append_tx, first_append_rx) = tokio::sync::oneshot::channel();
3915        let event_store: Arc<dyn khive_storage::EventStore> =
3916            Arc::new(AppendCompletionEventStore {
3917                inner: inner_event_store,
3918                first_append: std::sync::Mutex::new(Some(first_append_tx)),
3919            });
3920        let secondary_dir = tempfile::tempdir().expect("secondary tempdir");
3921        let secondary_backend =
3922            khive_db::StorageBackend::sqlite_for_test(secondary_dir.path().join("secondary.db"))
3923                .expect("file-backed secondary backend");
3924
3925        let mut tasks = checkpoint_task_specs(
3926            None,
3927            vec![secondary_backend.pool_arc()],
3928            Some(Arc::clone(&event_store)),
3929            "local".to_string(),
3930        );
3931        assert_eq!(tasks.len(), 1);
3932        let task = tasks.pop().expect("one secondary checkpoint task");
3933        assert!(!task.is_main, "the only checkpoint task must be secondary");
3934        assert!(
3935            task.lifecycle_owner.is_some(),
3936            "the secondary task must own lifecycle emission when no main task exists"
3937        );
3938
3939        let config = CheckpointConfig {
3940            interval: std::time::Duration::from_millis(10),
3941            warn_pages: 0,
3942            ..CheckpointConfig::default()
3943        };
3944        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(());
3945        let handle = tokio::spawn(run_checkpoint_task(
3946            task.pool,
3947            config,
3948            task.lifecycle_owner,
3949            shutdown_rx,
3950            task.is_main,
3951        ));
3952
3953        // The scheduler intentionally aborts its bounded append worker during
3954        // shutdown. Wait on the append's completion edge before requesting
3955        // shutdown, so the observation cannot race that deliberate abort.
3956        tokio::time::timeout(std::time::Duration::from_secs(10), first_append_rx)
3957            .await
3958            .expect("secondary checkpoint owner did not complete an append within 10s")
3959            .expect("checkpoint lifecycle append completion sender dropped");
3960
3961        let events = event_store
3962            .query_events(
3963                khive_storage::EventFilter::default(),
3964                khive_storage::PageRequest {
3965                    limit: 100,
3966                    offset: 0,
3967                },
3968            )
3969            .await
3970            .expect("query lifecycle events");
3971
3972        shutdown_tx.send(()).expect("send checkpoint shutdown");
3973        tokio::time::timeout(std::time::Duration::from_secs(1), handle)
3974            .await
3975            .expect("checkpoint task should exit within 1s")
3976            .expect("checkpoint task panicked");
3977        assert!(
3978            !events.items.is_empty()
3979                && events
3980                    .items
3981                    .iter()
3982                    .all(|event| event.kind == khive_types::EventKind::CheckpointOutcomeRecorded),
3983            "the designated secondary owner must emit CheckpointOutcomeRecorded"
3984        );
3985
3986        let file_main_dir = tempfile::tempdir().expect("file-backed main tempdir");
3987        let file_main =
3988            khive_db::StorageBackend::sqlite_for_test(file_main_dir.path().join("main.db"))
3989                .expect("file-backed main backend");
3990        let tasks = checkpoint_task_specs(
3991            Some(file_main.pool_arc()),
3992            vec![secondary_backend.pool_arc()],
3993            Some(event_store),
3994            "local".to_string(),
3995        );
3996        assert!(tasks[0].is_main && tasks[0].lifecycle_owner.is_some());
3997        assert!(!tasks[1].is_main && tasks[1].lifecycle_owner.is_none());
3998    }
3999
4000    // Focused regression tests for the unsafe process probe (SAFETY: signal 0
4001    // is an existence check with no side effects; see is_process_running).
4002
4003    #[test]
4004    fn current_process_is_running() {
4005        // The current PID is always alive.
4006        let pid = std::process::id();
4007        assert!(
4008            is_process_running(pid),
4009            "current process {pid} should be detected as running"
4010        );
4011    }
4012
4013    #[test]
4014    fn pid_zero_is_not_running() {
4015        // PID 0 is the process group; kill(0, 0) sends to the group,
4016        // which we treat as invalid — the guard `pid <= 0` must block it.
4017        assert!(
4018            !is_process_running(0),
4019            "pid 0 must be rejected by the guard before the unsafe call"
4020        );
4021    }
4022
4023    #[test]
4024    fn very_large_pid_is_not_running() {
4025        // u32::MAX overflows i32 — try_from returns Err, guard returns false.
4026        assert!(
4027            !is_process_running(u32::MAX),
4028            "u32::MAX should fail i32 conversion and return false"
4029        );
4030    }
4031
4032    // EPERM (process exists, no permission to signal it) must not be
4033    // misread as "not running" during stale-daemon cleanup.
4034
4035    #[test]
4036    fn classify_kill_result_zero_is_alive() {
4037        assert_eq!(classify_kill_result(0, 0), PidLiveness::Alive);
4038        assert!(classify_kill_result(0, 0).is_running());
4039    }
4040
4041    #[test]
4042    fn classify_kill_result_esrch_is_dead() {
4043        assert_eq!(classify_kill_result(-1, libc::ESRCH), PidLiveness::Dead);
4044        assert!(!classify_kill_result(-1, libc::ESRCH).is_running());
4045    }
4046
4047    #[test]
4048    fn classify_kill_result_eperm_is_permission_denied_and_counts_as_running() {
4049        assert_eq!(
4050            classify_kill_result(-1, libc::EPERM),
4051            PidLiveness::PermissionDenied
4052        );
4053        assert!(
4054            classify_kill_result(-1, libc::EPERM).is_running(),
4055            "EPERM must be unknown-safe: treated as running, never as a basis \
4056             for stale cleanup to unlink a live daemon's rendezvous files"
4057        );
4058    }
4059
4060    #[test]
4061    fn same_process_pid_requires_explicit_in_process_harness_opt_in() {
4062        let current = std::process::id();
4063        assert!(
4064            !pid_can_name_incumbent(current, current, false),
4065            "production startup must not trust a same-PID stale rendezvous"
4066        );
4067        assert!(
4068            pid_can_name_incumbent(current, current, true),
4069            "the in-process harness must let a live same-PID owner win"
4070        );
4071        // Keep the probe two away from `current` so the fixture preserves the
4072        // off-by-one regression check for adjacent PIDs; wrapping_add avoids
4073        // making the test overflow-sensitive at the u32 boundary.
4074        let distinct_probe_pid = current.wrapping_add(2);
4075        assert_ne!(
4076            distinct_probe_pid, current,
4077            "probe PID must differ from this process's PID"
4078        );
4079        assert!(
4080            pid_can_name_incumbent(distinct_probe_pid, current, false),
4081            "a distinct PID remains eligible under ordinary production rules"
4082        );
4083    }
4084
4085    #[test]
4086    fn pid_1_probe_is_running_regardless_of_permission_outcome() {
4087        // PID 1 (init/launchd) always exists. An unprivileged process gets
4088        // EPERM signaling it (never ESRCH); running as root would get 0
4089        // instead. Either way `is_process_running` must report true — this
4090        // is the live regression guard for EPERM being misread as dead;
4091        // `classify_kill_result` above is the pure-function unit coverage
4092        // for the same mapping, kept independent of process ownership so
4093        // it is never flaky in CI.
4094        assert!(
4095            is_process_running(1),
4096            "PID 1 always exists; EPERM must not read as dead"
4097        );
4098    }
4099
4100    #[tokio::test]
4101    async fn stale_cleanup_preserves_live_incumbent_without_reachable_socket() {
4102        // No other test in this process may fork while this fixture briefly
4103        // owns a listener: a child that inherits it can keep the socket
4104        // reachable after this test drops its own descriptor.
4105        if crate::test_process::run_in_child() {
4106            return;
4107        }
4108        assert_eq!(
4109            std::env::var("KHIVE_RUNTIME_ISOLATED_TEST").ok().as_deref(),
4110            Some("daemon::tests::stale_cleanup_preserves_live_incumbent_without_reachable_socket"),
4111            "the stale-listener fixture must run alone in its child process"
4112        );
4113        for socket_exists in [false, true] {
4114            let dir = tempfile::tempdir().expect("tempdir");
4115            let sock = dir.path().join("khived.sock");
4116            let pid_file = dir.path().join("khived.pid");
4117            if socket_exists {
4118                let listener = std::os::unix::net::UnixListener::bind(&sock)
4119                    .expect("bind socket before closing listener");
4120                drop(listener);
4121            }
4122            let identity = socket_identity(&sock);
4123            assert_eq!(identity.is_some(), socket_exists);
4124            let error = UnixStream::connect(&sock)
4125                .await
4126                .expect_err("incumbent must have no reachable listener");
4127            assert_eq!(
4128                error.kind(),
4129                if socket_exists {
4130                    std::io::ErrorKind::ConnectionRefused
4131                } else {
4132                    std::io::ErrorKind::NotFound
4133                }
4134            );
4135            let live_pid = std::process::id().to_string();
4136            let _pid_file_guard = write_pid_file_exclusive(&pid_file)
4137                .expect("claim and lock the live incumbent PID file");
4138
4139            // Harness eligibility makes our own stable PID an incumbent;
4140            // ordinary same-PID rejection is covered separately above.
4141            assert!(
4142                matches!(
4143                    cleanup_stale_daemon(&sock, &pid_file, true, "probe-test").await,
4144                    Incumbent::Live(_) | Incumbent::Serving(_)
4145                ),
4146                "live incumbent must retain ownership with socket_exists={socket_exists}"
4147            );
4148            assert_eq!(
4149                std::fs::read_to_string(&pid_file).expect("live incumbent PID must survive"),
4150                live_pid
4151            );
4152            assert!(socket_identity(&sock) == identity);
4153        }
4154    }
4155
4156    #[tokio::test]
4157    #[serial]
4158    async fn live_foreign_pid_does_not_block_daemon_startup() {
4159        if crate::test_process::run_in_child() {
4160            return;
4161        }
4162
4163        let dir = tempfile::tempdir().expect("tempdir");
4164        let sock = dir.path().join("khived.sock");
4165        let pid_file = dir.path().join("khived.pid");
4166        std::env::set_var("KHIVE_SOCKET", &sock);
4167        std::env::set_var("KHIVE_PID", &pid_file);
4168        std::env::set_var("KHIVE_LOCK", dir.path().join("khived.recovery.lock"));
4169
4170        let stale_listener =
4171            std::os::unix::net::UnixListener::bind(&sock).expect("create stale socket path");
4172        drop(stale_listener);
4173
4174        let mut foreign = std::process::Command::new("/bin/sleep")
4175            .arg("30")
4176            .spawn()
4177            .expect("spawn live unrelated process");
4178        std::fs::write(&pid_file, foreign.id().to_string()).expect("write unrelated PID");
4179
4180        let dispatcher = MockDispatch {
4181            namespace: "local".to_string(),
4182            config_id: "foreign-pid-start-test".to_string(),
4183            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4184            pool: None,
4185            dispatch_err: None,
4186        };
4187        let daemon = tokio::spawn(run_daemon_in_process_test(dispatcher));
4188        let connected = tokio::time::timeout(std::time::Duration::from_secs(5), async {
4189            loop {
4190                if let Ok(stream) = UnixStream::connect(&sock).await {
4191                    break Some(stream);
4192                }
4193                if daemon.is_finished() {
4194                    break None;
4195                }
4196                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4197            }
4198        })
4199        .await;
4200
4201        let response = if let Ok(Some(mut stream)) = connected {
4202            let mut request = base_request_frame("foreign-pid-start-test");
4203            request.probe_only = true;
4204            let payload = serde_json::to_vec(&request).expect("encode probe request");
4205            tokio::time::timeout(std::time::Duration::from_secs(1), async {
4206                write_frame(&mut stream, &payload).await.ok()?;
4207                let raw = read_frame(&mut stream).await.ok()?;
4208                serde_json::from_slice::<DaemonResponseFrame>(&raw).ok()
4209            })
4210            .await
4211            .ok()
4212            .flatten()
4213        } else {
4214            None
4215        };
4216        let foreign_survived_start = foreign
4217            .try_wait()
4218            .expect("query unrelated process state")
4219            .is_none();
4220
4221        daemon.abort();
4222        let _ = daemon.await;
4223        let _ = foreign.kill();
4224        let _ = foreign.wait();
4225        std::env::remove_var("KHIVE_SOCKET");
4226        std::env::remove_var("KHIVE_PID");
4227        std::env::remove_var("KHIVE_LOCK");
4228
4229        assert!(
4230            response.is_some_and(|response| {
4231                response.ok
4232                    && response.served_config_id.as_deref() == Some("foreign-pid-start-test")
4233            }),
4234            "daemon must start and answer its identity probe"
4235        );
4236        assert!(
4237            foreign_survived_start,
4238            "starting khived must leave the unrelated live process running"
4239        );
4240    }
4241
4242    #[tokio::test]
4243    #[serial]
4244    async fn second_start_refuses_while_pid_file_is_locked_before_bind() {
4245        if crate::test_process::run_in_child() {
4246            return;
4247        }
4248
4249        let dir = tempfile::tempdir().expect("tempdir");
4250        let sock = dir.path().join("khived.sock");
4251        let pid_file = dir.path().join("khived.pid");
4252        std::env::set_var("KHIVE_SOCKET", &sock);
4253        std::env::set_var("KHIVE_PID", &pid_file);
4254        std::env::set_var("KHIVE_LOCK", dir.path().join("khived.recovery.lock"));
4255
4256        let _incumbent_startup_guard = write_pid_file_exclusive(&pid_file)
4257            .expect("incumbent claims and locks its PID file before binding");
4258        let dispatcher = MockDispatch {
4259            namespace: "local".to_string(),
4260            config_id: "startup-lock-test".to_string(),
4261            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4262            pool: None,
4263            dispatch_err: None,
4264        };
4265        let second = tokio::time::timeout(
4266            std::time::Duration::from_secs(1),
4267            run_daemon_in_process_test(dispatcher),
4268        )
4269        .await;
4270        let refused = matches!(second, Ok(Err(_)));
4271        let pid_file_survived = pid_file.exists();
4272        let socket_was_not_bound = !sock.exists();
4273
4274        std::env::remove_var("KHIVE_SOCKET");
4275        std::env::remove_var("KHIVE_PID");
4276        std::env::remove_var("KHIVE_LOCK");
4277
4278        assert!(
4279            refused,
4280            "a second start must refuse while an incumbent holds its pre-bind PID lock"
4281        );
4282        assert!(
4283            pid_file_survived,
4284            "the incumbent PID file must remain in place"
4285        );
4286        assert!(
4287            socket_was_not_bound,
4288            "the second start must not bind the socket"
4289        );
4290    }
4291
4292    #[test]
4293    fn env_truthy_recognises_set_values() {
4294        assert!(!env_truthy("__KHIVE_TEST_ABSENT_VAR_XYZ__"));
4295
4296        // env_truthy with a live value — set and unset atomically to avoid
4297        // cross-test pollution (not parallel-safe without serial_test, but these
4298        // are fast unit tests and the variable name is unique).
4299        let key = "__KHIVE_TEST_TRUTHY_ABC__";
4300        std::env::set_var(key, "1");
4301        assert!(env_truthy(key));
4302        std::env::set_var(key, "false");
4303        assert!(!env_truthy(key));
4304        std::env::set_var(key, "0");
4305        assert!(!env_truthy(key));
4306        std::env::remove_var(key);
4307    }
4308
4309    #[tokio::test(flavor = "current_thread", start_paused = true)]
4310    #[serial(background_tasks)]
4311    async fn accepted_connection_is_counted_before_first_poll_and_drain_waits() {
4312        use std::sync::atomic::Ordering;
4313
4314        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4315        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
4316        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
4317        let started_in_task = Arc::clone(&started);
4318
4319        let handle = spawn_connection_task(Arc::clone(&active), async move {
4320            started_in_task.store(true, Ordering::Relaxed);
4321            let _ = release_rx.await;
4322        });
4323
4324        assert_eq!(active.load(Ordering::Relaxed), 1);
4325        assert!(
4326            !started.load(Ordering::Relaxed),
4327            "the current-thread runtime must leave the spawned handler unpolled"
4328        );
4329
4330        let drain_fut = drain(active.as_ref());
4331        tokio::pin!(drain_fut);
4332        let too_early =
4333            tokio::time::timeout(std::time::Duration::from_millis(150), &mut drain_fut).await;
4334        assert!(
4335            too_early.is_err(),
4336            "drain must wait for a connection claimed before its task's first poll"
4337        );
4338        assert!(started.load(Ordering::Relaxed));
4339
4340        release_tx.send(()).expect("handler still waiting");
4341        tokio::time::timeout(std::time::Duration::from_secs(1), handle)
4342            .await
4343            .expect("handler should finish promptly")
4344            .expect("handler should not panic");
4345        assert_eq!(active.load(Ordering::Relaxed), 0);
4346        tokio::time::timeout(std::time::Duration::from_secs(1), drain_fut)
4347            .await
4348            .expect("drain should finish once the handler releases its claim");
4349    }
4350
4351    #[tokio::test(flavor = "current_thread")]
4352    async fn cancelled_connection_releases_count_before_first_poll() {
4353        use std::sync::atomic::Ordering;
4354
4355        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4356        let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
4357        let started_in_task = Arc::clone(&started);
4358        let handle = spawn_connection_task(Arc::clone(&active), async move {
4359            started_in_task.store(true, Ordering::Relaxed);
4360            std::future::pending::<()>().await;
4361        });
4362
4363        assert_eq!(active.load(Ordering::Relaxed), 1);
4364        handle.abort();
4365        let error = handle.await.expect_err("aborted handler must be cancelled");
4366        assert!(error.is_cancelled());
4367        assert!(!started.load(Ordering::Relaxed));
4368        assert_eq!(active.load(Ordering::Relaxed), 0);
4369    }
4370
4371    #[tokio::test(flavor = "current_thread")]
4372    async fn panicked_connection_releases_count() {
4373        use std::sync::atomic::Ordering;
4374
4375        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4376        let handle = spawn_connection_task(Arc::clone(&active), async move {
4377            panic!("intentional connection-handler panic");
4378        });
4379
4380        assert_eq!(active.load(Ordering::Relaxed), 1);
4381        let error = handle
4382            .await
4383            .expect_err("panicked handler must fail its join");
4384        assert!(error.is_panic());
4385        assert_eq!(active.load(Ordering::Relaxed), 0);
4386    }
4387
4388    #[test]
4389    fn connection_claim_releases_if_spawn_panics() {
4390        use std::sync::atomic::Ordering;
4391
4392        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4393        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4394            drop(spawn_connection_task(Arc::clone(&active), async {}));
4395        }));
4396
4397        assert!(result.is_err(), "tokio::spawn outside a runtime must panic");
4398        assert_eq!(active.load(Ordering::Relaxed), 0);
4399    }
4400
4401    #[tokio::test]
4402    #[serial(background_tasks)]
4403    async fn drain_returns_promptly_with_no_accepted_connection() {
4404        let active = std::sync::atomic::AtomicUsize::new(0);
4405        tokio::time::timeout(std::time::Duration::from_secs(1), drain(&active))
4406            .await
4407            .expect("empty drain should return immediately");
4408    }
4409
4410    #[test]
4411    fn stopped_listener_is_closed_before_drain() {
4412        use std::process::{Command, Stdio};
4413
4414        let dir = tempfile::Builder::new()
4415            .prefix("kh-drain-")
4416            .tempdir_in("/tmp")
4417            .expect("short isolated socket directory");
4418        let child_home = dir.path().join("home");
4419        std::fs::create_dir(&child_home).expect("empty daemon child HOME");
4420        let mut child = Command::new(std::env::current_exe().expect("test executable"))
4421            .args([
4422                "--exact",
4423                "daemon::tests::stopped_listener_is_closed_before_drain_child",
4424                "--ignored",
4425                "--nocapture",
4426                "--test-threads=1",
4427            ])
4428            .env_clear()
4429            .envs(
4430                std::env::vars_os().filter(|(key, _)| !key.to_string_lossy().starts_with("KHIVE_")),
4431            )
4432            .env("HOME", &child_home)
4433            .env("KHIVE_VOLUME_LOCK_DIR", dir.path().join("volume-locks"))
4434            .env_remove("LATTICE_MODEL_CACHE")
4435            .env("KHIVE_TEST_HARNESS", "1")
4436            .env("KHIVE_DRAIN_TEST_CHILD", "1")
4437            .env("KHIVE_SOCKET", dir.path().join("s"))
4438            .env("KHIVE_PID", dir.path().join("p"))
4439            .env("KHIVE_LOCK", dir.path().join("l"))
4440            .env("KHIVE_RECOVERER_LOCK", dir.path().join("r"))
4441            .env("KHIVE_DRAIN_TIMEOUT_SECS", "10")
4442            .current_dir(dir.path())
4443            .stdin(Stdio::null())
4444            .stdout(Stdio::piped())
4445            .stderr(Stdio::piped())
4446            .spawn()
4447            .expect("spawn isolated daemon test");
4448        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
4449        let completed = loop {
4450            match child.try_wait() {
4451                Ok(Some(_)) => break true,
4452                Ok(None) if std::time::Instant::now() < deadline => {
4453                    std::thread::sleep(std::time::Duration::from_millis(10));
4454                }
4455                _ => {
4456                    let _ = child.kill();
4457                    break false;
4458                }
4459            }
4460        };
4461        let output = child.wait_with_output().expect("reap daemon test child");
4462        assert!(completed, "daemon test child did not finish: {output:?}");
4463        assert!(output.status.success(), "daemon test failed: {output:?}");
4464        assert!(
4465            String::from_utf8_lossy(&output.stdout).contains("STOPPED_LISTENER_DRAIN_VERIFIED"),
4466            "child must run the listener witness: {output:?}"
4467        );
4468        assert!(
4469            std::fs::read_dir(child_home).unwrap().next().is_none(),
4470            "daemon drain child must leave its private HOME empty"
4471        );
4472    }
4473
4474    #[tokio::test]
4475    #[ignore = "subprocess helper, invoked by stopped_listener_is_closed_before_drain"]
4476    async fn stopped_listener_is_closed_before_drain_child() {
4477        assert_eq!(
4478            std::env::var("KHIVE_DRAIN_TEST_CHILD").expect("isolated child environment"),
4479            "1"
4480        );
4481        let _sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
4482            .expect("install child SIGTERM handler");
4483        let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
4484        let background = spawn_tracked_task(async move {
4485            release_rx.await.expect("release held drain task");
4486        });
4487        let dispatcher = MockDispatch {
4488            namespace: "local".to_string(),
4489            config_id: "drain-test".to_string(),
4490            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4491            pool: None,
4492            dispatch_err: None,
4493        };
4494        let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4495        let stopped = Arc::new(std::sync::atomic::AtomicBool::new(false));
4496        let callback_starts = Arc::clone(&starts);
4497        let callback_stopped = Arc::clone(&stopped);
4498        let boot_guard = Some(acquire_daemon_boot_guard().expect("boot guard"));
4499        let daemon = tokio::spawn(run_daemon_with_boot_guard_and_start(
4500            dispatcher,
4501            boot_guard,
4502            move |_| {
4503                use std::os::unix::fs::FileTypeExt;
4504                assert!(std::fs::metadata(socket_path())
4505                    .unwrap()
4506                    .file_type()
4507                    .is_socket());
4508                assert_eq!(
4509                    std::fs::read_to_string(pid_path()).unwrap(),
4510                    std::process::id().to_string()
4511                );
4512                assert_eq!(
4513                    callback_starts.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
4514                    0
4515                );
4516                track_named_background_task("startup-lifecycle-test", async move {
4517                    daemon_shutdown_token().cancelled().await;
4518                    callback_stopped.store(true, std::sync::atomic::Ordering::SeqCst);
4519                });
4520            },
4521        ));
4522        let sock = socket_path();
4523        let mut stream = tokio::time::timeout(std::time::Duration::from_secs(5), async {
4524            loop {
4525                if let Ok(stream) = UnixStream::connect(&sock).await {
4526                    break stream;
4527                }
4528                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4529            }
4530        })
4531        .await
4532        .expect("daemon must bind");
4533        let payload = serde_json::to_vec(&base_request_frame("drain-test"))
4534            .expect("encode readiness request");
4535        write_frame(&mut stream, &payload)
4536            .await
4537            .expect("write readiness request");
4538        let response =
4539            tokio::time::timeout(std::time::Duration::from_secs(2), read_frame(&mut stream))
4540                .await
4541                .expect("daemon must serve readiness request")
4542                .expect("read readiness response");
4543        let response: DaemonResponseFrame =
4544            serde_json::from_slice(&response).expect("decode readiness response");
4545        assert!(response.ok, "daemon readiness failed: {response:?}");
4546        assert_eq!(starts.load(std::sync::atomic::Ordering::SeqCst), 1);
4547        drop(stream);
4548
4549        // SAFETY: the isolated child signals only itself, after installing its handler.
4550        let rc = unsafe { libc::kill(std::process::id() as i32, libc::SIGTERM) };
4551        assert_eq!(rc, 0, "signal isolated daemon child");
4552        tokio::time::timeout(
4553            std::time::Duration::from_secs(2),
4554            daemon_shutdown_token().cancelled(),
4555        )
4556        .await
4557        .expect("daemon must begin shutdown");
4558        assert!(
4559            !daemon.is_finished(),
4560            "held background task must retain drain"
4561        );
4562        assert!(
4563            sock.exists(),
4564            "cleanup must not have removed the socket yet"
4565        );
4566        assert_eq!(
4567            std::fs::read_to_string(pid_path()).expect("draining daemon PID"),
4568            std::process::id().to_string()
4569        );
4570
4571        // Cancellation is published after listener close but before drain.
4572        let late_connect = tokio::time::timeout(
4573            std::time::Duration::from_secs(1),
4574            UnixStream::connect(&sock),
4575        )
4576        .await
4577        .expect("late connect must finish promptly");
4578        release_tx.send(()).expect("release daemon drain");
4579        background.await.expect("held background task must finish");
4580        tokio::time::timeout(std::time::Duration::from_secs(2), daemon)
4581            .await
4582            .expect("released daemon must finish shutdown")
4583            .expect("daemon task must not panic")
4584            .expect("daemon shutdown must succeed");
4585        let error = late_connect.expect_err("stopped listener must not queue new connections");
4586        assert_eq!(error.kind(), std::io::ErrorKind::ConnectionRefused);
4587        assert!(!sock.exists(), "owned socket must be removed after drain");
4588        assert!(
4589            !pid_path().exists(),
4590            "owned PID must be removed after drain"
4591        );
4592        assert!(
4593            stopped.load(std::sync::atomic::Ordering::SeqCst),
4594            "work started after ownership must finish inside daemon drain"
4595        );
4596        println!("STOPPED_LISTENER_DRAIN_VERIFIED");
4597    }
4598
4599    #[tokio::test(start_paused = true)]
4600    #[serial(background_tasks)]
4601    async fn graceful_drain_has_a_hard_upper_bound_with_stuck_work() {
4602        use std::sync::atomic::Ordering;
4603
4604        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4605        let task = spawn_connection_task(Arc::clone(&active), async {
4606            std::future::pending::<()>().await;
4607        });
4608        assert_eq!(active.load(Ordering::Relaxed), 1);
4609        let started = tokio::time::Instant::now();
4610
4611        let drained = drain_with_timeout(&active, std::time::Duration::from_millis(250)).await;
4612
4613        assert!(!drained, "stuck work must exhaust the drain bound");
4614        assert!(
4615            started.elapsed() >= std::time::Duration::from_millis(250)
4616                && started.elapsed() < std::time::Duration::from_millis(350),
4617            "graceful shutdown exceeded its configured bound: {:?}",
4618            started.elapsed()
4619        );
4620        finish_connection_tasks(vec![task], drained).await;
4621        assert_eq!(
4622            active.load(Ordering::Relaxed),
4623            0,
4624            "hard-bound escalation must abort, await, and release the handler"
4625        );
4626    }
4627
4628    #[tokio::test(start_paused = true)]
4629    #[serial(background_tasks)]
4630    async fn admitted_work_finishes_inside_drain_window_without_abort() {
4631        use std::sync::atomic::Ordering;
4632
4633        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4634        let committed = Arc::new(std::sync::atomic::AtomicBool::new(false));
4635        let committed_in_task = Arc::clone(&committed);
4636        let task = spawn_connection_task(Arc::clone(&active), async move {
4637            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4638            committed_in_task.store(true, Ordering::SeqCst);
4639        });
4640
4641        let drained = drain_with_timeout(&active, std::time::Duration::from_millis(250)).await;
4642        assert!(
4643            drained,
4644            "admitted work should finish inside the drain window"
4645        );
4646        finish_connection_tasks(vec![task], drained).await;
4647        assert!(committed.load(Ordering::SeqCst));
4648        assert_eq!(active.load(Ordering::Relaxed), 0);
4649    }
4650
4651    // `drain()` must wait for tracked background tasks (e.g. memory.recall's
4652    // serve-ledger append), not just in-flight connections, or a SIGTERM
4653    // lands mid-flight with no log and no row.
4654    //
4655    // `#[serial(background_tasks)]`: this test reads/asserts on the
4656    // process-wide `BACKGROUND_TASKS` static shared with the two counter
4657    // tests below. Under default parallel execution one test's increment
4658    // leaks into another's snapshot-then-assert window (reproduced: both
4659    // counter tests failed together, passed with `--test-threads=1`).
4660    // Serializing just this named group isolates them from each other
4661    // without forcing the whole test binary (including unrelated
4662    // `#[serial]` tests elsewhere in this crate) onto one thread.
4663    #[tokio::test]
4664    #[serial(background_tasks)]
4665    async fn drain_waits_for_tracked_background_tasks_before_returning() {
4666        let active = std::sync::atomic::AtomicUsize::new(0);
4667        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
4668
4669        track_background_task(async move {
4670            let _ = rx.await;
4671        });
4672        assert!(
4673            background_task_count() >= 1,
4674            "track_background_task must make the in-flight task visible immediately"
4675        );
4676
4677        let drain_fut = drain(&active);
4678        tokio::pin!(drain_fut);
4679
4680        // Must NOT resolve while the tracked task is still pending.
4681        let too_early =
4682            tokio::time::timeout(std::time::Duration::from_millis(150), &mut drain_fut).await;
4683        assert!(
4684            too_early.is_err(),
4685            "drain() must not return while a tracked background task is still running"
4686        );
4687
4688        // Completing the task must let drain() proceed promptly.
4689        tx.send(())
4690            .expect("tracked task still awaiting the oneshot");
4691        let done = tokio::time::timeout(std::time::Duration::from_secs(5), drain_fut).await;
4692        assert!(
4693            done.is_ok(),
4694            "drain() must return once the tracked background task finishes"
4695        );
4696    }
4697
4698    #[tokio::test]
4699    #[serial(background_tasks)]
4700    async fn drain_waits_for_hydration_after_its_last_request_waiter_is_cancelled() {
4701        let before = background_task_count();
4702        let active = std::sync::atomic::AtomicUsize::new(0);
4703        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4704        let release = Arc::new(tokio::sync::Semaphore::new(0));
4705        let store = Arc::new(DrainBlockingBlobStore {
4706            started: std::sync::Mutex::new(Some(started_tx)),
4707            release: Arc::clone(&release),
4708        });
4709        let hydrator = Arc::new(
4710            crate::BlobHydrator::new(
4711                store as Arc<dyn khive_storage::BlobStore>,
4712                khive_storage::MAX_BLOB_WHOLE_BYTES,
4713            )
4714            .expect("minimum hydration budget is valid"),
4715        );
4716        let content_ref =
4717            khive_storage::ContentRef::from_hex("a".repeat(64)).expect("fixture content ref");
4718
4719        let request_hydrator = Arc::clone(&hydrator);
4720        let request = tokio::spawn(async move {
4721            request_hydrator
4722                .hydrate_verified(&content_ref, khive_storage::MAX_BLOB_WHOLE_BYTES)
4723                .await
4724        });
4725        started_rx.await.expect("backend work must begin");
4726        request.abort();
4727        assert!(request.await.unwrap_err().is_cancelled());
4728        assert_eq!(background_task_count(), before + 1);
4729
4730        let draining = drain_with_timeout(&active, std::time::Duration::from_secs(5));
4731        tokio::pin!(draining);
4732        assert!(
4733            tokio::time::timeout(std::time::Duration::from_millis(150), &mut draining)
4734                .await
4735                .is_err(),
4736            "drain must remain pending while cancelled-request hydration still runs"
4737        );
4738
4739        release.add_permits(1);
4740        assert!(
4741            tokio::time::timeout(std::time::Duration::from_secs(5), draining)
4742                .await
4743                .expect("drain should finish after native hydration ends"),
4744            "hydration should finish inside the drain window"
4745        );
4746        assert_eq!(background_task_count(), before);
4747    }
4748
4749    // See the `#[serial(background_tasks)]` note on
4750    // `drain_waits_for_tracked_background_tasks_before_returning` above —
4751    // this test shares the same process-wide `BACKGROUND_TASKS` static and
4752    // races it (and the panic test below) under default parallelism.
4753    #[tokio::test]
4754    #[serial(background_tasks)]
4755    async fn track_background_task_count_returns_to_zero_after_completion() {
4756        // Sanity check on the counter's own bookkeeping, independent of drain().
4757        let before = background_task_count();
4758        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
4759        track_background_task(async move {
4760            let _ = rx.await;
4761        });
4762        assert_eq!(background_task_count(), before + 1);
4763        tx.send(()).expect("still awaiting");
4764        // Yield until the spawned task's decrement has actually run.
4765        for _ in 0..100 {
4766            if background_task_count() == before {
4767                break;
4768            }
4769            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4770        }
4771        assert_eq!(background_task_count(), before);
4772    }
4773
4774    // See the `#[serial(background_tasks)]` note above — shares
4775    // `BACKGROUND_TASKS` with the other two tests in this group.
4776    #[tokio::test]
4777    #[serial(background_tasks)]
4778    async fn track_background_task_count_returns_to_baseline_after_panic() {
4779        // A panic inside the tracked future must still decrement the
4780        // counter (via BackgroundTaskGuard's Drop), not leak it forever.
4781        // `track_background_task` discards the spawned
4782        // task's `JoinHandle` (it is fire-and-forget by design — the caller
4783        // never awaits it), so this test does not await the panic directly;
4784        // tokio isolates the panic to the spawned task instead of aborting
4785        // the process, and we observe the recovery purely through the
4786        // shared counter returning to baseline after the guard's `Drop`
4787        // fires during that task's unwind.
4788        let before = background_task_count();
4789
4790        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
4791        track_background_task(async move {
4792            let _ = rx.await;
4793            panic!("intentional panic to exercise the Drop-guard decrement path");
4794        });
4795        assert_eq!(background_task_count(), before + 1);
4796
4797        tx.send(()).expect("still awaiting");
4798        for _ in 0..100 {
4799            if background_task_count() == before {
4800                break;
4801            }
4802            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4803        }
4804        assert_eq!(
4805            background_task_count(),
4806            before,
4807            "background task counter must return to baseline after the tracked future panics"
4808        );
4809    }
4810
4811    // ── active background phase names (ADR-103) ──────────────────────────
4812
4813    // `#[serial(active_phases)]`: these tests read/assert on the process-wide
4814    // `ACTIVE_PHASES` static. No other test in this crate touches it today,
4815    // but the group mirrors the `background_tasks` precedent above so a
4816    // future addition does not silently reintroduce the same interleaving
4817    // hazard that motivated it there.
4818    #[test]
4819    #[serial(active_phases)]
4820    fn register_active_phase_appears_and_disappears_with_the_guard() {
4821        assert!(
4822            !active_phase_names().contains(&"adr103_test_phase".to_string()),
4823            "must start absent (leaked from a prior failed run would poison this test)"
4824        );
4825
4826        let guard = register_active_phase("adr103_test_phase");
4827        assert!(active_phase_names().contains(&"adr103_test_phase".to_string()));
4828
4829        drop(guard);
4830        assert!(
4831            !active_phase_names().contains(&"adr103_test_phase".to_string()),
4832            "the phase name must drop out of the gauge once its guard is dropped"
4833        );
4834    }
4835
4836    #[test]
4837    #[serial(active_phases)]
4838    fn register_active_phase_counts_concurrent_occurrences_of_the_same_name() {
4839        let first = register_active_phase("adr103_concurrent_phase");
4840        let second = register_active_phase("adr103_concurrent_phase");
4841        assert!(active_phase_names().contains(&"adr103_concurrent_phase".to_string()));
4842
4843        drop(first);
4844        assert!(
4845            active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
4846            "one of two concurrent occurrences ending must not remove the name early"
4847        );
4848
4849        drop(second);
4850        assert!(
4851            !active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
4852            "the name must be removed only once every concurrent occurrence has ended"
4853        );
4854    }
4855
4856    // ── metrics-only frame (load/perf harness read-surface) ────────────────
4857
4858    /// Minimal `DaemonDispatch` for the metrics tests: `dispatch` just counts
4859    /// how many times it was called (so tests can assert the ops path was
4860    /// never reached) and `pool_for_checkpoint` returns whatever pool the
4861    /// test wired in (or `None`, matching an in-memory/poolless dispatcher).
4862    #[derive(Clone)]
4863    struct MockDispatch {
4864        namespace: String,
4865        config_id: String,
4866        dispatch_calls: Arc<std::sync::atomic::AtomicUsize>,
4867        pool: Option<Arc<ConnectionPool>>,
4868        /// When `Some(msg)`, `dispatch` returns `Err(msg)` instead of the
4869        /// default `Ok("{}")` — lets a test drive `handle_conn`'s real
4870        /// dispatch-error arm (khive#948 request_id echo coverage).
4871        dispatch_err: Option<String>,
4872    }
4873
4874    #[derive(Clone)]
4875    struct CancellationAwareDispatch {
4876        started: Arc<tokio::sync::Notify>,
4877        cancellation_observed: Arc<std::sync::atomic::AtomicBool>,
4878        count_sql: Option<Arc<dyn khive_storage::SqlAccess>>,
4879    }
4880
4881    #[async_trait]
4882    impl DaemonDispatch for CancellationAwareDispatch {
4883        fn plan(&self, ops: &str) -> String {
4884            khive_request::plan_request(ops, &Default::default()).to_string()
4885        }
4886
4887        async fn dispatch(
4888            &self,
4889            _ops: String,
4890            _presentation: Option<String>,
4891            _presentation_per_op: Option<Vec<Option<String>>>,
4892            _format: Option<String>,
4893            _format_per_op: Option<Vec<Option<String>>>,
4894            _from_wire: bool,
4895            _identity: Option<RequestIdentity>,
4896        ) -> Result<String, String> {
4897            self.started.notify_one();
4898            if let Some(sql) = &self.count_sql {
4899                let mut reader = sql.reader().await.map_err(|error| error.to_string())?;
4900                let result = reader.query_scalar(khive_storage::SqlStatement {
4901                    sql: "SELECT COUNT(*) FROM events WHERE namespace = ?1 AND verb LIKE 'knowledge.%'".into(),
4902                    params: vec![khive_storage::SqlValue::Text("local".into())],
4903                    label: Some("knowledge.stats.event_count".into()),
4904                }).await;
4905                self.cancellation_observed.store(
4906                    matches!(
4907                        result,
4908                        Err(khive_storage::error::StorageError::Timeout { .. })
4909                    ),
4910                    std::sync::atomic::Ordering::SeqCst,
4911                );
4912                return result
4913                    .map(|value| format!("{value:?}"))
4914                    .map_err(|error| error.to_string());
4915            }
4916            khive_storage::wait_for_request_read_cancellation().await;
4917            self.cancellation_observed
4918                .store(true, std::sync::atomic::Ordering::SeqCst);
4919            Ok("{}".to_string())
4920        }
4921
4922        async fn warm_all(&self) {}
4923
4924        fn namespace(&self) -> &str {
4925            "local"
4926        }
4927
4928        fn config_id(&self) -> &str {
4929            "disconnect-test"
4930        }
4931    }
4932
4933    mod demand_retirement_tests {
4934        use super::*;
4935        use khive_storage::SqlAccess;
4936
4937        pub(super) fn dispatcher(pool: Option<Arc<ConnectionPool>>) -> MockDispatch {
4938            MockDispatch {
4939                namespace: "local".to_owned(),
4940                config_id: "idle-test".to_owned(),
4941                dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4942                pool,
4943                dispatch_err: None,
4944            }
4945        }
4946
4947        pub(super) fn lifecycle(mode: DaemonLifetime) -> Arc<DaemonLifecycle> {
4948            Arc::new(DaemonLifecycle::new(
4949                DaemonOptions {
4950                    lifetime: mode,
4951                    idle_interval: std::time::Duration::from_secs(1),
4952                },
4953                DaemonStartupReport::default(),
4954            ))
4955        }
4956
4957        #[tokio::test(start_paused = true)]
4958        async fn ordinary_cleanup_resets_idle_and_admission_is_one_way() {
4959            let state = lifecycle(DaemonLifetime::Demand);
4960            tokio::time::advance(std::time::Duration::from_secs(3)).await;
4961            assert!(
4962                !state.try_idle(Vec::new),
4963                "readiness must precede the idle clock"
4964            );
4965            state.ready();
4966            let request = state.admit().unwrap();
4967            tokio::time::advance(std::time::Duration::from_secs(3)).await;
4968            assert!(
4969                !state.try_idle(Vec::new),
4970                "admitted work must prevent retirement"
4971            );
4972            drop(request);
4973            assert!(
4974                !state.try_idle(Vec::new),
4975                "cleanup starts a fresh idle interval"
4976            );
4977            tokio::time::advance(std::time::Duration::from_secs(1)).await;
4978            assert!(state.try_idle(Vec::new));
4979            assert!(
4980                state.admit().is_none(),
4981                "draining must refuse before dispatch"
4982            );
4983            assert!(!state.try_idle(Vec::new), "retirement cannot be repeated");
4984            assert_eq!(
4985                state.snapshot().shutdown_reason,
4986                Some(DaemonShutdownReason::Idle)
4987            );
4988            state.stopped();
4989            assert!(state.admit().is_none());
4990        }
4991
4992        #[test]
4993        fn concurrent_admission_and_idle_decision_choose_one_winner() {
4994            for _ in 0..16 {
4995                let state = Arc::new(DaemonLifecycle::new(
4996                    DaemonOptions {
4997                        lifetime: DaemonLifetime::Demand,
4998                        idle_interval: std::time::Duration::from_nanos(1),
4999                    },
5000                    DaemonStartupReport::default(),
5001                ));
5002                state.ready();
5003                let barrier = Arc::new(std::sync::Barrier::new(2));
5004                let admitting_state = Arc::clone(&state);
5005                let admitting_barrier = Arc::clone(&barrier);
5006                let admission = std::thread::spawn(move || {
5007                    admitting_barrier.wait();
5008                    admitting_state.admit()
5009                });
5010                let retiring_state = Arc::clone(&state);
5011                let retirement = std::thread::spawn(move || {
5012                    barrier.wait();
5013                    retiring_state.try_idle(Vec::new)
5014                });
5015                let admitted = admission.join().unwrap();
5016                let retired = retirement.join().unwrap();
5017                assert_ne!(
5018                    admitted.is_some(),
5019                    retired,
5020                    "request admission and voluntary retirement cannot both win"
5021                );
5022                if retired {
5023                    assert!(state.admit().is_none());
5024                }
5025                drop(admitted);
5026            }
5027        }
5028
5029        #[tokio::test(start_paused = true)]
5030        async fn named_service_obligations_and_unknown_resources_are_ineligible() {
5031            let state = Arc::new(DaemonLifecycle::new(
5032                DaemonOptions {
5033                    lifetime: DaemonLifetime::Demand,
5034                    idle_interval: std::time::Duration::from_secs(1),
5035                },
5036                DaemonStartupReport {
5037                    skipped_components: vec!["schedule-tick".to_owned()],
5038                    idle_ineligible_reasons: vec![
5039                        "unclassified_component:external-service".to_owned()
5040                    ],
5041                },
5042            ));
5043            state.ready();
5044            tokio::time::advance(std::time::Duration::from_secs(3)).await;
5045            assert!(!state.try_idle(Vec::new));
5046            assert_eq!(
5047                state.snapshot().idle_ineligible_reasons,
5048                vec!["unclassified_component:external-service"]
5049            );
5050            let unknown = CancellationAwareDispatch {
5051                started: Arc::new(tokio::sync::Notify::new()),
5052                cancellation_observed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
5053                count_sql: None,
5054            };
5055            let clean = lifecycle(DaemonLifetime::Demand);
5056            clean.ready();
5057            tokio::time::advance(std::time::Duration::from_secs(3)).await;
5058            assert!(!clean.try_idle(|| unknown.idle_retirement_blockers()));
5059            assert_eq!(
5060                clean.snapshot().idle_blockers,
5061                vec!["dispatcher_resource_inventory_unknown"]
5062            );
5063        }
5064
5065        #[tokio::test(start_paused = true)]
5066        #[serial(background_tasks, tx_registry)]
5067        async fn retained_raw_sql_writer_blocks_actual_idle_wait_and_persistent_stays() {
5068            let dir = tempfile::tempdir().unwrap();
5069            let pool = Arc::new(
5070                ConnectionPool::new(khive_db::PoolConfig {
5071                    path: Some(dir.path().join("retained.db")),
5072                    write_queue_enabled: Some(false),
5073                    write_routing_strict: false,
5074                    ..Default::default()
5075                })
5076                .unwrap(),
5077            );
5078            let bridge = khive_db::SqlBridge::new(Arc::clone(&pool), true);
5079            let writer = bridge.writer().await.unwrap();
5080            assert!(
5081                khive_storage::tx_registry::snapshot().is_empty(),
5082                "this hold must be autocommit, not an open transaction"
5083            );
5084            let d = dispatcher(Some(Arc::clone(&pool)));
5085            let demand = lifecycle(DaemonLifetime::Demand);
5086            let persistent = lifecycle(DaemonLifetime::Persistent);
5087            demand.ready();
5088            persistent.ready();
5089            tokio::time::advance(std::time::Duration::from_secs(3)).await;
5090            let idle = wait_for_idle(&d, &demand);
5091            tokio::pin!(idle);
5092            assert!(
5093                tokio::time::timeout(std::time::Duration::from_millis(150), &mut idle)
5094                    .await
5095                    .is_err(),
5096                "a genuine retained writer handle must prevent the actual idle arm"
5097            );
5098            assert!(tokio::time::timeout(
5099                std::time::Duration::from_millis(150),
5100                wait_for_idle(&d, &persistent)
5101            )
5102            .await
5103            .is_err());
5104            drop(writer);
5105            assert_eq!(pool.retirement_writer_holds(), 0);
5106            tokio::time::timeout(std::time::Duration::from_secs(2), idle)
5107                .await
5108                .unwrap();
5109            assert_eq!(demand.snapshot().phase, DaemonLifecyclePhase::Draining);
5110            assert_eq!(persistent.snapshot().phase, DaemonLifecyclePhase::Serving);
5111            let pooled = lifecycle(DaemonLifetime::Demand);
5112            pooled.ready();
5113            tokio::time::advance(std::time::Duration::from_secs(2)).await;
5114            let pooled_guard = pool.writer().unwrap();
5115            assert!(
5116                !pooled.try_idle(|| idle_retirement_blockers(&d)),
5117                "pooled writer hold must block retirement"
5118            );
5119            drop(pooled_guard);
5120            assert!(pooled.try_idle(|| idle_retirement_blockers(&d)));
5121        }
5122
5123        #[tokio::test(start_paused = true)]
5124        #[serial(background_tasks, tx_registry)]
5125        async fn persistent_idle_wait_never_retires_after_writer_release() {
5126            let dir = tempfile::tempdir().unwrap();
5127            let pool = Arc::new(
5128                ConnectionPool::new(khive_db::PoolConfig {
5129                    path: Some(dir.path().join("persistent.db")),
5130                    write_queue_enabled: Some(false),
5131                    write_routing_strict: false,
5132                    ..Default::default()
5133                })
5134                .unwrap(),
5135            );
5136            let bridge = khive_db::SqlBridge::new(Arc::clone(&pool), true);
5137            let held = bridge.writer().await.unwrap();
5138            let d = dispatcher(Some(pool));
5139            let state = lifecycle(DaemonLifetime::Persistent);
5140            state.ready();
5141            tokio::time::advance(std::time::Duration::from_secs(3)).await;
5142            assert!(tokio::time::timeout(
5143                std::time::Duration::from_millis(100),
5144                wait_for_idle(&d, &state)
5145            )
5146            .await
5147            .is_err());
5148            drop(held);
5149            assert!(tokio::time::timeout(
5150                std::time::Duration::from_secs(2),
5151                wait_for_idle(&d, &state)
5152            )
5153            .await
5154            .is_err());
5155            assert_eq!(state.snapshot().phase, DaemonLifecyclePhase::Serving);
5156        }
5157
5158        #[tokio::test(start_paused = true)]
5159        #[serial(background_tasks, tx_registry)]
5160        async fn explicit_sql_reader_transaction_blocks_retirement_without_writer_hold() {
5161            let dir = tempfile::tempdir().unwrap();
5162            let pool = Arc::new(
5163                ConnectionPool::new(khive_db::PoolConfig {
5164                    path: Some(dir.path().join("reader.db")),
5165                    write_queue_enabled: Some(false),
5166                    write_routing_strict: false,
5167                    ..Default::default()
5168                })
5169                .unwrap(),
5170            );
5171            let bridge = khive_db::SqlBridge::new(Arc::clone(&pool), true);
5172            let mut reader = bridge.reader().await.unwrap();
5173            reader
5174                .query_all(khive_storage::SqlStatement {
5175                    sql: "BEGIN DEFERRED".to_owned(),
5176                    params: vec![],
5177                    label: Some("idle-reader".to_owned()),
5178                })
5179                .await
5180                .unwrap();
5181            assert_eq!(pool.retirement_writer_holds(), 0);
5182            let d = dispatcher(Some(pool));
5183            let state = lifecycle(DaemonLifetime::Demand);
5184            state.ready();
5185            tokio::time::advance(std::time::Duration::from_secs(3)).await;
5186            assert!(!state.try_idle(|| idle_retirement_blockers(&d)));
5187            assert!(state
5188                .snapshot()
5189                .idle_blockers
5190                .contains(&"open_sql_transaction".to_owned()));
5191            drop(reader);
5192            assert!(state.try_idle(|| idle_retirement_blockers(&d)));
5193        }
5194
5195        #[tokio::test(start_paused = true)]
5196        #[serial(background_tasks, tx_registry)]
5197        async fn unsettled_named_worker_blocks_idle_without_resetting_clock() {
5198            let (release, pending) = tokio::sync::oneshot::channel::<()>();
5199            let task = spawn_named_tracked_task("idle-test-worker", async move {
5200                pending.await.unwrap();
5201            });
5202            let state = lifecycle(DaemonLifetime::Demand);
5203            state.ready();
5204            let d = dispatcher(None);
5205            tokio::time::advance(std::time::Duration::from_secs(2)).await;
5206            assert!(!state.try_idle(|| idle_retirement_blockers(&d)));
5207            assert!(state
5208                .snapshot()
5209                .idle_blockers
5210                .contains(&"unsettled_worker:idle-test-worker".to_owned()));
5211            release.send(()).unwrap();
5212            task.await.unwrap();
5213            assert!(
5214                state.try_idle(|| idle_retirement_blockers(&d)),
5215                "maintenance completion must not reset ordinary activity"
5216            );
5217        }
5218
5219        #[tokio::test(start_paused = true)]
5220        #[serial(background_tasks)]
5221        async fn voluntary_drain_retains_pending_work_past_deadline() {
5222            let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5223            let (release, pending) = tokio::sync::oneshot::channel::<()>();
5224            let task = spawn_connection_task(Arc::clone(&active), async move {
5225                pending.await.unwrap();
5226            });
5227            let drain = drain_for_idle(&active, std::time::Duration::from_millis(10));
5228            tokio::pin!(drain);
5229            assert!(
5230                tokio::time::timeout(std::time::Duration::from_secs(1), &mut drain)
5231                    .await
5232                    .is_err(),
5233                "voluntary timeout must retain admitted work"
5234            );
5235            assert!(!task.is_finished());
5236            release.send(()).unwrap();
5237            task.await.unwrap();
5238            tokio::time::timeout(std::time::Duration::from_secs(1), drain)
5239                .await
5240                .unwrap();
5241        }
5242
5243        #[tokio::test(start_paused = true)]
5244        async fn stalled_response_transport_is_bounded() {
5245            let (mut writer, _held_reader) = tokio::io::duplex(1);
5246            let error = tokio::time::timeout(
5247                std::time::Duration::from_secs(35),
5248                write_response_frame(&mut writer, b"bounded response"),
5249            )
5250            .await
5251            .expect("the production response bound must fire before the fixture ceiling")
5252            .unwrap_err();
5253            assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
5254        }
5255
5256        #[tokio::test(start_paused = true)]
5257        async fn draining_handler_refuses_before_dispatch() {
5258            let d = dispatcher(None);
5259            let calls = Arc::clone(&d.dispatch_calls);
5260            let state = lifecycle(DaemonLifetime::Demand);
5261            state.ready();
5262            tokio::time::advance(std::time::Duration::from_secs(2)).await;
5263            assert!(state.try_idle(Vec::new));
5264            // The lifecycle transition is already fixed. Real Unix socket
5265            // readiness must not race the paused clock's automatic timeout jump.
5266            tokio::time::resume();
5267            let (mut client, server) = UnixStream::pair().unwrap();
5268            let handle = tokio::spawn(handle_conn_with_lifecycle(
5269                server,
5270                d,
5271                None,
5272                tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
5273                Some(state),
5274            ));
5275            let mut frame = base_request_frame("idle-test");
5276            frame.ops = "stats()".to_owned();
5277            write_frame(&mut client, &serde_json::to_vec(&frame).unwrap())
5278                .await
5279                .unwrap();
5280            let refusal: DaemonResponseFrame =
5281                serde_json::from_slice(&read_frame(&mut client).await.unwrap()).unwrap();
5282            assert!(!refusal.ok);
5283            assert_eq!(refusal.error_detail.unwrap()["code"], "daemon_draining");
5284            assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
5285            handle.await.unwrap();
5286        }
5287
5288        #[test]
5289        fn lifecycle_metrics_are_additive_and_generation_is_stable() {
5290            let state = lifecycle(DaemonLifetime::Demand);
5291            let generation = state.snapshot().instance_generation;
5292            let metrics = MetricsSnapshot {
5293                lifecycle: Some(state.snapshot()),
5294                ..Default::default()
5295            };
5296            let decoded: MetricsSnapshot =
5297                serde_json::from_value(serde_json::to_value(metrics).unwrap()).unwrap();
5298            assert_eq!(decoded.lifecycle.unwrap().instance_generation, generation);
5299            let old = serde_json::to_value(MetricsSnapshot::default()).unwrap();
5300            assert!(old.get("lifecycle").is_none());
5301            assert!(serde_json::from_value::<MetricsSnapshot>(old)
5302                .unwrap()
5303                .lifecycle
5304                .is_none());
5305        }
5306    }
5307
5308    #[async_trait]
5309    impl DaemonDispatch for MockDispatch {
5310        fn idle_retirement_blockers(&self) -> Vec<String> {
5311            self.pool
5312                .as_ref()
5313                .filter(|pool| pool.retirement_writer_holds() != 0)
5314                .map(|_| vec!["test_backend:held_writer".to_owned()])
5315                .unwrap_or_default()
5316        }
5317
5318        fn plan(&self, ops: &str) -> String {
5319            khive_request::plan_request(ops, &Default::default()).to_string()
5320        }
5321
5322        async fn dispatch(
5323            &self,
5324            _ops: String,
5325            _presentation: Option<String>,
5326            _presentation_per_op: Option<Vec<Option<String>>>,
5327            _format: Option<String>,
5328            _format_per_op: Option<Vec<Option<String>>>,
5329            _from_wire: bool,
5330            _identity: Option<RequestIdentity>,
5331        ) -> Result<String, String> {
5332            self.dispatch_calls
5333                .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5334            match &self.dispatch_err {
5335                Some(msg) => Err(msg.clone()),
5336                None => Ok("{}".to_string()),
5337            }
5338        }
5339
5340        async fn warm_all(&self) {}
5341
5342        fn namespace(&self) -> &str {
5343            &self.namespace
5344        }
5345
5346        fn config_id(&self) -> &str {
5347            &self.config_id
5348        }
5349
5350        fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
5351            self.pool.clone()
5352        }
5353    }
5354
5355    fn base_request_frame(config_id: &str) -> DaemonRequestFrame {
5356        DaemonRequestFrame {
5357            plan: false,
5358            ops: String::new(),
5359            presentation: None,
5360            presentation_per_op: None,
5361            namespace: "local".to_string(),
5362            actor_id: None,
5363            process_ref: None,
5364            visible_namespaces: Vec::new(),
5365            config_id: config_id.to_string(),
5366            protocol_version: PROTOCOL_VERSION,
5367            probe_only: false,
5368            metrics_only: false,
5369            format: None,
5370            format_per_op: None,
5371            from_wire: false,
5372            request_id: None,
5373        }
5374    }
5375
5376    /// Drive `handle_conn` over an in-process `UnixStream::pair()` (no real
5377    /// socket file needed) and decode the response frame it writes back.
5378    async fn round_trip<D: DaemonDispatch>(
5379        dispatcher: D,
5380        req: &DaemonRequestFrame,
5381    ) -> DaemonResponseFrame {
5382        let (mut client, server) = UnixStream::pair().expect("unix stream pair");
5383        let payload = serde_json::to_vec(req).expect("encode request frame");
5384        let handle = tokio::spawn(async move {
5385            handle_conn(server, dispatcher).await;
5386        });
5387        write_frame(&mut client, &payload)
5388            .await
5389            .expect("write request frame");
5390        let raw = read_frame(&mut client).await.expect("read response frame");
5391        handle.await.expect("handle_conn task panicked");
5392        serde_json::from_slice(&raw).expect("decode response frame")
5393    }
5394
5395    #[tokio::test]
5396    async fn expired_accepted_deadline_refuses_even_buffered_complete_frame() {
5397        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5398        let dispatcher = MockDispatch {
5399            namespace: "local".into(),
5400            config_id: "expired-accept-test".into(),
5401            dispatch_calls: Arc::clone(&calls),
5402            pool: None,
5403            dispatch_err: None,
5404        };
5405        let (mut client, server) = UnixStream::pair().expect("unix stream pair");
5406        let frame = base_request_frame("expired-accept-test");
5407        write_frame(&mut client, &serde_json::to_vec(&frame).unwrap())
5408            .await
5409            .expect("buffer complete frame before handler starts");
5410        // Model a task first polled after its acceptance-time deadline. Tokio
5411        // polls a ready frame before its timer, so timeout_at alone would
5412        // wrongly dispatch this already-buffered request.
5413        let accepted_deadline = tokio::time::Instant::now() - std::time::Duration::from_secs(1);
5414        tokio::time::timeout(
5415            std::time::Duration::from_secs(1),
5416            handle_conn_with_shutdown(server, dispatcher, None, accepted_deadline),
5417        )
5418        .await
5419        .expect("expired accepted deadline must not start a fresh read window");
5420        let mut byte = [0u8; 1];
5421        match client.read(&mut byte).await {
5422            Ok(0) => {}
5423            Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => {}
5424            other => panic!("expected closed socket, got {other:?}"),
5425        }
5426        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
5427    }
5428
5429    /// Supplies the entire frame without registering readiness or yielding.
5430    /// `timeout_at` must not be allowed to accept this ready first poll after
5431    /// the connection's acceptance-time deadline has already passed.
5432    struct ReadyFrameReader {
5433        frame: Vec<u8>,
5434        offset: usize,
5435        polls: usize,
5436    }
5437
5438    impl tokio::io::AsyncRead for ReadyFrameReader {
5439        fn poll_read(
5440            self: std::pin::Pin<&mut Self>,
5441            _cx: &mut std::task::Context<'_>,
5442            buf: &mut tokio::io::ReadBuf<'_>,
5443        ) -> std::task::Poll<std::io::Result<()>> {
5444            let reader = self.get_mut();
5445            reader.polls += 1;
5446            let remaining = &reader.frame[reader.offset..];
5447            let count = remaining.len().min(buf.remaining());
5448            buf.put_slice(&remaining[..count]);
5449            reader.offset += count;
5450            std::task::Poll::Ready(Ok(()))
5451        }
5452    }
5453
5454    #[tokio::test]
5455    async fn expired_accepted_deadline_refuses_a_frame_ready_on_first_poll() {
5456        let mut reader = ReadyFrameReader {
5457            frame: [2_u32.to_be_bytes().as_slice(), b"{}"].concat(),
5458            offset: 0,
5459            polls: 0,
5460        };
5461        let accepted_deadline = tokio::time::Instant::now() - std::time::Duration::from_secs(1);
5462        let error = read_initial_frame(&mut reader, accepted_deadline)
5463            .await
5464            .expect_err("a fully ready frame must not outlive its acceptance deadline");
5465        assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
5466        assert_eq!(reader.polls, 0, "an expired frame must not be polled");
5467    }
5468
5469    /// #2230 review (Medium): duplicate-daemon detection must not treat any
5470    /// accepting Unix listener as khived. A real khived (`handle_conn` behind
5471    /// a bound socket) must still be recognized by the protocol probe.
5472    #[tokio::test]
5473    async fn socket_speaks_khived_protocol_accepts_a_real_khived() {
5474        let dir = tempfile::tempdir().expect("tempdir");
5475        let sock_path = dir.path().join("real.sock");
5476        let listener = UnixListener::bind(&sock_path).expect("bind real listener");
5477        let dispatcher = MockDispatch {
5478            namespace: "local".to_string(),
5479            config_id: "probe-test".to_string(),
5480            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
5481            pool: None,
5482            dispatch_err: None,
5483        };
5484        let accept_task = tokio::spawn(async move {
5485            if let Ok((stream, _)) = listener.accept().await {
5486                handle_conn(stream, dispatcher).await;
5487            }
5488        });
5489
5490        assert!(
5491            socket_speaks_khived_protocol(&sock_path, "probe-test").await,
5492            "a real khived answering the probe_only frame with a matching config_id must be recognized"
5493        );
5494
5495        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), accept_task).await;
5496    }
5497
5498    include!("daemon/probe_listener_tests.rs");
5499
5500    /// Regression (#2230): a well-formed [`DaemonResponseFrame`]
5501    /// that is not the unambiguous probe-ack sentinel — e.g. one reporting a
5502    /// `config_mismatch` for a *different* config_id, exactly what a live
5503    /// khived serving another store would send back — must not be treated as
5504    /// the same live, identity-matching duplicate. Before this fix, any
5505    /// frame that merely deserialized was accepted, so this response would
5506    /// have been misclassified as "alive" and refused a legitimate boot.
5507    #[tokio::test]
5508    async fn socket_speaks_khived_protocol_rejects_a_non_ack_or_mismatched_response() {
5509        let dir = tempfile::tempdir().expect("tempdir");
5510        let sock_path = dir.path().join("mismatched.sock");
5511        let listener = UnixListener::bind(&sock_path).expect("bind fake listener");
5512        let accept_task = tokio::spawn(async move {
5513            if let Ok((mut stream, _)) = listener.accept().await {
5514                let _raw = read_frame(&mut stream).await.expect("read probe frame");
5515                let resp = DaemonResponseFrame {
5516                    ok: false,
5517                    result: None,
5518                    error: None,
5519                    namespace_mismatch: false,
5520                    config_mismatch: true,
5521                    served_config_id: Some("someone-elses-config".to_string()),
5522                    version_mismatch: false,
5523                    daemon_protocol_version: PROTOCOL_VERSION,
5524                    error_detail: None,
5525                    metrics: None,
5526                    request_id: None,
5527                };
5528                let payload = serde_json::to_vec(&resp).expect("encode response");
5529                write_frame(&mut stream, &payload)
5530                    .await
5531                    .expect("write response");
5532            }
5533        });
5534
5535        let speaks = socket_speaks_khived_protocol(&sock_path, "expected-config").await;
5536        assert!(
5537            !speaks,
5538            "a well-formed but non-ack / identity-mismatched response must not be treated as \
5539             the same live khived"
5540        );
5541
5542        let _ = tokio::time::timeout(std::time::Duration::from_secs(2), accept_task).await;
5543    }
5544
5545    /// Regression (#2230): the daemon's `metrics_only` arm answers with
5546    /// `ok=true, result=None, error=None`, every mismatch flag false, the
5547    /// current protocol version, and a matching `served_config_id` — the
5548    /// exact same shape the probe-ack arm produces, differing only in
5549    /// carrying `metrics: Some(...)`. A well-formed metrics snapshot
5550    /// response must not be misread as a probe acknowledgement; otherwise a
5551    /// client whose only interaction with the socket happened to be a
5552    /// metrics poll would be classified as the same live, identity-matching
5553    /// khived. This response carries `request_id: None`, so it isolates the
5554    /// `metrics.is_none()` conjunct — see the sibling test below for the
5555    /// `request_id.is_none()` conjunct.
5556    #[tokio::test]
5557    async fn socket_speaks_khived_protocol_rejects_a_metrics_only_response() {
5558        let dir = tempfile::tempdir().expect("tempdir");
5559        let sock_path = dir.path().join("metrics-only.sock");
5560        let listener = UnixListener::bind(&sock_path).expect("bind fake listener");
5561        let accept_task = tokio::spawn(async move {
5562            if let Ok((mut stream, _)) = listener.accept().await {
5563                let _raw = read_frame(&mut stream).await.expect("read probe frame");
5564                let resp = DaemonResponseFrame {
5565                    ok: true,
5566                    result: None,
5567                    error: None,
5568                    namespace_mismatch: false,
5569                    config_mismatch: false,
5570                    served_config_id: Some("expected-config".to_string()),
5571                    version_mismatch: false,
5572                    daemon_protocol_version: PROTOCOL_VERSION,
5573                    error_detail: None,
5574                    metrics: Some(MetricsSnapshot::default()),
5575                    request_id: None,
5576                };
5577                let payload = serde_json::to_vec(&resp).expect("encode response");
5578                write_frame(&mut stream, &payload)
5579                    .await
5580                    .expect("write response");
5581            }
5582        });
5583
5584        let speaks = socket_speaks_khived_protocol(&sock_path, "expected-config").await;
5585        assert!(
5586            !speaks,
5587            "an otherwise-matching response carrying a metrics snapshot must not be treated as \
5588             a probe acknowledgement"
5589        );
5590
5591        tokio::time::timeout(std::time::Duration::from_secs(2), accept_task)
5592            .await
5593            .expect("fake listener accept task timed out")
5594            .expect("fake listener accept task panicked");
5595    }
5596
5597    /// Regression (#2230): sibling of the metrics-only test above, isolating
5598    /// the `request_id.is_none()` conjunct. A response with `metrics: None`
5599    /// but an echoed `request_id: Some(_)` is otherwise identical to a probe
5600    /// acknowledgement and must not be misread as one — a probe frame never
5601    /// sets `request_id`, so an echo of one is proof the peer answered a
5602    /// different, non-probe request.
5603    #[tokio::test]
5604    async fn socket_speaks_khived_protocol_rejects_a_response_with_request_id() {
5605        let dir = tempfile::tempdir().expect("tempdir");
5606        let sock_path = dir.path().join("request-id.sock");
5607        let listener = UnixListener::bind(&sock_path).expect("bind fake listener");
5608        let accept_task = tokio::spawn(async move {
5609            if let Ok((mut stream, _)) = listener.accept().await {
5610                let _raw = read_frame(&mut stream).await.expect("read probe frame");
5611                let resp = DaemonResponseFrame {
5612                    ok: true,
5613                    result: None,
5614                    error: None,
5615                    namespace_mismatch: false,
5616                    config_mismatch: false,
5617                    served_config_id: Some("expected-config".to_string()),
5618                    version_mismatch: false,
5619                    daemon_protocol_version: PROTOCOL_VERSION,
5620                    error_detail: None,
5621                    metrics: None,
5622                    request_id: Some(42),
5623                };
5624                let payload = serde_json::to_vec(&resp).expect("encode response");
5625                write_frame(&mut stream, &payload)
5626                    .await
5627                    .expect("write response");
5628            }
5629        });
5630
5631        let speaks = socket_speaks_khived_protocol(&sock_path, "expected-config").await;
5632        assert!(
5633            !speaks,
5634            "an otherwise-matching response carrying an echoed request_id must not be treated \
5635             as a probe acknowledgement"
5636        );
5637
5638        tokio::time::timeout(std::time::Duration::from_secs(2), accept_task)
5639            .await
5640            .expect("fake listener accept task timed out")
5641            .expect("fake listener accept task panicked");
5642    }
5643
5644    #[derive(Clone)]
5645    struct DetailedDispatch {
5646        calls: Arc<std::sync::atomic::AtomicUsize>,
5647        detail: serde_json::Value,
5648    }
5649
5650    #[async_trait]
5651    impl DaemonDispatch for DetailedDispatch {
5652        fn plan(&self, ops: &str) -> String {
5653            khive_request::plan_request(ops, &Default::default()).to_string()
5654        }
5655
5656        async fn dispatch(
5657            &self,
5658            _ops: String,
5659            _presentation: Option<String>,
5660            _presentation_per_op: Option<Vec<Option<String>>>,
5661            _format: Option<String>,
5662            _format_per_op: Option<Vec<Option<String>>>,
5663            _from_wire: bool,
5664            _identity: Option<RequestIdentity>,
5665        ) -> Result<String, String> {
5666            panic!("the daemon must use the detailed dispatch seam");
5667        }
5668
5669        async fn dispatch_with_error_detail(
5670            &self,
5671            _ops: String,
5672            _presentation: Option<String>,
5673            _presentation_per_op: Option<Vec<Option<String>>>,
5674            _format: Option<String>,
5675            _format_per_op: Option<Vec<Option<String>>>,
5676            _from_wire: bool,
5677            _identity: Option<RequestIdentity>,
5678        ) -> Result<String, DaemonDispatchError> {
5679            self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5680            Err(DaemonDispatchError::new(
5681                "audit failed",
5682                Some(self.detail.clone()),
5683            ))
5684        }
5685
5686        async fn warm_all(&self) {}
5687
5688        fn namespace(&self) -> &str {
5689            "local"
5690        }
5691
5692        fn config_id(&self) -> &str {
5693            "disposition-test"
5694        }
5695    }
5696
5697    #[tokio::test]
5698    async fn disposition_detail_survives_daemon_framing_and_legacy_v4_decoder() {
5699        #[allow(dead_code)]
5700        #[derive(serde::Deserialize)]
5701        struct LegacyV4Response {
5702            ok: bool,
5703            result: Option<String>,
5704            error: Option<String>,
5705            namespace_mismatch: bool,
5706            #[serde(default)]
5707            config_mismatch: bool,
5708            #[serde(default)]
5709            served_config_id: Option<String>,
5710            #[serde(default)]
5711            version_mismatch: bool,
5712            #[serde(default)]
5713            daemon_protocol_version: u32,
5714            #[serde(default)]
5715            metrics: Option<MetricsSnapshot>,
5716            #[serde(default)]
5717            request_id: Option<u64>,
5718        }
5719
5720        let detail = serde_json::json!({
5721            "kind": "obligation",
5722            "code": "store_failure",
5723            "message": "audit failed",
5724            "domain_disposition": "committed",
5725            "domain_result": { "id": "persisted-row" },
5726        });
5727        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5728        let response = round_trip(
5729            DetailedDispatch {
5730                calls: Arc::clone(&calls),
5731                detail: detail.clone(),
5732            },
5733            &base_request_frame("disposition-test"),
5734        )
5735        .await;
5736        assert_eq!(response.error_detail.as_ref(), Some(&detail));
5737        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5738        let encoded = serde_json::to_vec(&response).expect("serialize detailed response");
5739        let legacy: LegacyV4Response = serde_json::from_slice(&encoded).expect("legacy v4 decode");
5740        assert!(!legacy.ok);
5741        assert_eq!(legacy.error.as_deref(), Some("audit failed"));
5742        assert_eq!(legacy.daemon_protocol_version, PROTOCOL_VERSION);
5743    }
5744
5745    #[tokio::test]
5746    async fn disposition_legacy_dispatch_error_is_unknown_and_success_has_no_detail() {
5747        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5748        let dispatcher = MockDispatch {
5749            namespace: "local".to_string(),
5750            config_id: "disposition-test".to_string(),
5751            dispatch_calls: Arc::clone(&calls),
5752            pool: None,
5753            dispatch_err: Some("legacy failure".to_string()),
5754        };
5755        let request = base_request_frame("disposition-test");
5756        let failure = round_trip(dispatcher.clone(), &request).await;
5757        assert_eq!(
5758            failure.error_detail.as_ref().unwrap()["domain_disposition"],
5759            "unknown"
5760        );
5761        assert_eq!(failure.error.as_deref(), Some("legacy failure"));
5762        let success = round_trip(
5763            MockDispatch {
5764                dispatch_err: None,
5765                ..dispatcher
5766            },
5767            &request,
5768        )
5769        .await;
5770        assert!(success.ok);
5771        assert!(serde_json::to_value(success)
5772            .unwrap()
5773            .get("error_detail")
5774            .is_none());
5775        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
5776    }
5777
5778    #[test]
5779    fn disposition_new_decoder_accepts_legacy_v4_error_without_detail() {
5780        let response: DaemonResponseFrame = serde_json::from_str(
5781            r#"{
5782            "ok":false,"result":null,"error":"legacy failure",
5783            "namespace_mismatch":false,"config_mismatch":false,
5784            "served_config_id":"cfg","version_mismatch":false,
5785            "daemon_protocol_version":4,"request_id":null
5786        }"#,
5787        )
5788        .expect("decode legacy v4 error frame");
5789        assert!(response.error_detail.is_none());
5790        assert_eq!(response.error.as_deref(), Some("legacy failure"));
5791    }
5792
5793    #[test]
5794    fn disposition_normalization_omits_unconfirmed_domain_results() {
5795        for disposition in ["not_committed", "unknown", "unrecognized"] {
5796            let error = DaemonDispatchError::new(
5797                "failure",
5798                Some(serde_json::json!({
5799                    "message": "failure",
5800                    "domain_disposition": disposition,
5801                    "domain_result": { "id": "unconfirmed" },
5802                })),
5803            );
5804            assert!(error.error_detail.get("domain_result").is_none());
5805            assert_eq!(
5806                error.error_detail["domain_disposition"],
5807                if disposition == "not_committed" {
5808                    "not_committed"
5809                } else {
5810                    "unknown"
5811                }
5812            );
5813        }
5814    }
5815
5816    #[test]
5817    fn disposition_normalization_iteratively_discards_deep_owned_values() {
5818        for disposition in ["committed", "not_committed", "unknown"] {
5819            let mut value = serde_json::Value::Null;
5820            for _ in 0..4096 {
5821                value = serde_json::Value::Array(vec![value]);
5822            }
5823            let fields = serde_json::Map::from_iter([
5824                ("domain_disposition".into(), serde_json::json!(disposition)),
5825                ("domain_result".into(), value),
5826            ]);
5827            let error =
5828                DaemonDispatchError::new("failure", Some(serde_json::Value::Object(fields)));
5829            assert!(error.error_detail.get("domain_result").is_none());
5830            assert_eq!(error.error_detail["domain_disposition"], disposition);
5831            if disposition == "committed" {
5832                assert_eq!(error.error_detail["code"], "result_too_deep");
5833            }
5834            serde_json::to_vec(&error.error_detail).expect("bounded error detail serializes");
5835        }
5836        let mut value = serde_json::Value::Null;
5837        for _ in 0..4096 {
5838            value = serde_json::Value::Array(vec![value]);
5839        }
5840        let error = DaemonDispatchError::new("failure", Some(value));
5841        assert_eq!(error.error_detail["code"], "error_detail_too_deep");
5842        assert_eq!(error.error_detail["domain_disposition"], "unknown");
5843        assert!(error.error_detail.get("data").is_none());
5844    }
5845
5846    #[tokio::test]
5847    async fn daemon_peer_disconnect_signals_request_read_cancellation() {
5848        let started = Arc::new(tokio::sync::Notify::new());
5849        let cancellation_observed = Arc::new(std::sync::atomic::AtomicBool::new(false));
5850        let dispatcher = CancellationAwareDispatch {
5851            started: Arc::clone(&started),
5852            cancellation_observed: Arc::clone(&cancellation_observed),
5853            count_sql: None,
5854        };
5855        let (mut client, server) = UnixStream::pair().expect("unix stream pair");
5856        let request = base_request_frame("disconnect-test");
5857        let payload = serde_json::to_vec(&request).expect("encode request frame");
5858        let handler = tokio::spawn(async move { handle_conn(server, dispatcher).await });
5859        write_frame(&mut client, &payload)
5860            .await
5861            .expect("write request frame");
5862        started.notified().await;
5863
5864        drop(client);
5865        tokio::time::timeout(std::time::Duration::from_millis(500), handler)
5866            .await
5867            .expect("daemon handler ignored peer disconnect")
5868            .expect("daemon handler panicked");
5869        assert!(
5870            cancellation_observed.load(std::sync::atomic::Ordering::SeqCst),
5871            "peer loss did not reach the request-scoped read cancellation signal"
5872        );
5873    }
5874
5875    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5876    async fn daemon_disconnect_interrupts_pooled_stats_count() {
5877        use khive_storage::{SqlAccess, SqlStatement, SqlValue};
5878        let dir = tempfile::tempdir().unwrap();
5879        let pool = Arc::new(
5880            ConnectionPool::new(khive_db::PoolConfig {
5881                path: Some(dir.path().join("disconnect-count.db")),
5882                max_readers: 1,
5883                ..Default::default()
5884            })
5885            .unwrap(),
5886        );
5887        pool.writer()
5888            .unwrap()
5889            .conn()
5890            .execute_batch(
5891                "CREATE TABLE count_fixture(n INTEGER PRIMARY KEY); \
5892             WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1000) \
5893             INSERT INTO count_fixture SELECT x FROM n; \
5894             CREATE VIEW events AS SELECT 'local' AS namespace, 'knowledge.learn' AS verb \
5895             FROM count_fixture a CROSS JOIN count_fixture b CROSS JOIN count_fixture c;",
5896            )
5897            .unwrap();
5898        let sql = Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
5899        let cancellation_observed = Arc::new(std::sync::atomic::AtomicBool::new(false));
5900        let dispatcher = CancellationAwareDispatch {
5901            started: Arc::new(tokio::sync::Notify::new()),
5902            cancellation_observed: Arc::clone(&cancellation_observed),
5903            count_sql: Some(sql.clone()),
5904        };
5905        let (mut client, server) = UnixStream::pair().unwrap();
5906        let request = base_request_frame("disconnect-test");
5907        let progress = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5908        let handler = tokio::spawn(khive_db::scope_test_read_progress(
5909            Arc::clone(&progress),
5910            async move { handle_conn(server, dispatcher).await },
5911        ));
5912        write_frame(&mut client, &serde_json::to_vec(&request).unwrap())
5913            .await
5914            .unwrap();
5915        tokio::time::timeout(std::time::Duration::from_secs(2), async {
5916            while progress.load(std::sync::atomic::Ordering::SeqCst) == 0 {
5917                assert!(
5918                    !handler.is_finished(),
5919                    "COUNT returned before its first SQLite progress callback"
5920                );
5921                tokio::task::yield_now().await;
5922            }
5923        })
5924        .await
5925        .unwrap();
5926        assert!(
5927            !handler.is_finished(),
5928            "COUNT must be outstanding at disconnect"
5929        );
5930        let started = std::time::Instant::now();
5931        let grace = khive_db::sqlite_interrupt_grace_from_env();
5932        drop(client);
5933        tokio::time::timeout(grace, handler)
5934            .await
5935            .expect("disconnected COUNT did not settle within interrupt grace")
5936            .unwrap();
5937        assert!(cancellation_observed.load(std::sync::atomic::Ordering::SeqCst));
5938        let snapshot = pool.reader_acquisition_snapshot();
5939        assert_eq!(snapshot.active_pooled_checkouts, 0);
5940        assert_eq!(snapshot.available_reader_admission_slots, 1);
5941        eprintln!(
5942            "daemon_stats_count_disconnect_ms={} grace_ms={}",
5943            started.elapsed().as_secs_f64() * 1000.0,
5944            grace.as_millis()
5945        );
5946        let count = sql
5947            .reader()
5948            .await
5949            .unwrap()
5950            .query_scalar(SqlStatement {
5951                sql: "SELECT COUNT(*) FROM count_fixture".into(),
5952                params: vec![],
5953                label: None,
5954            })
5955            .await
5956            .unwrap();
5957        assert!(matches!(count, Some(SqlValue::Integer(1000))));
5958    }
5959
5960    /// Protocol v4 makes `process_ref` part of dispatch semantics. A still-warm
5961    /// v3 daemon/client pairing must fail before the verb runs; otherwise the
5962    /// older peer can ignore the unknown field, persist a message without the
5963    /// requested provenance, and leave the caller unable to retry safely.
5964    #[tokio::test]
5965    async fn protocol_v3_frame_is_rejected_before_process_ref_dispatch() {
5966        const {
5967            assert!(
5968                PROTOCOL_VERSION >= 4,
5969                "process_ref requires protocol v4 or later"
5970            )
5971        };
5972        let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5973        let dispatcher = MockDispatch {
5974            namespace: "local".to_string(),
5975            config_id: "cfg-v4".to_string(),
5976            dispatch_calls: Arc::clone(&dispatch_calls),
5977            pool: None,
5978            dispatch_err: None,
5979        };
5980        let mut request = base_request_frame("cfg-v4");
5981        request.protocol_version = 3;
5982        request.process_ref = Some("worker/legacy-rollout".to_string());
5983
5984        let response = round_trip(dispatcher, &request).await;
5985        assert!(!response.ok);
5986        assert!(
5987            !response.version_mismatch,
5988            "a client below this protocol is answered in the implicit shape its bridge re-execs on"
5989        );
5990        assert_eq!(
5991            response.error_detail.as_ref().unwrap()["code"],
5992            "version_mismatch"
5993        );
5994        assert_eq!(
5995            response.error_detail.as_ref().unwrap()["domain_disposition"],
5996            "unknown"
5997        );
5998        assert_eq!(response.daemon_protocol_version, PROTOCOL_VERSION);
5999        assert_eq!(
6000            dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6001            0,
6002            "a v3 frame must be rejected before a provenance-bearing mutation dispatches"
6003        );
6004        let error = response.error.expect("mismatch explains both versions");
6005        assert!(
6006            error.contains("client=3") && error.contains(&format!("daemon={PROTOCOL_VERSION}")),
6007            "mismatch must identify the exact rollout boundary; got {error:?}"
6008        );
6009    }
6010
6011    /// Pre-v8 bridges compare the served config id exactly and can replay a
6012    /// successful write locally after a daemon accepts a compatible superset.
6013    /// Reject their requests before dispatch so a rolling upgrade cannot write twice.
6014    #[tokio::test]
6015    async fn protocol_v7_frame_is_rejected_before_compatible_superset_dispatch() {
6016        let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6017        let client_id = config_id("p", "");
6018        let daemon_id = config_id("p", "m");
6019        assert!(super::config_ids_compatible(&client_id, &daemon_id));
6020        let dispatcher = MockDispatch {
6021            namespace: "local".to_string(),
6022            config_id: daemon_id,
6023            dispatch_calls: Arc::clone(&dispatch_calls),
6024            pool: None,
6025            dispatch_err: None,
6026        };
6027        let mut request = base_request_frame(&client_id);
6028        request.protocol_version = 7;
6029
6030        let response = round_trip(dispatcher, &request).await;
6031        assert!(!response.ok);
6032        assert!(!response.version_mismatch);
6033        assert_eq!(
6034            response.error_detail.as_ref().unwrap()["code"],
6035            "version_mismatch"
6036        );
6037        assert_eq!(response.daemon_protocol_version, PROTOCOL_VERSION);
6038        assert_eq!(dispatch_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
6039    }
6040
6041    /// A client above this protocol is answered with the explicit flag: that
6042    /// direction is the warm-old-daemon case, where the newer client's own
6043    /// handling replaces the daemon, and the implicit shape reserved for older
6044    /// bridges must not reach it. A matching client is served (the round-trip
6045    /// tests above).
6046    #[tokio::test]
6047    async fn newer_client_frame_is_refused_with_the_explicit_flag() {
6048        let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6049        let dispatcher = MockDispatch {
6050            namespace: "local".to_string(),
6051            config_id: "cfg-v4".to_string(),
6052            dispatch_calls: Arc::clone(&dispatch_calls),
6053            pool: None,
6054            dispatch_err: None,
6055        };
6056        let mut request = base_request_frame("cfg-v4");
6057        request.protocol_version = PROTOCOL_VERSION + 1;
6058
6059        let response = round_trip(dispatcher, &request).await;
6060        assert!(!response.ok);
6061        assert!(
6062            response.version_mismatch,
6063            "a client above this protocol keeps the explicit flag"
6064        );
6065        assert_eq!(response.daemon_protocol_version, PROTOCOL_VERSION);
6066        assert_eq!(
6067            response.error_detail.as_ref().unwrap()["code"],
6068            "version_mismatch"
6069        );
6070        assert_eq!(
6071            dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6072            0,
6073            "a newer client's frame must not dispatch"
6074        );
6075    }
6076
6077    /// Test 1: a `metrics_only: true` request
6078    /// returns `metrics: Some(_)` and never reaches the ops-dispatch path; a
6079    /// normal request (the default `metrics_only: false`) still dispatches
6080    /// exactly as before and carries no metrics. Also proves `metrics_only`
6081    /// bypasses the `config_id` equality reject (a gauge read is
6082    /// process-global, not namespaced to a particular client config).
6083    #[tokio::test]
6084    async fn metrics_only_frame_returns_snapshot_without_dispatching() {
6085        let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6086        let dispatcher = MockDispatch {
6087            namespace: "local".to_string(),
6088            config_id: "cfg-a".to_string(),
6089            dispatch_calls: Arc::clone(&dispatch_calls),
6090            pool: None,
6091            dispatch_err: None,
6092        };
6093
6094        let mut metrics_req = base_request_frame("cfg-a");
6095        metrics_req.metrics_only = true;
6096        let metrics_resp = round_trip(dispatcher.clone(), &metrics_req).await;
6097
6098        assert!(metrics_resp.ok, "metrics_only response must be ok=true");
6099        assert!(
6100            metrics_resp.metrics.is_some(),
6101            "metrics_only=true must return Some(snapshot)"
6102        );
6103        assert_eq!(
6104            dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6105            0,
6106            "metrics_only must never reach the ops-dispatch path"
6107        );
6108
6109        // metrics_only bypasses the config_id equality reject.
6110        let mut mismatched_req = base_request_frame("some-other-config");
6111        mismatched_req.metrics_only = true;
6112        let mismatched_resp = round_trip(dispatcher.clone(), &mismatched_req).await;
6113        assert!(mismatched_resp.ok);
6114        assert!(mismatched_resp.metrics.is_some());
6115        assert!(!mismatched_resp.config_mismatch);
6116        assert_eq!(
6117            dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6118            0,
6119            "a mismatched-config metrics_only request must still skip dispatch"
6120        );
6121
6122        // A normal request (default metrics_only=false) is unaffected: it
6123        // still dispatches and carries no metrics.
6124        let normal_req = base_request_frame("cfg-a");
6125        let normal_resp = round_trip(dispatcher, &normal_req).await;
6126        assert!(normal_resp.ok);
6127        assert!(normal_resp.metrics.is_none());
6128        assert_eq!(dispatch_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
6129    }
6130
6131    /// Group/other-writable socket directories are refused whether or not the
6132    /// sticky bit is set, and — the part that matters — are left exactly as
6133    /// they were found.
6134    ///
6135    /// The sticky `/tmp` shape (1777) is in the refusal set deliberately: the
6136    /// sticky bit restricts unlinking, not creating, so a shared directory
6137    /// lets another user pre-bind the predictable socket path before this
6138    /// daemon starts; and a sticky directory's owner may unlink and rebind
6139    /// regardless. Re-permissioning someone else's directory on the way past
6140    /// is what the second assertion pins against: run as a user who *can*
6141    /// chmod it, the old unconditional call succeeded and revoked access for
6142    /// every other process using that directory.
6143    #[test]
6144    fn shared_writable_socket_dirs_are_refused_without_being_modified() {
6145        let dir = tempfile::tempdir().expect("tempdir");
6146        for (name, mode) in [("open", 0o777u32), ("sticky-tmp", 0o1777u32)] {
6147            let shared = dir.path().join(name);
6148            std::fs::create_dir(&shared).expect("create");
6149            std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))
6150                .expect("chmod");
6151
6152            let err = ensure_socket_dir_is_trusted(&shared)
6153                .expect_err("group/other-writable must be refused, sticky or not");
6154            assert!(
6155                err.to_string().contains(&format!("{:04o}", mode & 0o7777)),
6156                "the refusal should name the mode it saw, got: {err}"
6157            );
6158            let after = std::fs::metadata(&shared)
6159                .expect("stat")
6160                .permissions()
6161                .mode()
6162                & 0o7777;
6163            assert_eq!(
6164                after, mode,
6165                "refusing must not re-permission a directory khive does not own"
6166            );
6167        }
6168    }
6169
6170    /// A vetted final directory is still refused when an ANCESTOR would let
6171    /// another local user rename it away and recreate it: a non-sticky
6172    /// group/other-writable ancestor re-roots the whole socket path without
6173    /// the final directory's own metadata ever changing. The sticky-ancestor
6174    /// arm (a root-owned 1777 `/tmp` above a user-owned 0700 directory is
6175    /// acceptable) is exercised implicitly by every accepting test below,
6176    /// whose tempdirs live under the platform temp root.
6177    #[test]
6178    fn writable_non_sticky_ancestor_is_refused() {
6179        let dir = tempfile::tempdir().expect("tempdir");
6180        let open_mid = dir.path().join("open-mid");
6181        std::fs::create_dir(&open_mid).expect("create mid");
6182        let inner = open_mid.join("private");
6183        std::fs::create_dir(&inner).expect("create inner");
6184        std::fs::set_permissions(&inner, std::fs::Permissions::from_mode(0o700)).expect("chmod");
6185        std::fs::set_permissions(&open_mid, std::fs::Permissions::from_mode(0o777))
6186            .expect("chmod mid");
6187
6188        let err = ensure_socket_dir_is_trusted(&inner)
6189            .expect_err("a 0777 non-sticky ancestor must be refused");
6190        assert!(
6191            err.to_string().contains("ancestor"),
6192            "the refusal should say it was an ancestor that failed, got: {err}"
6193        );
6194        assert!(
6195            err.to_string().contains("open-mid"),
6196            "the refusal should name the failing ancestor, got: {err}"
6197        );
6198    }
6199
6200    /// The path walk validates what a symlink component points AT, not just
6201    /// the symlink node: a trusted (self-owned) link into a group/other-
6202    /// writable non-sticky directory is refused on the target directory's
6203    /// metadata, proving the walk keeps traversing — and keeps applying the
6204    /// directory rules — past the link, exactly as the kernel will at bind
6205    /// time.
6206    #[test]
6207    fn symlink_component_to_untrusted_directory_is_refused() {
6208        let dir = tempfile::tempdir().expect("tempdir");
6209        let open = dir.path().join("open-target");
6210        std::fs::create_dir(&open).expect("create target");
6211        std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).expect("chmod");
6212        let link = dir.path().join("link");
6213        std::os::unix::fs::symlink(&open, &link).expect("symlink");
6214
6215        // SAFETY: `geteuid` is always successful and takes no arguments.
6216        let euid = unsafe { libc::geteuid() } as u32;
6217        let err = ensure_socket_path_is_swap_resistant(&link, euid)
6218            .expect_err("a link into a 0777 non-sticky directory must be refused");
6219        assert!(
6220            err.to_string().contains("open-target"),
6221            "the refusal should name the untrusted target directory, got: {err}"
6222        );
6223    }
6224
6225    include!("daemon/socket_path_tests.rs");
6226
6227    /// Directories this daemon can trust end to end are served as found:
6228    /// owner-only, and the umask-default 0755 every `tempfile::tempdir` and
6229    /// test runner produces (readable/traversable but writable only by the
6230    /// owner). On macOS these fixtures also implicitly exercise the
6231    /// symlink-accept arm, since the platform temp root itself resolves
6232    /// through root-owned symlinks. Foreign ownership of a directory is
6233    /// refused by the same helper; a separate simulated-euid test covers
6234    /// foreign ownership without requiring a privileged chown operation.
6235    #[test]
6236    fn trusted_socket_dirs_are_accepted_unmodified() {
6237        let dir = tempfile::tempdir().expect("tempdir");
6238        for (name, mode) in [("private", 0o700), ("listable", 0o755)] {
6239            let d = dir.path().join(name);
6240            std::fs::create_dir(&d).expect("create");
6241            std::fs::set_permissions(&d, std::fs::Permissions::from_mode(mode)).expect("chmod");
6242
6243            ensure_socket_dir_is_trusted(&d)
6244                .unwrap_or_else(|e| panic!("mode {mode:04o} must be accepted, got: {e}"));
6245
6246            let after = std::fs::metadata(&d).expect("stat").permissions().mode() & 0o7777;
6247            assert_eq!(
6248                after, mode,
6249                "acceptance must not re-permission the directory either"
6250            );
6251        }
6252    }
6253
6254    #[test]
6255    fn pid_directory_owned_by_another_uid_is_refused() {
6256        let dir = tempfile::Builder::new()
6257            .prefix("khive-pid-owner-")
6258            .tempdir()
6259            .expect("tempdir");
6260        let parent = dir.path().join("private");
6261        std::fs::create_dir(&parent).expect("create private directory");
6262        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))
6263            .expect("set private mode");
6264
6265        // A non-root test cannot chown its directory to another uid. Injecting
6266        // an euid that does not own this directory exercises that same refusal.
6267        // SAFETY: `geteuid` is always successful and takes no arguments.
6268        let daemon_euid = (unsafe { libc::geteuid() } as u32).wrapping_add(1);
6269        let error = ensure_rendezvous_dir_is_trusted(
6270            &parent,
6271            RendezvousPathRole::PidFile,
6272            daemon_euid,
6273            false,
6274        )
6275        .expect_err("a PID parent owned by another uid must be refused");
6276        let message = format!("{error:#}");
6277
6278        assert!(
6279            message.contains("KHIVE_PID"),
6280            "wrong variable in refusal: {message}"
6281        );
6282        assert!(
6283            message.contains("PID-file directory") && message.contains("owned by uid"),
6284            "refusal must identify foreign ownership of the PID parent: {message}"
6285        );
6286    }
6287
6288    /// Test 2: `wal_pages` reflects a real
6289    /// checkpoint observation after writes, deterministically forced via a
6290    /// direct `checkpoint_once` call rather than waiting on the async
6291    /// periodic task.
6292    #[tokio::test]
6293    async fn metrics_snapshot_wal_pages_reflects_recent_write() {
6294        let dir = tempfile::tempdir().expect("tempdir");
6295        let path = dir.path().join("metrics_wal_test.db");
6296        let pool = Arc::new(
6297            ConnectionPool::new(khive_db::PoolConfig {
6298                path: Some(path),
6299                ..khive_db::PoolConfig::for_test()
6300            })
6301            .expect("pool open"),
6302        );
6303
6304        {
6305            let writer = pool.try_writer().expect("writer");
6306            writer
6307                .conn()
6308                .execute_batch(
6309                    "CREATE TABLE t (x INTEGER); \
6310                     INSERT INTO t VALUES (1); \
6311                     INSERT INTO t VALUES (2);",
6312                )
6313                .expect("seed writes");
6314        }
6315
6316        let dedicated_conn = pool
6317            .open_standalone_writer()
6318            .expect("open dedicated checkpoint connection");
6319        khive_db::checkpoint_once(
6320            &pool,
6321            &dedicated_conn,
6322            &CheckpointConfig::default(),
6323            &mut khive_db::checkpoint::TruncateState::default(),
6324        )
6325        .expect("checkpoint_once must observe on a healthy dedicated connection");
6326
6327        let dispatcher = MockDispatch {
6328            namespace: "local".to_string(),
6329            config_id: "cfg-wal".to_string(),
6330            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6331            pool: Some(pool),
6332            dispatch_err: None,
6333        };
6334
6335        let snapshot = build_metrics_snapshot(&dispatcher);
6336        assert!(
6337            snapshot.wal_pages.is_some(),
6338            "wal_pages must be observed after a real checkpoint tick, got {snapshot:?}"
6339        );
6340        assert_eq!(snapshot.wal_log_frames, snapshot.wal_pages);
6341        assert!(snapshot.wal_checkpointed_frames.is_some());
6342        assert!(snapshot.wal_pending_frames.is_some());
6343        assert!(snapshot.wal_physical_bytes.is_some());
6344        assert!(snapshot.wal_observed_at_unix_ms.is_some());
6345        assert_eq!(snapshot.wal_checkpoint_stores.len(), 1);
6346        assert_eq!(snapshot.wal_checkpoint_stores[0].store_id, "main");
6347        assert_eq!(snapshot.wal_checkpoint_stores[0].timing.ticks, 1);
6348        // The snapshot carries the checkpoint-pressure fields read-only
6349        // (no mutation path reachable through `MetricsSnapshot`/`DaemonRequestFrame`);
6350        // an observed tick (not a skip) must report a zero-length skip streak.
6351        assert_eq!(
6352            snapshot.wal_checkpoint_consecutive_skips, 0,
6353            "an observed (non-skipped) tick must report zero consecutive skips, got {snapshot:?}"
6354        );
6355    }
6356
6357    #[derive(Clone)]
6358    struct CheckpointMetricsDispatch {
6359        main: Option<Arc<ConnectionPool>>,
6360        secondaries: Vec<Arc<ConnectionPool>>,
6361    }
6362
6363    #[async_trait]
6364    impl DaemonDispatch for CheckpointMetricsDispatch {
6365        fn plan(&self, _ops: &str) -> String {
6366            panic!("metrics must not plan")
6367        }
6368        async fn dispatch(
6369            &self,
6370            _ops: String,
6371            _presentation: Option<String>,
6372            _presentation_per_op: Option<Vec<Option<String>>>,
6373            _format: Option<String>,
6374            _format_per_op: Option<Vec<Option<String>>>,
6375            _from_wire: bool,
6376            _identity: Option<RequestIdentity>,
6377        ) -> Result<String, String> {
6378            panic!("metrics must not dispatch")
6379        }
6380        async fn warm_all(&self) {}
6381        fn namespace(&self) -> &str {
6382            "local"
6383        }
6384        fn config_id(&self) -> &str {
6385            "checkpoint-metrics"
6386        }
6387        fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
6388            self.main.clone()
6389        }
6390        fn secondary_pools_for_checkpoint(&self) -> Vec<Arc<ConnectionPool>> {
6391            self.secondaries.clone()
6392        }
6393    }
6394
6395    #[tokio::test]
6396    #[serial(checkpoint_skip_metrics)]
6397    async fn metrics_checkpoint_timing_keeps_stores_separate_and_scrapes_read_only() {
6398        let dir = tempfile::tempdir().unwrap();
6399        let mut pools = Vec::new();
6400        for label in ["primary", "secondary"] {
6401            let directory = dir.path().join(label);
6402            std::fs::create_dir(&directory).unwrap();
6403            let pool = Arc::new(
6404                ConnectionPool::new(khive_db::PoolConfig {
6405                    path: Some(directory.join("same.db")),
6406                    ..khive_db::PoolConfig::for_test()
6407                })
6408                .unwrap(),
6409            );
6410            pool.try_writer()
6411                .unwrap()
6412                .conn()
6413                .execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
6414                .unwrap();
6415            pools.push(pool);
6416        }
6417        let dispatcher = CheckpointMetricsDispatch {
6418            main: Some(Arc::clone(&pools[0])),
6419            secondaries: vec![Arc::clone(&pools[1])],
6420        };
6421        let before = build_metrics_snapshot(&dispatcher);
6422        assert_eq!(before.wal_checkpoint_stores.len(), 2);
6423        assert!(before
6424            .wal_checkpoint_stores
6425            .iter()
6426            .all(|store| store.timing.ticks == 0));
6427        for (index, pool) in pools.iter().enumerate() {
6428            let conn = pool.open_standalone_writer().unwrap();
6429            for _ in 0..=index {
6430                khive_db::checkpoint_once(
6431                    pool,
6432                    &conn,
6433                    &CheckpointConfig {
6434                        truncate_high_water_pages: u64::MAX,
6435                        ..CheckpointConfig::default()
6436                    },
6437                    &mut khive_db::checkpoint::TruncateState::default(),
6438                )
6439                .unwrap();
6440            }
6441        }
6442        let mut request = base_request_frame("checkpoint-metrics");
6443        request.metrics_only = true;
6444        let snapshot = round_trip(dispatcher.clone(), &request)
6445            .await
6446            .metrics
6447            .unwrap();
6448        let stores = &snapshot.wal_checkpoint_stores;
6449        assert_eq!(stores.len(), 2);
6450        assert_eq!(stores[0].store_id, "main");
6451        assert_eq!(stores[0].role, "main");
6452        assert_eq!(stores[1].store_id, "secondary:0");
6453        assert_eq!(stores[1].role, "secondary");
6454        for store in stores {
6455            assert_eq!(
6456                store.database.as_deref(),
6457                Some("same.db"),
6458                "wire label must omit directories"
6459            );
6460            assert!(store.timing.elapsed_us_max <= store.timing.elapsed_us_sum);
6461        }
6462        assert_eq!(stores[0].timing.ticks, 1);
6463        assert_eq!(stores[1].timing.ticks, 2);
6464        let again = build_metrics_snapshot(&dispatcher);
6465        assert_eq!(
6466            again.wal_checkpoint_stores, *stores,
6467            "scraping must not checkpoint"
6468        );
6469        let secondary_only = build_metrics_snapshot(&CheckpointMetricsDispatch {
6470            main: None,
6471            secondaries: vec![Arc::clone(&pools[1])],
6472        });
6473        assert_eq!(secondary_only.wal_checkpoint_stores.len(), 1);
6474        assert_eq!(
6475            secondary_only.wal_checkpoint_stores[0].store_id,
6476            "secondary:0"
6477        );
6478        assert_eq!(secondary_only.wal_checkpoint_stores[0].timing.ticks, 2);
6479        assert!(build_metrics_snapshot(&CheckpointMetricsDispatch {
6480            main: None,
6481            secondaries: vec![]
6482        })
6483        .wal_checkpoint_stores
6484        .is_empty());
6485    }
6486
6487    #[test]
6488    fn metrics_checkpoint_timing_serde_is_additive_and_round_trips() {
6489        let snapshot = MetricsSnapshot {
6490            wal_checkpoint_stores: vec![CheckpointStoreMetrics {
6491                store_id: "secondary:0".into(),
6492                role: "secondary".into(),
6493                database: Some("memory.db".into()),
6494                timing: khive_db::checkpoint::CheckpointTiming {
6495                    ticks: 7,
6496                    elapsed_us_sum: 123,
6497                    elapsed_us_max: 50,
6498                    busy_ticks: 2,
6499                    error_ticks: 1,
6500                },
6501            }],
6502            ..MetricsSnapshot::default()
6503        };
6504        let wire = serde_json::to_value(&snapshot).unwrap();
6505        let store = &wire["wal_checkpoint_stores"][0];
6506        assert_eq!(
6507            store,
6508            &serde_json::json!({
6509                "store_id": "secondary:0", "role": "secondary", "database": "memory.db",
6510                "ticks": 7, "elapsed_us_sum": 123, "elapsed_us_max": 50, "busy_ticks": 2, "error_ticks": 1,
6511            })
6512        );
6513        assert_eq!(
6514            serde_json::from_value::<MetricsSnapshot>(wire.clone()).unwrap(),
6515            snapshot
6516        );
6517        let mut old_wire = wire.clone();
6518        old_wire
6519            .as_object_mut()
6520            .unwrap()
6521            .remove("wal_checkpoint_stores");
6522        let old = serde_json::from_value::<MetricsSnapshot>(old_wire).unwrap();
6523        assert!(
6524            old.wal_checkpoint_stores.is_empty(),
6525            "old snapshot must default the new vector"
6526        );
6527        let partial = serde_json::from_value::<CheckpointStoreMetrics>(serde_json::json!({
6528            "store_id": "main", "role": "main"
6529        }))
6530        .unwrap();
6531        assert_eq!(
6532            partial.timing,
6533            khive_db::checkpoint::CheckpointTiming::default()
6534        );
6535        assert_eq!(partial.database, None);
6536        #[derive(serde::Deserialize)]
6537        struct LegacyMetrics {
6538            wal_pages: Option<u64>,
6539            open_tx_count: usize,
6540        }
6541        let legacy: LegacyMetrics = serde_json::from_value(wire).unwrap();
6542        assert_eq!(legacy.wal_pages, None);
6543        assert_eq!(legacy.open_tx_count, 0);
6544    }
6545
6546    #[tokio::test]
6547    async fn metrics_snapshot_exposes_decomposed_writer_stages() {
6548        let dir = tempfile::tempdir().expect("tempdir");
6549        let path = dir.path().join("metrics_writer_stage_test.db");
6550        let pool = Arc::new(
6551            ConnectionPool::new(khive_db::PoolConfig {
6552                path: Some(path),
6553                ..khive_db::PoolConfig::for_test()
6554            })
6555            .expect("pool open"),
6556        );
6557        {
6558            let writer = pool.try_writer().unwrap();
6559            writer
6560                .conn()
6561                .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY)")
6562                .unwrap();
6563        }
6564        let handle = pool
6565            .writer_task_handle()
6566            .unwrap()
6567            .expect("file-backed default writer task");
6568        handle
6569            .send(|conn| {
6570                std::thread::sleep(std::time::Duration::from_millis(30));
6571                conn.execute("INSERT INTO t VALUES (1)", [])
6572                    .map_err(|error| khive_storage::error::StorageError::Pool {
6573                        operation: "metrics_writer_stage_test".into(),
6574                        message: error.to_string(),
6575                    })
6576            })
6577            .await
6578            .unwrap();
6579
6580        let dispatcher = MockDispatch {
6581            namespace: "local".to_string(),
6582            config_id: "cfg-writer-stages".to_string(),
6583            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6584            pool: Some(pool),
6585            dispatch_err: None,
6586        };
6587        let snapshot = build_metrics_snapshot(&dispatcher);
6588        assert!(snapshot.write_last_queue_wait_micros.is_some());
6589        assert!(snapshot.write_last_transaction_acquire_micros.is_some());
6590        assert!(snapshot.write_last_commit_micros.is_some());
6591        assert!(
6592            snapshot.write_last_body_micros >= Some(25_000),
6593            "synthetic delay must be attributed to the body: {snapshot:?}"
6594        );
6595        assert!(snapshot.write_last_total_micros >= snapshot.write_last_body_micros);
6596        assert!(snapshot.write_last_observed_at_unix_ms.is_some());
6597    }
6598
6599    /// Test 3: the tx-pin oracle. The registry is process-global, so an
6600    /// unrelated transaction can depart between snapshots and exactly offset
6601    /// this test's registration. Keep an owned handle live and assert the
6602    /// resulting count floor instead of comparing two points in time.
6603    #[test]
6604    #[serial(tx_registry)]
6605    fn metrics_snapshot_reflects_open_transaction_registry() {
6606        let dispatcher = MockDispatch {
6607            namespace: "local".to_string(),
6608            config_id: "cfg-tx".to_string(),
6609            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6610            pool: None,
6611            dispatch_err: None,
6612        };
6613
6614        let departing_handle = khive_storage::tx_registry::register(Some(
6615            "daemon_metrics_snapshot_departing_test_tx".to_string(),
6616        ));
6617        let before = build_metrics_snapshot(&dispatcher).open_tx_count;
6618        assert!(before >= 1);
6619
6620        let handle = khive_storage::tx_registry::register(Some(
6621            "daemon_metrics_snapshot_owned_test_tx".to_string(),
6622        ));
6623        drop(departing_handle);
6624
6625        let during = build_metrics_snapshot(&dispatcher);
6626        assert!(
6627            during.open_tx_count >= 1,
6628            "open_tx_count must reflect the live owned transaction despite registry churn: \
6629             churn_baseline={before} during={}",
6630            during.open_tx_count
6631        );
6632        assert!(
6633            during.oldest_pinned_tx_micros.is_some(),
6634            "oldest_pinned_tx_micros must be Some while a transaction is open"
6635        );
6636
6637        drop(handle);
6638        assert!(
6639            !khive_storage::tx_registry::snapshot()
6640                .iter()
6641                .any(|(_, label)| label.as_deref()
6642                    == Some("daemon_metrics_snapshot_owned_test_tx")),
6643            "the owned registry entry must disappear when its handle is dropped"
6644        );
6645    }
6646
6647    /// Test 4: write-queue depth is flag-gated
6648    /// on `PoolConfig::write_queue_enabled` (the `KHIVE_WRITE_QUEUE=1`
6649    /// setting), never on a specific depth value (racy under concurrency).
6650    #[tokio::test]
6651    async fn metrics_snapshot_write_queue_depth_flag_gated() {
6652        let dir = tempfile::tempdir().expect("tempdir");
6653
6654        let enabled_pool = Arc::new(
6655            ConnectionPool::new(khive_db::PoolConfig {
6656                path: Some(dir.path().join("wq_enabled.db")),
6657                write_queue_enabled: Some(true),
6658                ..khive_db::PoolConfig::for_test()
6659            })
6660            .expect("pool open"),
6661        );
6662        let enabled_dispatcher = MockDispatch {
6663            namespace: "local".to_string(),
6664            config_id: "cfg-wq-on".to_string(),
6665            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6666            pool: Some(enabled_pool),
6667            dispatch_err: None,
6668        };
6669        let snapshot_on = build_metrics_snapshot(&enabled_dispatcher);
6670        assert!(
6671            snapshot_on.write_queue_depth.is_some(),
6672            "write_queue_depth must be Some when write_queue_enabled=true, got {snapshot_on:?}"
6673        );
6674        assert!(snapshot_on.write_queue_capacity.is_some());
6675
6676        let disabled_pool = Arc::new(
6677            ConnectionPool::new(khive_db::PoolConfig {
6678                path: Some(dir.path().join("wq_disabled.db")),
6679                write_queue_enabled: Some(false),
6680                ..khive_db::PoolConfig::for_test()
6681            })
6682            .expect("pool open"),
6683        );
6684        let disabled_dispatcher = MockDispatch {
6685            namespace: "local".to_string(),
6686            config_id: "cfg-wq-off".to_string(),
6687            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6688            pool: Some(disabled_pool),
6689            dispatch_err: None,
6690        };
6691        let snapshot_off = build_metrics_snapshot(&disabled_dispatcher);
6692        assert!(
6693            snapshot_off.write_queue_depth.is_none(),
6694            "write_queue_depth must be None when write_queue_enabled=false, got {snapshot_off:?}"
6695        );
6696        assert!(snapshot_off.write_queue_capacity.is_none());
6697
6698        // No pool at all (in-memory/poolless dispatcher): also None.
6699        let no_pool_dispatcher = MockDispatch {
6700            namespace: "local".to_string(),
6701            config_id: "cfg-no-pool".to_string(),
6702            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6703            pool: None,
6704            dispatch_err: None,
6705        };
6706        let snapshot_no_pool = build_metrics_snapshot(&no_pool_dispatcher);
6707        assert!(snapshot_no_pool.write_queue_depth.is_none());
6708        assert!(snapshot_no_pool.write_queue_capacity.is_none());
6709    }
6710
6711    /// Test 5: serde default back-compat in both directions — a request JSON
6712    /// without additive request fields deserializes to their defaults, and a
6713    /// response JSON without `metrics` (an old daemon's shape) deserializes
6714    /// with it `None`.
6715    #[test]
6716    fn frame_serde_defaults_additive_fields_when_absent() {
6717        let req_json = serde_json::json!({
6718            "ops": "",
6719            "presentation": null,
6720            "presentation_per_op": null,
6721            "namespace": "local",
6722            "actor_id": null,
6723            "visible_namespaces": [],
6724            "config_id": "cfg",
6725            "protocol_version": PROTOCOL_VERSION,
6726            "probe_only": false,
6727            "format": null,
6728            "format_per_op": null,
6729            "from_wire": false
6730        });
6731        let frame: DaemonRequestFrame =
6732            serde_json::from_value(req_json).expect("decode a metrics_only-absent request frame");
6733        assert!(
6734            !frame.metrics_only,
6735            "metrics_only must default to false when absent from the wire payload"
6736        );
6737        assert_eq!(
6738            frame.request_id, None,
6739            "request_id must default to None when absent from the wire payload (khive#948)"
6740        );
6741        assert_eq!(
6742            frame.process_ref, None,
6743            "process_ref must default to None when absent from the wire payload (khive#1428)"
6744        );
6745        let encoded_frame = serde_json::to_value(&frame).expect("encode request frame");
6746        assert!(
6747            encoded_frame.get("process_ref").is_none(),
6748            "absent provenance must not change the serialized request wire shape"
6749        );
6750
6751        let resp_json = serde_json::json!({
6752            "ok": true,
6753            "result": null,
6754            "error": null,
6755            "namespace_mismatch": false,
6756            "config_mismatch": false,
6757            "served_config_id": "cfg",
6758            "version_mismatch": false,
6759            "daemon_protocol_version": PROTOCOL_VERSION
6760        });
6761        let resp: DaemonResponseFrame =
6762            serde_json::from_value(resp_json).expect("decode a metrics-absent response frame");
6763        assert!(
6764            resp.metrics.is_none(),
6765            "metrics must default to None when absent from the wire payload"
6766        );
6767        assert_eq!(
6768            resp.request_id, None,
6769            "request_id must default to None when absent from the wire payload (khive#948)"
6770        );
6771    }
6772
6773    /// khive#948: a request carrying `request_id: Some(n)` gets back a
6774    /// response with `request_id: Some(n)` on both the success and the
6775    /// error/denied dispatch arms — the echo must survive every branch of
6776    /// `handle_conn`, not only the happy path.
6777    #[tokio::test]
6778    async fn request_id_echoed_on_success_and_error_arms() {
6779        let dispatcher = MockDispatch {
6780            namespace: "local".to_string(),
6781            config_id: "cfg-a".to_string(),
6782            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6783            pool: None,
6784            dispatch_err: None,
6785        };
6786        let mut ok_req = base_request_frame("cfg-a");
6787        ok_req.request_id = Some(42);
6788        let ok_resp = round_trip(dispatcher, &ok_req).await;
6789        assert!(ok_resp.ok, "expected successful dispatch: {ok_resp:?}");
6790        assert_eq!(
6791            ok_resp.request_id,
6792            Some(42),
6793            "request_id must be echoed back on a successful dispatch response"
6794        );
6795
6796        // config_mismatch is a rejection arm that never reaches dispatch —
6797        // must still echo the id so the client can join the failure.
6798        let mismatched_dispatcher = MockDispatch {
6799            namespace: "local".to_string(),
6800            config_id: "cfg-a".to_string(),
6801            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6802            pool: None,
6803            dispatch_err: None,
6804        };
6805        let mut mismatch_req = base_request_frame("cfg-WRONG");
6806        mismatch_req.request_id = Some(99);
6807        let mismatch_resp = round_trip(mismatched_dispatcher, &mismatch_req).await;
6808        assert!(mismatch_resp.config_mismatch);
6809        assert_eq!(
6810            mismatch_resp.request_id,
6811            Some(99),
6812            "request_id must be echoed on the config_mismatch rejection arm too"
6813        );
6814
6815        // The real ops-dispatch error arm (`Err(e)` from `dispatcher.dispatch`)
6816        // must echo the id as well, not only the pre-dispatch rejection arms.
6817        let erroring_dispatcher = MockDispatch {
6818            namespace: "local".to_string(),
6819            config_id: "cfg-a".to_string(),
6820            dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6821            pool: None,
6822            dispatch_err: Some("simulated dispatch error".to_string()),
6823        };
6824        let mut err_req = base_request_frame("cfg-a");
6825        err_req.request_id = Some(7);
6826        let err_resp = round_trip(erroring_dispatcher, &err_req).await;
6827        assert!(!err_resp.ok, "expected a dispatch error: {err_resp:?}");
6828        assert_eq!(
6829            err_resp.request_id,
6830            Some(7),
6831            "request_id must be echoed on the real ops-dispatch error arm"
6832        );
6833    }
6834
6835    // ── owner-checked shutdown cleanup ────────────────────────────────────────
6836    //
6837    // A draining daemon must not unlink a socket/PID pair that a replacement
6838    // daemon has already bound. These tests exercise `shutdown_cleanup_if_owned`
6839    // directly (the pure decision the caller makes under the recovery lock)
6840    // rather than driving `run_daemon`'s real SIGTERM shutdown, which would
6841    // require sending a signal to the whole test process.
6842
6843    #[test]
6844    fn shutdown_cleanup_removes_paths_it_still_owns() {
6845        let dir = tempfile::tempdir().expect("tempdir");
6846        let sock = dir.path().join("khived.sock");
6847        let pid_file = dir.path().join("khived.pid");
6848
6849        let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
6850        std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
6851        let identity = socket_identity(&sock);
6852        assert!(
6853            identity.is_some(),
6854            "must read identity of a freshly bound socket"
6855        );
6856
6857        let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
6858
6859        assert!(
6860            cleaned,
6861            "cleanup must proceed when PID and socket still match"
6862        );
6863        assert!(!sock.exists(), "owned socket must be removed");
6864        assert!(!pid_file.exists(), "owned pid file must be removed");
6865    }
6866
6867    #[test]
6868    fn shutdown_cleanup_skips_when_pid_file_names_a_different_process() {
6869        let dir = tempfile::tempdir().expect("tempdir");
6870        let sock = dir.path().join("khived.sock");
6871        let pid_file = dir.path().join("khived.pid");
6872
6873        let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
6874        let identity = socket_identity(&sock);
6875        // A concurrent client's kill_and_respawn already replaced the PID file
6876        // with a different (replacement daemon's) PID before this daemon's
6877        // drain completed.
6878        std::fs::write(&pid_file, "1").expect("write foreign pid file");
6879
6880        let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
6881
6882        assert!(
6883            !cleaned,
6884            "cleanup must be skipped when the PID file no longer names this process"
6885        );
6886        assert!(sock.exists(), "replacement daemon's socket must survive");
6887        assert!(
6888            pid_file.exists(),
6889            "replacement daemon's pid file must survive"
6890        );
6891    }
6892
6893    #[test]
6894    fn shutdown_cleanup_skips_when_socket_was_rebound_by_a_replacement() {
6895        let dir = tempfile::tempdir().expect("tempdir");
6896        let sock = dir.path().join("khived.sock");
6897        let original_sock = dir.path().join("original.sock");
6898        let pid_file = dir.path().join("khived.pid");
6899
6900        // Bind two sockets at DIFFERENT paths, both alive at the same time,
6901        // so the OS cannot recycle an inode between them the way it could
6902        // across a bind/drop/rebind cycle at a single path (the flakiness a
6903        // prior version of this test hit on some filesystems). Both
6904        // identities are captured through the real production
6905        // `socket_identity()` path, not a synthetic/sentinel value, so a
6906        // regression where `socket_identity()` returns a constant identity
6907        // for every socket makes the `assert!` below fail loudly instead of
6908        // silently passing.
6909        let _original_listener =
6910            std::os::unix::net::UnixListener::bind(&original_sock).expect("bind original socket");
6911        let _replacement_listener =
6912            std::os::unix::net::UnixListener::bind(&sock).expect("bind replacement socket");
6913
6914        let original_identity = socket_identity(&original_sock);
6915        let replacement_identity = socket_identity(&sock);
6916        assert!(
6917            original_identity.is_some(),
6918            "must read identity of the original socket"
6919        );
6920        assert!(
6921            replacement_identity.is_some(),
6922            "must read identity of the replacement socket"
6923        );
6924        assert!(
6925            original_identity != replacement_identity,
6926            "two concurrently bound sockets must have distinct identities"
6927        );
6928
6929        std::fs::write(&pid_file, std::process::id().to_string())
6930            .expect("write pid file matching this process");
6931
6932        // `sock` (the replacement bind's path) is checked against
6933        // `original_identity` (a different, concurrently-alive socket's
6934        // identity) - the mismatch alone must be enough to block cleanup,
6935        // even though the pid file matches this process.
6936        let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, original_identity);
6937
6938        assert!(
6939            !cleaned,
6940            "cleanup must be skipped when the socket at this path is a different \
6941             inode than the one this daemon originally bound"
6942        );
6943        assert!(sock.exists(), "replacement daemon's socket must survive");
6944        assert!(
6945            pid_file.exists(),
6946            "replacement daemon's pid file must survive"
6947        );
6948    }
6949
6950    #[test]
6951    fn shutdown_cleanup_preserves_atomically_renamed_successor() {
6952        let dir = tempfile::tempdir().expect("tempdir");
6953        let sock = dir.path().join("khived.sock");
6954        let staged_sock = dir.path().join("next.sock");
6955        let pid_file = dir.path().join("khived.pid");
6956        let _original_listener =
6957            std::os::unix::net::UnixListener::bind(&sock).expect("bind original socket");
6958        let successor =
6959            std::os::unix::net::UnixListener::bind(&staged_sock).expect("bind staged successor");
6960        let original_identity = socket_identity(&sock).expect("original socket identity");
6961        let successor_identity = socket_identity(&staged_sock).expect("successor socket identity");
6962        assert!(original_identity != successor_identity);
6963        let original_pid = std::process::id().to_string();
6964        std::fs::write(&pid_file, &original_pid).expect("write original PID");
6965
6966        std::fs::rename(&staged_sock, &sock).expect("publish successor over original socket");
6967        assert!(!staged_sock.exists());
6968        assert!(socket_identity(&sock) == Some(successor_identity));
6969        // A matching PID must not authorize deleting a different socket inode.
6970        assert!(!shutdown_cleanup_if_owned(
6971            &sock,
6972            &pid_file,
6973            Some(original_identity)
6974        ));
6975        assert!(socket_identity(&sock) == Some(successor_identity));
6976        assert_eq!(
6977            std::fs::read_to_string(&pid_file).expect("PID must survive stale cleanup"),
6978            original_pid
6979        );
6980        successor
6981            .set_nonblocking(true)
6982            .expect("bound successor must support nonblocking accept");
6983        let _client = std::os::unix::net::UnixStream::connect(&sock)
6984            .expect("published successor must remain reachable");
6985        let _accepted = successor
6986            .accept()
6987            .expect("successor must receive connection");
6988    }
6989
6990    #[test]
6991    fn isolated_daemon_locks_use_private_fixture_paths() {
6992        if crate::test_process::run_in_child() {
6993            return;
6994        }
6995        let home = PathBuf::from(std::env::var_os("HOME").expect("child HOME"));
6996        for path in [lock_path(), recoverer_lock_path()] {
6997            assert_eq!(
6998                path.parent(),
6999                home.parent(),
7000                "runtime daemon locks must use private fixture paths outside HOME"
7001            );
7002        }
7003        let _boot = acquire_daemon_boot_guard().expect("private boot lock");
7004        let _recoverer = try_acquire_recoverer_lock_until(
7005            std::time::Instant::now() + std::time::Duration::from_secs(1),
7006        )
7007        .expect("private recoverer lock")
7008        .expect("private recoverer lock must be available");
7009        assert!(lock_path().is_file());
7010        assert!(recoverer_lock_path().is_file());
7011        assert!(
7012            std::fs::read_dir(home).unwrap().next().is_none(),
7013            "both daemon lock producers must leave the child HOME empty"
7014        );
7015    }
7016
7017    include!("daemon/store_guard_tests.rs");
7018
7019    // ── the recovery lock actually serializes two boot sequences ─────────────
7020    //
7021    // Production wiring (`khive_mcp::serve::run` / `serve_server`) now acquires
7022    // this same lock *before* building a `KhiveMcpServer` (which runs
7023    // migrations and applies pack schema plans / FTS DDL) and holds it through
7024    // daemon bind+pid-write, via `run_daemon_with_boot_guard`. That closes the
7025    // cold-boot race only if `acquire_recovery_lock` genuinely provides mutual
7026    // exclusion across concurrent boot attempts — this test proves the
7027    // primitive itself: two "boot sequences" (each holding the lock across a
7028    // simulated schema-init critical section) must never run their critical
7029    // sections at the same time.
7030    #[test]
7031    #[serial]
7032    fn recovery_lock_serializes_two_concurrent_boot_sequences() {
7033        if crate::test_process::run_in_child() {
7034            return;
7035        }
7036
7037        let dir = tempfile::tempdir().expect("tempdir");
7038        let lock_file = dir.path().join("khived.recovery.lock");
7039        std::env::set_var("KHIVE_LOCK", &lock_file);
7040
7041        let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7042        let overlap_detected = Arc::new(std::sync::atomic::AtomicBool::new(false));
7043
7044        let run_one_boot =
7045            |active: Arc<std::sync::atomic::AtomicUsize>,
7046             overlap: Arc<std::sync::atomic::AtomicBool>| {
7047                move || {
7048                    let _guard = acquire_recovery_lock().expect("acquire recovery lock");
7049                    // Enter the "schema-init" critical section.
7050                    if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) != 0 {
7051                        overlap.store(true, std::sync::atomic::Ordering::SeqCst);
7052                    }
7053                    std::thread::sleep(std::time::Duration::from_millis(50));
7054                    active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
7055                    // `_guard` drops here, releasing the lock.
7056                }
7057            };
7058
7059        let t1 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
7060        let t2 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
7061        t1.join().expect("boot thread 1 must not panic");
7062        t2.join().expect("boot thread 2 must not panic");
7063
7064        assert!(
7065            !overlap_detected.load(std::sync::atomic::Ordering::SeqCst),
7066            "two concurrent boot sequences must never hold the schema-init \
7067             critical section at the same time (#667)"
7068        );
7069
7070        std::env::remove_var("KHIVE_LOCK");
7071    }
7072
7073    // ── acquire_daemon_boot_guard treats lock failure as fatal ───────────────
7074    // (unlike best-effort acquire_recovery_lock, whose `None` on failure is
7075    // correct for its own best-effort callers).
7076
7077    #[test]
7078    #[serial]
7079    fn acquire_daemon_boot_guard_returns_guard_when_lock_available() {
7080        if crate::test_process::run_in_child() {
7081            return;
7082        }
7083
7084        let dir = tempfile::tempdir().expect("tempdir");
7085        let lock_file = dir.path().join("khived.recovery.lock");
7086        std::env::set_var("KHIVE_LOCK", &lock_file);
7087
7088        let guard = acquire_daemon_boot_guard();
7089        assert!(
7090            guard.is_ok(),
7091            "daemon boot guard must succeed when the lock file can be opened and flocked"
7092        );
7093        drop(guard);
7094
7095        std::env::remove_var("KHIVE_LOCK");
7096    }
7097
7098    #[test]
7099    #[serial]
7100    fn acquire_daemon_boot_guard_fails_loudly_when_lock_file_cannot_be_opened() {
7101        if crate::test_process::run_in_child() {
7102            return;
7103        }
7104
7105        let dir = tempfile::tempdir().expect("tempdir");
7106        // Point KHIVE_LOCK at a directory, not a file: opening a directory
7107        // with `write(true)` fails (EISDIR), so `acquire_recovery_lock`
7108        // returns `None` here — the exact failure mode
7109        // `acquire_daemon_boot_guard` must turn into a hard `Err` instead of
7110        // silently letting daemon-mode boot proceed unguarded.
7111        std::env::set_var("KHIVE_LOCK", dir.path());
7112
7113        let result = acquire_daemon_boot_guard();
7114        assert!(
7115            result.is_err(),
7116            "daemon boot guard must fail loudly, never silently proceed unguarded, \
7117             when the underlying recovery lock cannot be acquired"
7118        );
7119
7120        std::env::remove_var("KHIVE_LOCK");
7121    }
7122
7123    // ── write_pid_file_exclusive never truncates a winner's pid file ────────
7124
7125    #[test]
7126    fn write_pid_file_exclusive_creates_new_file_with_own_pid() {
7127        let dir = tempfile::tempdir().expect("tempdir");
7128        let pid_file = dir.path().join("khived.pid");
7129        write_pid_file_exclusive(&pid_file).expect("first writer must win");
7130        let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
7131        assert_eq!(contents, std::process::id().to_string());
7132    }
7133
7134    #[test]
7135    fn write_pid_file_exclusive_refuses_to_overwrite_an_existing_file() {
7136        let dir = tempfile::tempdir().expect("tempdir");
7137        let pid_file = dir.path().join("khived.pid");
7138        std::fs::write(&pid_file, "999999").expect("seed an existing pid file");
7139
7140        let err = write_pid_file_exclusive(&pid_file)
7141            .expect_err("must not silently overwrite an existing pid file");
7142        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
7143
7144        // The existing content must be completely untouched — proving this is
7145        // `create_new`, not the old `create(true).truncate(true)`.
7146        let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
7147        assert_eq!(
7148            contents, "999999",
7149            "an existing pid file must never be truncated by a losing writer"
7150        );
7151    }
7152
7153    // Real (not simulated) concurrency: two OS threads race to `create_new`
7154    // the exact same path, synchronized with a `Barrier` so they genuinely
7155    // overlap at the syscall rather than relying on a sleep-based ordering
7156    // guess. This is the deterministic race oracle for the convergence
7157    // requirement the atomic-creation primitive `write_pid_file_exclusive`
7158    // is built on: exactly one of two simultaneous daemon starters may claim
7159    // the pid file, and the loser must see `AlreadyExists`, never silently
7160    // clobber the winner's content.
7161    #[test]
7162    fn two_concurrent_writers_converge_on_exactly_one_pid_file_owner() {
7163        let dir = tempfile::tempdir().expect("tempdir");
7164        let pid_file = std::sync::Arc::new(dir.path().join("khived.pid"));
7165        let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
7166
7167        let spawn_writer =
7168            |pid_file: std::sync::Arc<std::path::PathBuf>,
7169             barrier: std::sync::Arc<std::sync::Barrier>| {
7170                std::thread::spawn(move || {
7171                    barrier.wait();
7172                    write_pid_file_exclusive(&pid_file)
7173                })
7174            };
7175
7176        let t1 = spawn_writer(pid_file.clone(), barrier.clone());
7177        let t2 = spawn_writer(pid_file.clone(), barrier.clone());
7178        let r1 = t1.join().expect("writer 1 must not panic");
7179        let r2 = t2.join().expect("writer 2 must not panic");
7180
7181        let results = [&r1, &r2];
7182        let ok_count = results.iter().filter(|r| r.is_ok()).count();
7183        let already_exists_count = results
7184            .iter()
7185            .filter(|r| matches!(r, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists))
7186            .count();
7187        assert_eq!(
7188            ok_count, 1,
7189            "exactly one of two concurrent writers must win the pid file"
7190        );
7191        assert_eq!(
7192            already_exists_count, 1,
7193            "the other writer must observe AlreadyExists, never a silent overwrite"
7194        );
7195        assert!(pid_file.exists(), "the winner's pid file must exist");
7196        let contents = std::fs::read_to_string(&*pid_file).expect("read pid file");
7197        assert_eq!(
7198            contents,
7199            std::process::id().to_string(),
7200            "the surviving pid file must contain the winner's pid — both threads \
7201             share this process's pid, so an unexpected value would also prove a \
7202             lost/garbled write raced through"
7203        );
7204    }
7205
7206    // ── connection principal (ADR-096 condition 2) ────────────────────────────
7207
7208    /// The peer-credential syscall must actually work on this platform and
7209    /// report the real uid, not error or return a placeholder.
7210    ///
7211    /// This matters more than it looks because the accept path fails CLOSED: if
7212    /// `peer_uid` errored unconditionally — wrong syscall, wrong socket option,
7213    /// an unimplemented platform arm — every connection would be refused and
7214    /// the daemon would be silently unreachable. A test that only exercised the
7215    /// decision function would not catch that.
7216    #[tokio::test]
7217    async fn peer_uid_reports_the_connecting_process_uid() {
7218        let dir = tempfile::tempdir().expect("tempdir");
7219        let sock = dir.path().join("peer.sock");
7220        let listener = UnixListener::bind(&sock).expect("bind");
7221
7222        let connect_path = sock.clone();
7223        let client = tokio::spawn(async move { UnixStream::connect(&connect_path).await });
7224
7225        let (server_side, _) = listener.accept().await.expect("accept");
7226        let client_side = client.await.expect("join").expect("connect");
7227
7228        // SAFETY: `geteuid` is always successful and takes no arguments.
7229        let expected = unsafe { libc::geteuid() } as u32;
7230
7231        assert_eq!(
7232            peer_uid(&server_side).expect("peer_uid must succeed on a live connection"),
7233            expected,
7234            "the uid read from the kernel for a same-process connection must be \
7235             this process's euid"
7236        );
7237        // Symmetric: both ends report the same peer on a same-uid connection.
7238        assert_eq!(
7239            peer_uid(&client_side).expect("peer_uid must succeed on the client end"),
7240            expected
7241        );
7242    }
7243
7244    /// The refusal rule itself: the principal is the uid, and only a foreign
7245    /// uid is refused.
7246    ///
7247    /// The second assertion is the load-bearing one and it is a regression
7248    /// guard, not a formality. ADR-096 shipped many `actor_id`s over one
7249    /// socket; every seat on a normal host is a distinct attribution at the
7250    /// same uid. A check that conflated attribution with principal would refuse
7251    /// them all, so "same uid is permitted" must stay true no matter how the
7252    /// rule is later tightened.
7253    #[test]
7254    fn only_a_foreign_uid_is_refused() {
7255        // SAFETY: `geteuid` is always successful and takes no arguments.
7256        let euid = unsafe { libc::geteuid() } as u32;
7257
7258        assert!(
7259            uid_is_permitted(euid, euid),
7260            "a connection from the daemon's own uid must be served — this is \
7261             every seat on the host, and ADR-096 accepted exactly this shape"
7262        );
7263        assert!(
7264            !uid_is_permitted(euid.wrapping_add(1), euid),
7265            "a connection from any other uid must be refused"
7266        );
7267        assert!(
7268            !uid_is_permitted(0, euid.wrapping_add(1)),
7269            "root is not special-cased: the rule is equality with the daemon's \
7270             euid, not a privilege comparison"
7271        );
7272    }
7273
7274    /// Captures one tracing event's fields as `name=value ` text, so a test can
7275    /// assert on what an operator reading the log actually sees rather than on
7276    /// the value the log line was formatted from.
7277    struct CapturedFields(Arc<std::sync::Mutex<Vec<String>>>);
7278
7279    impl tracing::Subscriber for CapturedFields {
7280        fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
7281            true
7282        }
7283        fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
7284            tracing::span::Id::from_u64(1)
7285        }
7286        fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
7287        fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
7288        fn event(&self, event: &tracing::Event<'_>) {
7289            struct Visitor(String);
7290            impl tracing::field::Visit for Visitor {
7291                fn record_debug(
7292                    &mut self,
7293                    field: &tracing::field::Field,
7294                    value: &dyn std::fmt::Debug,
7295                ) {
7296                    self.0.push_str(&format!("{}={:?} ", field.name(), value));
7297                }
7298            }
7299            let mut visitor = Visitor(String::new());
7300            event.record(&mut visitor);
7301            self.0.lock().unwrap().push(visitor.0);
7302        }
7303        fn enter(&self, _: &tracing::span::Id) {}
7304        fn exit(&self, _: &tracing::span::Id) {}
7305    }
7306
7307    // Shares the process-wide background-task statics with the counter tests
7308    // above; see the `#[serial(background_tasks)]` note there.
7309    #[cfg(unix)]
7310    #[tokio::test]
7311    #[serial(background_tasks)]
7312    async fn drain_timeout_warning_names_the_outstanding_tasks() {
7313        let lines = Arc::new(std::sync::Mutex::new(Vec::new()));
7314        let subscriber = CapturedFields(lines.clone());
7315        let _dispatch = tracing::dispatcher::set_default(&tracing::Dispatch::new(subscriber));
7316
7317        let active = std::sync::atomic::AtomicUsize::new(0);
7318        let (stop_tx, stop_rx) = tokio::sync::broadcast::channel::<()>(1);
7319        for name in ["test_task_alpha", "test_task_beta"] {
7320            let mut rx = stop_rx.resubscribe();
7321            track_named_background_task(name, async move {
7322                let _ = rx.recv().await;
7323            });
7324        }
7325        drop(stop_rx);
7326
7327        let drained = drain_with_timeout(&active, std::time::Duration::from_millis(150)).await;
7328        assert!(
7329            !drained,
7330            "two unfinished tasks must make the drain time out"
7331        );
7332
7333        let warned = lines
7334            .lock()
7335            .unwrap()
7336            .iter()
7337            .find(|line| line.contains("drain timeout reached"))
7338            .cloned()
7339            .expect("the drain timeout must emit its warning through the test subscriber");
7340        assert!(
7341            warned.contains("test_task_alpha") && warned.contains("test_task_beta"),
7342            "the drain-timeout warning must name every outstanding task; got {warned}"
7343        );
7344
7345        let _ = stop_tx.send(());
7346        for _ in 0..100 {
7347            if background_task_names().is_empty() {
7348                break;
7349            }
7350            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7351        }
7352    }
7353
7354    // Shares the process-wide background-task statics; see the
7355    // `#[serial(background_tasks)]` note above.
7356    #[tokio::test]
7357    #[serial(background_tasks)]
7358    async fn a_named_task_drops_its_name_when_it_finishes() {
7359        let before = background_task_count();
7360        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
7361        track_named_background_task("test_task_finishes", async move {
7362            let _ = rx.await;
7363        });
7364        assert!(
7365            background_task_names().contains(&"test_task_finishes".to_string()),
7366            "a live named task must be listed while the counter holds it"
7367        );
7368        tx.send(()).expect("still awaiting");
7369        for _ in 0..100 {
7370            if background_task_count() == before {
7371                break;
7372            }
7373            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7374        }
7375        assert_eq!(background_task_count(), before);
7376        assert!(
7377            !background_task_names().contains(&"test_task_finishes".to_string()),
7378            "a finished task's name must be released, not left to accumulate"
7379        );
7380    }
7381
7382    // Shares the process-wide background-task statics; see the
7383    // `#[serial(background_tasks)]` note above.
7384    #[tokio::test]
7385    #[serial(background_tasks)]
7386    async fn the_unnamed_entry_point_still_registers_and_releases() {
7387        let before = background_task_count();
7388        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
7389        track_background_task(async move {
7390            let _ = rx.await;
7391        });
7392        assert_eq!(background_task_count(), before + 1);
7393        assert!(
7394            background_task_names().contains(&UNNAMED_BACKGROUND_TASK.to_string()),
7395            "the unchanged public entry point must still register, under the placeholder name"
7396        );
7397        tx.send(()).expect("still awaiting");
7398        for _ in 0..100 {
7399            if background_task_count() == before {
7400                break;
7401            }
7402            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7403        }
7404        assert_eq!(background_task_count(), before);
7405    }
7406
7407    include!("daemon_config_id_tests.rs");
7408}