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 locks;
14#[cfg(all(test, unix))]
15use locks::open_lock_file;
16#[cfg(unix)]
17pub use locks::{
18    acquire_daemon_boot_guard, acquire_recovery_lock, try_acquire_daemon_boot_guard_until,
19    try_acquire_recoverer_lock_until, DaemonBootGuard,
20};
21#[cfg(unix)]
22pub(crate) use locks::{peer_uid, uid_is_permitted};
23#[cfg(unix)]
24use locks::{socket_identity, SocketIdentity};
25
26mod store_guard;
27mod store_identity;
28#[cfg(unix)]
29use store_guard::ensure_claimed_parent_identity;
30#[cfg(unix)]
31pub use store_guard::{acquire_daemon_store_guards, bind_daemon_store_files, claim_stores};
32pub use store_guard::{assert_daemon_store_identities, DaemonStoreGuard};
33#[cfg(unix)]
34pub use store_identity::claimed_daemon_store_identity;
35mod supervisor_marker;
36#[cfg(unix)]
37pub use supervisor_marker::supervisor_marker_path;
38
39#[cfg(unix)]
40use std::io::Write as _;
41#[cfg(unix)]
42use std::os::unix::fs::{MetadataExt, PermissionsExt};
43#[cfg(unix)]
44use std::os::unix::io::AsRawFd;
45#[cfg(all(test, unix))]
46use std::path::PathBuf;
47
48#[cfg(unix)]
49use async_trait::async_trait;
50#[cfg(unix)]
51use libc;
52#[cfg(unix)]
53use serde::Deserialize;
54#[cfg(unix)]
55use tokio::io::AsyncReadExt;
56#[cfg(all(test, unix))]
57use tokio::io::AsyncWriteExt;
58#[cfg(unix)]
59use tokio::net::{UnixListener, UnixStream};
60
61#[cfg(unix)]
62use crate::pack::RequestIdentity;
63#[cfg(unix)]
64use khive_db::{run_checkpoint_task, CheckpointConfig, CheckpointLifecycleOwner, ConnectionPool};
65
66// Unix-only: gated inside the file, because the error-constructor census
67// admits only attribute-free submodule declarations.
68mod exit_record;
69mod lifecycle;
70#[cfg(unix)]
71use lifecycle::khive_dir;
72#[cfg(test)]
73use lifecycle::khive_root_from;
74#[cfg(unix)]
75use lifecycle::{
76    current_supervisor_claim, default_socket_path, ensure_rendezvous_overrides_paired,
77    read_supervisor_marker_claim, DaemonLifecycle, PID_PATH_ENV, SOCKET_PATH_ENV,
78};
79pub use lifecycle::{
80    lock_path, volume_lock_dir, DaemonLifecyclePhase, DaemonLifecycleSnapshot, DaemonLifetime,
81    DaemonOptions, DaemonShutdownReason, DaemonStartupReport, DEFAULT_DEMAND_IDLE_SECS,
82};
83#[cfg(unix)]
84pub use lifecycle::{
85    pid_path, recoverer_lock_path, rendezvous_split, socket_path, RendezvousSplit,
86    SUPERVISOR_CLAIM_ENV,
87};
88
89mod load_limits;
90#[cfg(unix)]
91use load_limits::{admit_or_refuse_busy, ConnectionAdmission};
92pub use load_limits::{
93    recall_ledger_snapshot, track_recall_ledger_task, ConnectionCapSnapshot, RecallLedgerSnapshot,
94};
95
96/// Maximum frame size accepted in either direction.
97pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
98
99/// Wire protocol version for the daemon IPC framing.
100///
101/// Increment this constant whenever the request or response frame shape
102/// changes in a backward-incompatible way. The client sends its version
103/// in every request; the daemon rejects mismatches with an explicit error
104/// that names both sides so the operator knows exactly what to do
105/// (`make local` rebuilds the client binary).
106/// See `docs/api/daemon.md#protocol_version` for the version-by-version history.
107pub const PROTOCOL_VERSION: u32 = 8;
108
109/// Internal signal carried in a dispatch result until the daemon moves it to
110/// response-frame metadata. It must never be sent in `result`: older v8
111/// clients publish that string without inspecting its contents.
112#[doc(hidden)]
113pub const DAEMON_LEXICAL_TIMEOUT_MARKER: &str = "__khive_daemon_lexical_timeout";
114
115const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 10;
116/// An accepted local socket must finish its first frame within this window.
117/// Dispatch deadlines start only after decoding, so they cannot reap peers
118/// that connect and then stop sending request bytes.
119#[cfg(unix)]
120const INITIAL_FRAME_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
121
122#[cfg(unix)]
123fn next_accept_error_backoff(previous: Option<std::time::Duration>) -> std::time::Duration {
124    previous
125        .map(|delay| delay.saturating_mul(2))
126        .unwrap_or_else(|| std::time::Duration::from_millis(10))
127        .min(std::time::Duration::from_secs(1))
128}
129
130// ── wire types ────────────────────────────────────────────────────────────────
131
132mod config_id;
133#[cfg(test)]
134use config_id::parse_config_id;
135pub use config_id::{
136    config_id_extra_embedder_exclusions, config_ids_compatible, first_config_mismatch_field,
137};
138
139mod wire_frames;
140#[cfg(unix)]
141pub use wire_frames::{read_frame, write_frame};
142#[cfg(unix)]
143use wire_frames::{read_initial_frame, take_daemon_lexical_timeout_marker};
144pub use wire_frames::{
145    CheckpointStoreMetrics, DaemonDispatchError, DaemonRequestFrame, DaemonResponseFrame,
146    MetricsSnapshot, ERROR_DETAIL_NESTING_DEPTH_LIMIT,
147};
148
149// ── dispatch trait ────────────────────────────────────────────────────────────
150
151/// Transport-agnostic dispatch interface for the daemon server.
152///
153/// The MCP crate implements this by dispatching through the shared request body
154/// while honoring [`DaemonRequestFrame::from_wire`] (so subhandler visibility is
155/// gated by request origin, not by transport); any future transport can do the
156/// same.
157#[cfg(unix)]
158#[async_trait]
159pub trait DaemonDispatch: Clone + Send + Sync + 'static {
160    /// Named retained resources or unknown inventory that prevents idle exit.
161    /// An implementor must explicitly account for its resources before retiring.
162    fn idle_retirement_blockers(&self) -> Vec<String> {
163        vec!["dispatcher_resource_inventory_unknown".to_owned()]
164    }
165
166    /// Describe syntax and loaded catalog membership without dispatching.
167    fn plan(&self, ops: &str) -> String;
168
169    /// Dispatch a verb-DSL request string and return the rendered result.
170    ///
171    /// `from_wire` carries the origin discriminator from
172    /// [`DaemonRequestFrame::from_wire`]: when `true`, the implementor enforces
173    /// verb visibility (rejects `Visibility::Subhandler` verbs); when `false`,
174    /// the request is from a trusted operator surface and subhandlers pass.
175    ///
176    /// `identity` is the per-request identity context threaded from the frame
177    /// (ADR-096): `Some(..)` when serving a request forwarded over the
178    /// daemon socket (built from `frame.namespace` / `frame.actor_id` /
179    /// `frame.visible_namespaces` by the connection handler), `None` for any
180    /// other dispatch path. Implementors should mint the storage/gate token from
181    /// `identity` when present and fall back to their own construction-baked
182    /// identity when absent, so pure local (non-daemon) dispatch is unchanged.
183    #[allow(clippy::too_many_arguments)]
184    async fn dispatch(
185        &self,
186        ops: String,
187        presentation: Option<String>,
188        presentation_per_op: Option<Vec<Option<String>>>,
189        format: Option<String>,
190        format_per_op: Option<Vec<Option<String>>>,
191        from_wire: bool,
192        identity: Option<RequestIdentity>,
193    ) -> Result<String, String>;
194
195    /// Read-deadline ceiling for one request. The default is the operator
196    /// ceiling; an implementor that understands the request may grant a
197    /// longer bounded allowance (a long poll's declared wait plus a margin).
198    fn request_read_timeout(&self, _ops: &str) -> std::time::Duration {
199        khive_storage::request_read_timeout_from_env()
200    }
201
202    /// Preserve structured dispatch errors without breaking string-only implementors.
203    #[allow(clippy::too_many_arguments)]
204    async fn dispatch_with_error_detail(
205        &self,
206        ops: String,
207        presentation: Option<String>,
208        presentation_per_op: Option<Vec<Option<String>>>,
209        format: Option<String>,
210        format_per_op: Option<Vec<Option<String>>>,
211        from_wire: bool,
212        identity: Option<RequestIdentity>,
213    ) -> Result<String, DaemonDispatchError> {
214        self.dispatch(
215            ops,
216            presentation,
217            presentation_per_op,
218            format,
219            format_per_op,
220            from_wire,
221            identity,
222        )
223        .await
224        .map_err(|message| DaemonDispatchError::new(message, None))
225    }
226
227    /// Warm every pack's in-memory state (ANN indexes, etc.).
228    async fn warm_all(&self);
229
230    /// The namespace this dispatcher was configured for.
231    fn namespace(&self) -> &str;
232
233    /// Fingerprint of this dispatcher's resolved runtime config (packs, db
234    /// target, embedders). Used to reject forwarded requests from clients whose
235    /// config differs, so a restricted client cannot dispatch through a broader
236    /// daemon.
237    fn config_id(&self) -> &str;
238
239    /// Return the pool to use for background WAL checkpointing, if available.
240    ///
241    /// Implementors backed by a file-based SQLite database should return
242    /// `Some(pool_arc)`. In-memory or test dispatchers that have no pool
243    /// return `None` and the checkpoint task is not spawned.
244    ///
245    /// The default implementation returns `None`.
246    fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
247        None
248    }
249
250    /// File-backed backend pools beyond [`Self::pool_for_checkpoint`]'s pool
251    /// (ADR-091 Amendment 3): one checkpoint task is spawned per entry here,
252    /// in addition to the one spawned for the primary pool, so a
253    /// multi-backend deployment gets PASSIVE/TRUNCATE checkpointing and
254    /// sidecar enumeration on every file-backed backend it wired, not only
255    /// the main one.
256    ///
257    /// The default implementation returns an empty `Vec` — an implementor
258    /// with only one backend (or none) needs no override.
259    fn secondary_pools_for_checkpoint(&self) -> Vec<Arc<ConnectionPool>> {
260        Vec::new()
261    }
262
263    /// Return the audit `EventStore` the checkpoint task should append
264    /// ADR-094 lifecycle events (`CheckpointOutcomeRecorded`) to, if any.
265    ///
266    /// Mirrors [`Self::pool_for_checkpoint`]'s default-`None` shape: an
267    /// implementor with no configured event store (or no pool at all) simply
268    /// gets a checkpoint task that never appends events — the checkpoint
269    /// task itself remains fully functional either way.
270    ///
271    /// The default implementation returns `None`.
272    fn event_store_for_checkpoint(&self) -> Option<Arc<dyn khive_storage::EventStore>> {
273        None
274    }
275}
276
277#[cfg(unix)]
278struct CheckpointTaskSpec {
279    pool: Arc<ConnectionPool>,
280    lifecycle_owner: Option<CheckpointLifecycleOwner>,
281    is_main: bool,
282}
283
284/// Build checkpoint-task fan-out and designate one lifecycle owner.
285///
286/// The main checkpoint task owns lifecycle emission when it exists. If the
287/// main backend is in-memory and therefore has no checkpoint task, the first
288/// file-backed secondary owns emission instead. All remaining tasks are
289/// explicit non-owners.
290#[cfg(unix)]
291fn checkpoint_task_specs(
292    main_pool: Option<Arc<ConnectionPool>>,
293    secondary_pools: Vec<Arc<ConnectionPool>>,
294    event_store: Option<Arc<dyn khive_storage::EventStore>>,
295    namespace: String,
296) -> Vec<CheckpointTaskSpec> {
297    let mut tasks = Vec::with_capacity(usize::from(main_pool.is_some()) + secondary_pools.len());
298    if let Some(pool) = main_pool {
299        tasks.push(CheckpointTaskSpec {
300            pool,
301            lifecycle_owner: None,
302            is_main: true,
303        });
304    }
305    tasks.extend(secondary_pools.into_iter().map(|pool| CheckpointTaskSpec {
306        pool,
307        lifecycle_owner: None,
308        is_main: false,
309    }));
310
311    if let (Some(task), Some(event_store)) = (tasks.first_mut(), event_store) {
312        task.lifecycle_owner = Some(CheckpointLifecycleOwner::new(event_store, namespace));
313    }
314    tasks
315}
316
317// ── tracked background tasks ─────────────────────────────────────────────────
318//
319// Pack handlers (e.g. memory.recall's ADR-081 serve-ledger append) fire
320// fire-and-forget `tokio::spawn`ed work off the response path so the caller
321// never waits on a cross-pack dispatch or a SQL write. Left untracked, that
322// work is invisible to `drain()`: a SIGTERM landing between the response
323// returning and the spawned task completing can abort it mid-flight with no
324// log and no row. `track_background_task` gives such spawns a process-wide
325// presence that `drain()` waits on, exactly like the `active` counter does
326// for in-flight connections: the caller still only pays for the spawn +
327// counter increment, never the task's own work.
328/// Set once by the boot path that takes the daemon role, and never cleared: a
329/// process that is not the warm daemon has no path to becoming one except exec.
330static WARM_INDEX_HOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
331
332/// Declare this process the warm index host. Called by the serve path as soon as
333/// the daemon role is decided, before any runtime is built, so nothing warms
334/// under the wrong answer.
335pub fn mark_warm_index_host() {
336    WARM_INDEX_HOST.store(true, std::sync::atomic::Ordering::Release);
337}
338
339/// Whether this process is the warm index host.
340///
341/// Building an ANN index from the full corpus is minutes of CPU and hundreds of
342/// megabytes of segment rewrite, and it pays for itself only across a process
343/// that outlives the request. A short-lived client that does it pays the whole
344/// cost, discards the result at exit, and publishes a checkpoint that every
345/// other reader on the root must then re-read. Consumers use this to decide
346/// whether to build or to serve degraded and let the daemon build.
347pub fn is_warm_index_host() -> bool {
348    WARM_INDEX_HOST.load(std::sync::atomic::Ordering::Acquire)
349}
350
351static BACKGROUND_TASKS: std::sync::OnceLock<Arc<std::sync::atomic::AtomicUsize>> =
352    std::sync::OnceLock::new();
353
354fn background_tasks() -> &'static Arc<std::sync::atomic::AtomicUsize> {
355    BACKGROUND_TASKS.get_or_init(|| Arc::new(std::sync::atomic::AtomicUsize::new(0)))
356}
357
358/// Decrements the shared background-task counter from `Drop`, so the count
359/// comes back down whether the tracked future returns normally, panics, or
360/// is cancelled — a plain post-`await` `fetch_sub` only covers the return
361/// path and leaks the count forever on a panic, since unwinding skips every
362/// statement after the panic point.
363// ── outstanding background-task names ────────────────────────────────────────
364//
365// Names of the tasks the background counter is currently holding, so a drain
366// timeout can say which ones held it open instead of printing a bare count.
367// Registered and released at exactly the points the counter is incremented and
368// decremented, and in the order that keeps the counter authoritative: the name
369// goes in after the increment and comes out before the decrement, so a reported
370// name always belongs to a task the counter already holds. The reverse ordering
371// would let the warning name a task that had already finished, which is the one
372// reading that would send someone looking in the wrong place.
373static BACKGROUND_TASK_NAMES: std::sync::OnceLock<
374    std::sync::Mutex<std::collections::HashMap<&'static str, usize>>,
375> = std::sync::OnceLock::new();
376
377fn background_task_names_registry(
378) -> &'static std::sync::Mutex<std::collections::HashMap<&'static str, usize>> {
379    BACKGROUND_TASK_NAMES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
380}
381
382/// Name recorded for tasks spawned through the unnamed entry points, which
383/// stay on the public API of a published crate.
384pub const UNNAMED_BACKGROUND_TASK: &str = "unnamed";
385
386fn register_background_task_name(name: &'static str) {
387    let mut names = background_task_names_registry()
388        .lock()
389        .unwrap_or_else(std::sync::PoisonError::into_inner);
390    *names.entry(name).or_insert(0) += 1;
391}
392
393fn release_background_task_name(name: &'static str) {
394    let mut names = background_task_names_registry()
395        .lock()
396        .unwrap_or_else(std::sync::PoisonError::into_inner);
397    if let Some(count) = names.get_mut(name) {
398        *count -= 1;
399        if *count == 0 {
400            names.remove(name);
401        }
402    }
403}
404
405/// Names of the in-flight tracked background tasks, sorted and deduplicated.
406/// A diagnostic beside [`background_task_count`], never a substitute for it:
407/// the count is what drain waits on.
408pub fn background_task_names() -> Vec<String> {
409    let names = background_task_names_registry()
410        .lock()
411        .unwrap_or_else(std::sync::PoisonError::into_inner);
412    let mut out: Vec<String> = names.keys().map(|name| (*name).to_string()).collect();
413    out.sort();
414    out
415}
416
417#[cfg(unix)]
418fn idle_retirement_blockers<D: DaemonDispatch>(dispatcher: &D) -> Vec<String> {
419    let mut blockers = dispatcher.idle_retirement_blockers();
420    if !khive_storage::tx_registry::snapshot().is_empty() {
421        blockers.push("open_sql_transaction".to_owned());
422    }
423    blockers.extend(
424        active_phase_names()
425            .into_iter()
426            .map(|name| format!("active_phase:{name}")),
427    );
428    let count = background_task_count();
429    let names = background_task_names_registry()
430        .lock()
431        .unwrap_or_else(std::sync::PoisonError::into_inner);
432    if names.values().sum::<usize>() != count {
433        blockers.push("tracked_worker_inventory_unsettled".to_owned());
434    }
435    // Only these inspected loops maintain replaceable caches/checkpoint state.
436    // Their tracked lifetime still participates in the final drain.
437    for name in names.keys() {
438        if !matches!(
439            *name,
440            "wal_checkpoint" | "memory_ann_rotation_watch" | "knowledge_ann_rotation_watch"
441        ) {
442            blockers.push(format!("unsettled_worker:{name}"));
443        }
444    }
445    blockers.sort();
446    blockers.dedup();
447    blockers
448}
449
450#[cfg(unix)]
451async fn wait_for_idle<D: DaemonDispatch>(dispatcher: &D, lifecycle: &DaemonLifecycle) {
452    if lifecycle.options.lifetime == DaemonLifetime::Persistent {
453        std::future::pending::<()>().await;
454    }
455    loop {
456        if lifecycle.try_idle(|| idle_retirement_blockers(dispatcher)) {
457            return;
458        }
459        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
460    }
461}
462
463struct BackgroundTaskGuard {
464    counter: Arc<std::sync::atomic::AtomicUsize>,
465    name: &'static str,
466}
467
468impl Drop for BackgroundTaskGuard {
469    fn drop(&mut self) {
470        release_background_task_name(self.name);
471        self.counter
472            .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
473    }
474}
475
476/// Spawn a task that daemon shutdown's `drain()` waits for and return its join
477/// handle. Retaining the handle lets boot coordinators form an explicit barrier;
478/// dropping it deliberately detaches the task while the background counter still
479/// keeps daemon drain aware of its lifetime. The decrement happens via
480/// `BackgroundTaskGuard`'s `Drop`, including panic and cancellation paths.
481pub fn spawn_tracked_task<F, T>(fut: F) -> tokio::task::JoinHandle<T>
482where
483    F: std::future::Future<Output = T> + Send + 'static,
484    T: Send + 'static,
485{
486    spawn_named_tracked_task(UNNAMED_BACKGROUND_TASK, fut)
487}
488
489/// [`spawn_tracked_task`] with a name that a drain timeout can print.
490///
491/// The name is a short static string describing the task, never a formatted or
492/// caller-supplied value: it is read by an operator staring at a shutdown that
493/// would not finish, so it is a label for a call site, not a record of one
494/// occurrence.
495pub fn spawn_named_tracked_task<F, T>(name: &'static str, fut: F) -> tokio::task::JoinHandle<T>
496where
497    F: std::future::Future<Output = T> + Send + 'static,
498    T: Send + 'static,
499{
500    background_tasks().fetch_add(1, std::sync::atomic::Ordering::SeqCst);
501    register_background_task_name(name);
502    let guard = BackgroundTaskGuard {
503        counter: background_tasks().clone(),
504        name,
505    };
506    tokio::spawn(async move {
507        let _guard = guard;
508        fut.await
509    })
510}
511
512/// Spawn a fire-and-forget task through [`spawn_tracked_task`].
513///
514/// Callers that need a boot or shutdown barrier should retain and await the
515/// returned handle from [`spawn_tracked_task`] instead of detaching it here.
516pub fn track_background_task<F>(fut: F)
517where
518    F: std::future::Future<Output = ()> + Send + 'static,
519{
520    track_named_background_task(UNNAMED_BACKGROUND_TASK, fut);
521}
522
523/// [`track_background_task`] with a name that a drain timeout can print. See
524/// [`spawn_named_tracked_task`] for what belongs in the name.
525pub fn track_named_background_task<F>(name: &'static str, fut: F)
526where
527    F: std::future::Future<Output = ()> + Send + 'static,
528{
529    drop(spawn_named_tracked_task(name, fut));
530}
531
532/// Current count of in-flight tasks started via [`track_background_task`].
533/// Exposed for tests; `drain()` reads the shared counter directly.
534pub fn background_task_count() -> usize {
535    background_tasks().load(std::sync::atomic::Ordering::SeqCst)
536}
537
538/// Process-wide daemon shutdown signal (ADR-119).
539///
540/// Cancelled exactly once, when the daemon's unified shutdown future resolves
541/// — before `drain()` begins waiting on tracked tasks — so long-running
542/// daemon components supervised outside this module observe shutdown through
543/// the same path the daemon itself does, rather than inventing their own.
544/// Clones share the underlying token; child tokens derived from it are
545/// cancelled transitively.
546///
547/// In non-daemon processes the token simply never fires.
548pub fn daemon_shutdown_token() -> tokio_util::sync::CancellationToken {
549    static TOKEN: std::sync::OnceLock<tokio_util::sync::CancellationToken> =
550        std::sync::OnceLock::new();
551    TOKEN
552        .get_or_init(tokio_util::sync::CancellationToken::new)
553        .clone()
554}
555
556// ── active background phase names (ADR-103) ──────────────────────────────────
557//
558// A lightweight, best-effort process-wide gauge of which named background
559// phases (e.g. `ann_warm`) are in flight right now, read by `comm.health`'s
560// resource self-report so a caller can see "what is the daemon doing" at a
561// glance without correlating timestamps across the event log itself. Counted
562// per name rather than boolean, since more than one occurrence of the same
563// named phase can legitimately overlap (e.g. two embedding models warming
564// concurrently) — the name only drops out of the reported set once every
565// concurrent occurrence has ended.
566static ACTIVE_PHASES: std::sync::OnceLock<
567    std::sync::Mutex<std::collections::HashMap<String, usize>>,
568> = std::sync::OnceLock::new();
569
570fn active_phases() -> &'static std::sync::Mutex<std::collections::HashMap<String, usize>> {
571    ACTIVE_PHASES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
572}
573
574/// RAII guard for one occurrence of a named background phase. Increments the
575/// phase's count on creation (see [`register_active_phase`]); decrements on
576/// `Drop`, so the count comes back down whether the guarded work returns
577/// normally, panics, or is cancelled — the same rationale as
578/// `BackgroundTaskGuard` above.
579pub struct PhaseGuard {
580    name: String,
581}
582
583impl Drop for PhaseGuard {
584    fn drop(&mut self) {
585        let mut map = active_phases()
586            .lock()
587            .unwrap_or_else(std::sync::PoisonError::into_inner);
588        if let Some(count) = map.get_mut(&self.name) {
589            *count -= 1;
590            if *count == 0 {
591                map.remove(&self.name);
592            }
593        }
594    }
595}
596
597/// Register one occurrence of a named background phase as currently active.
598/// Returns a guard: drop it (or let it fall out of scope) when the phase
599/// ends. Best-effort process-wide gauge only, read by `comm.health` — never
600/// load-bearing for correctness.
601pub fn register_active_phase(name: &str) -> PhaseGuard {
602    let mut map = active_phases()
603        .lock()
604        .unwrap_or_else(std::sync::PoisonError::into_inner);
605    *map.entry(name.to_string()).or_insert(0) += 1;
606    PhaseGuard {
607        name: name.to_string(),
608    }
609}
610
611/// Currently active background-phase names, sorted for deterministic output.
612/// Empty when no tracked phase is in flight.
613pub fn active_phase_names() -> Vec<String> {
614    let map = active_phases()
615        .lock()
616        .unwrap_or_else(std::sync::PoisonError::into_inner);
617    let mut names: Vec<String> = map.keys().cloned().collect();
618    names.sort();
619    names
620}
621
622// ── server ────────────────────────────────────────────────────────────────────
623
624/// Build a point-in-time [`MetricsSnapshot`] of this process's server-side
625/// gauges. Called only from `handle_conn`'s `metrics_only` arm — a
626/// process-global, read-only assembly with no side effects of its own.
627/// See `docs/api/daemon.md#build_metrics_snapshot` for where each gauge is sourced
628/// from and why.
629#[cfg(unix)]
630fn build_metrics_snapshot<D: DaemonDispatch>(dispatcher: &D) -> MetricsSnapshot {
631    let open_tx_count = khive_storage::tx_registry::snapshot().len();
632    // ADR-091 Amendment 3: deliberately the process-wide aggregate, not a
633    // backend-attributed view — this gauge reports "oldest pinned tx in this
634    // process" across every wired backend, not an attribution claim about
635    // which database it belongs to. Attribution consumers (the session sweep,
636    // the per-backend checkpoint tasks) use the scoped `oldest_for` views in
637    // khive-db instead.
638    let (oldest_pinned_tx_micros, oldest_pinned_tx_label) =
639        match khive_storage::tx_registry::oldest() {
640            Some((_id, age, label)) => (Some(age.as_micros() as u64), label),
641            None => (None, None),
642        };
643
644    let checkpoint_pool = dispatcher.pool_for_checkpoint();
645    let mut secondary_index = 0;
646    let wal_checkpoint_stores = checkpoint_task_specs(
647        checkpoint_pool.clone(),
648        dispatcher.secondary_pools_for_checkpoint(),
649        None,
650        String::new(),
651    )
652    .into_iter()
653    .map(|task| {
654        let (store_id, role) = if task.is_main {
655            ("main".to_string(), "main".to_string())
656        } else {
657            let store_id = format!("secondary:{secondary_index}");
658            secondary_index += 1;
659            (store_id, "secondary".to_string())
660        };
661        CheckpointStoreMetrics {
662            store_id,
663            role,
664            database: task
665                .pool
666                .canonical_path()
667                .and_then(std::path::Path::file_name)
668                .map(|name| name.to_string_lossy().into_owned()),
669            timing: khive_db::checkpoint::checkpoint_timing(&task.pool),
670        }
671    })
672    .collect();
673    let routine_wal = checkpoint_pool
674        .as_deref()
675        .and_then(khive_db::checkpoint::routine_wal_observation);
676    let writer_stages = checkpoint_pool
677        .as_deref()
678        .and_then(khive_db::writer_task::last_writer_stage_observation);
679    let (write_queue_depth, write_queue_capacity) = checkpoint_pool
680        .as_ref()
681        .and_then(|pool| pool.writer_task_handle().ok().flatten())
682        .map(|handle| (Some(handle.queue_depth()), Some(handle.capacity())))
683        .unwrap_or((None, None));
684
685    MetricsSnapshot {
686        lifecycle: None,
687        wal_pages: routine_wal.as_ref().map(|sample| sample.log_frames),
688        wal_log_frames: routine_wal.as_ref().map(|sample| sample.log_frames),
689        wal_checkpointed_frames: routine_wal
690            .as_ref()
691            .map(|sample| sample.checkpointed_frames),
692        wal_pending_frames: routine_wal.as_ref().map(|sample| sample.pending_frames),
693        wal_physical_bytes: routine_wal
694            .as_ref()
695            .and_then(|sample| sample.physical_wal_bytes),
696        wal_observed_at_unix_ms: routine_wal
697            .as_ref()
698            .map(|sample| sample.observed_at_unix_ms),
699        wal_checkpoint_stores,
700        wal_truncate_attempts: khive_db::checkpoint::truncate_attempts(),
701        wal_truncate_consecutive_failures: khive_db::checkpoint::truncate_consecutive_failures(),
702        wal_checkpoint_skipped_ticks: khive_db::checkpoint::checkpoint_skipped_ticks(),
703        wal_checkpoint_consecutive_skips: khive_db::checkpoint::checkpoint_consecutive_skips(),
704        wal_checkpoint_last_skip_wal_pages: khive_db::checkpoint::checkpoint_last_skip_wal_pages(),
705        oldest_pinned_tx_micros,
706        oldest_pinned_tx_label,
707        open_tx_count,
708        write_queue_depth,
709        write_queue_capacity,
710        write_last_queue_wait_micros: writer_stages
711            .as_ref()
712            .map(|sample| sample.queue_wait_micros),
713        write_last_transaction_acquire_micros: writer_stages
714            .as_ref()
715            .map(|sample| sample.transaction_acquire_micros),
716        write_last_body_micros: writer_stages.as_ref().map(|sample| sample.body_micros),
717        write_last_commit_micros: writer_stages.as_ref().map(|sample| sample.commit_micros),
718        write_last_total_micros: writer_stages.as_ref().map(|sample| sample.total_micros),
719        write_last_observed_at_unix_ms: writer_stages
720            .as_ref()
721            .map(|sample| sample.observed_at_unix_ms),
722        connections: None,
723        recall_ledger: Some(recall_ledger_snapshot()),
724    }
725}
726
727#[cfg(unix)]
728async fn write_response_frame<W>(stream: &mut W, payload: &[u8]) -> std::io::Result<()>
729where
730    W: tokio::io::AsyncWrite + Unpin,
731{
732    tokio::time::timeout(INITIAL_FRAME_READ_TIMEOUT, write_frame(stream, payload))
733        .await
734        .map_err(|_| {
735            std::io::Error::new(
736                std::io::ErrorKind::TimedOut,
737                "daemon response write timed out",
738            )
739        })?
740}
741
742#[cfg(unix)]
743async fn wait_for_peer_disconnect(read: &mut tokio::net::unix::OwnedReadHalf) {
744    let mut byte = [0u8; 1];
745    // One request is admitted per connection. EOF, a read error, or any
746    // subsequent byte all make this connection no longer a valid response
747    // peer, so the first completed read is the entire observation.
748    let _ = read.read(&mut byte).await;
749}
750
751#[cfg(all(unix, test))]
752async fn handle_conn<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
753    handle_conn_with_shutdown(
754        stream,
755        dispatcher,
756        None,
757        tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
758    )
759    .await;
760}
761
762#[cfg(all(unix, feature = "fault-injection"))]
763#[doc(hidden)]
764pub async fn handle_conn_for_test<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
765    handle_conn_with_shutdown(
766        stream,
767        dispatcher,
768        None,
769        tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
770    )
771    .await;
772}
773
774#[cfg(unix)]
775fn plan_frame_companion(raw: &[u8]) -> Option<&'static str> {
776    let value: serde_json::Value = serde_json::from_slice(raw).ok()?;
777    if value.get("plan").and_then(serde_json::Value::as_bool) != Some(true)
778        || value
779            .get("protocol_version")
780            .and_then(serde_json::Value::as_u64)
781            != Some(u64::from(PROTOCOL_VERSION))
782    {
783        return None;
784    }
785    [
786        "presentation",
787        "presentation_per_op",
788        "format",
789        "format_per_op",
790        "request_id",
791    ]
792    .into_iter()
793    .find(|field| value.get(*field).is_some())
794}
795
796#[cfg(all(
797    unix,
798    any(test, feature = "fault-injection", feature = "test-internals")
799))]
800async fn handle_conn_with_shutdown<D: DaemonDispatch>(
801    stream: UnixStream,
802    dispatcher: D,
803    shutdown: Option<tokio::sync::watch::Receiver<bool>>,
804    initial_frame_deadline: tokio::time::Instant,
805) {
806    handle_conn_with_lifecycle(stream, dispatcher, shutdown, initial_frame_deadline, None).await;
807}
808
809#[cfg(unix)]
810async fn handle_conn_with_lifecycle<D: DaemonDispatch>(
811    mut stream: UnixStream,
812    dispatcher: D,
813    shutdown: Option<tokio::sync::watch::Receiver<bool>>,
814    initial_frame_deadline: tokio::time::Instant,
815    lifecycle: Option<Arc<DaemonLifecycle>>,
816) {
817    let mut ordinary_admission = None;
818    let production_shutdown = shutdown.is_some();
819    // A handover is delivered over the probed connection. The production
820    // listener enforces same-uid admission, and direct handler tests cannot
821    // self-signal because they do not supply the daemon shutdown receiver.
822    let handover_peer_allowed = peer_uid(&stream)
823        .ok()
824        .is_some_and(|uid| uid == unsafe { libc::geteuid() } as u32);
825    let (local_shutdown_tx, local_shutdown_rx) = tokio::sync::watch::channel(false);
826    let shutdown = shutdown.unwrap_or(local_shutdown_rx);
827    // Keeps the fallback receiver open in direct/test calls. Production owns
828    // a sender at the daemon-run scope and passes its receiver above.
829    let _local_shutdown_tx = local_shutdown_tx;
830    let raw = match read_initial_frame(&mut stream, initial_frame_deadline).await {
831        Ok(r) => r,
832        Err(e) => {
833            tracing::debug!(error = %e, "failed to read daemon request frame");
834            return;
835        }
836    };
837    #[derive(Deserialize)]
838    struct SupervisorRequestEnvelope {
839        #[serde(flatten)]
840        frame: DaemonRequestFrame,
841        #[serde(default)]
842        supervisor_handover: bool,
843    }
844    let decoded: Result<SupervisorRequestEnvelope, _> = serde_json::from_slice(&raw);
845    if decoded.as_ref().ok().is_none_or(|item| item.frame.plan) {
846        if let Some(field) = plan_frame_companion(&raw) {
847            let response = DaemonResponseFrame {
848                ok: false,
849                result: None,
850                error: Some(format!(
851                    "invalid_params: plan=true cannot be combined with {field}"
852                )),
853                error_detail: Some(serde_json::json!({
854                    "kind": "protocol",
855                    "code": "invalid_params",
856                    "message": format!("plan=true cannot be combined with {field}"),
857                    "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
858                })),
859                namespace_mismatch: false,
860                config_mismatch: false,
861                served_config_id: Some(dispatcher.config_id().to_string()),
862                version_mismatch: false,
863                daemon_protocol_version: PROTOCOL_VERSION,
864                metrics: None,
865                request_id: None,
866            };
867            if let Ok(payload) = serde_json::to_vec(&response) {
868                if let Err(error) = write_response_frame(&mut stream, &payload).await {
869                    tracing::debug!(%error, "failed to write plan envelope refusal");
870                }
871            }
872            return;
873        }
874    }
875    let (frame, handover_requested) = match decoded {
876        Ok(item) => (item.frame, item.supervisor_handover),
877        Err(e) => {
878            tracing::debug!(error = %e, "failed to decode daemon request frame");
879            return;
880        }
881    };
882    let supervisor_probe = frame.probe_only;
883    let handover_accepted = handover_requested
884        && supervisor_probe
885        && production_shutdown
886        && handover_peer_allowed
887        && frame.protocol_version == PROTOCOL_VERSION
888        && !frame.plan
889        && !frame.metrics_only
890        && frame.ops.is_empty()
891        && read_supervisor_marker_claim().is_some_and(|(pid, _)| pid != std::process::id());
892    let (mut peer_read, mut peer_write) = stream.into_split();
893
894    let served_config_id = Some(dispatcher.config_id().to_string());
895    let resp = if frame.protocol_version != PROTOCOL_VERSION {
896        let msg = format!(
897            "daemon protocol mismatch: client={} daemon={} — \
898             rebuild/update the client binary (make local)",
899            frame.protocol_version, PROTOCOL_VERSION,
900        );
901        tracing::warn!(
902            client_version = frame.protocol_version,
903            daemon_version = PROTOCOL_VERSION,
904            "daemon protocol version mismatch"
905        );
906        DaemonResponseFrame {
907            ok: false,
908            result: None,
909            error: Some(msg.clone()),
910            error_detail: Some(serde_json::json!({
911                "kind": "protocol",
912                "code": "version_mismatch",
913                "message": msg,
914                "domain_disposition": crate::DomainDisposition::Unknown.as_str(),
915            })),
916            namespace_mismatch: false,
917            config_mismatch: false,
918            served_config_id,
919            // A client below this protocol is a bridge that predates the binary this
920            // daemon was spawned from. Through protocol 5 the bridge treats an
921            // explicit `version_mismatch` from a higher-numbered daemon as a terminal
922            // error it repeats on every request, and re-execs itself onto the on-disk
923            // binary only for the implicit shape: an unequal `daemon_protocol_version`
924            // with the flag clear. Answering older clients in that shape, still
925            // refused and still carrying the code in `error_detail`, lets every
926            // pre-swap bridge replace itself on its first request instead of staying
927            // refused until a person reconnects the session. A client above this
928            // protocol keeps the explicit flag. Remove once no live bridge predates
929            // the two-direction re-exec in khive-mcp (an inode census of `kkernel mcp`
930            // processes before the swap): bridges built with it no longer read the
931            // flag, but a bump while older bridges still run must keep this shape so
932            // they replace themselves too.
933            version_mismatch: frame.protocol_version > PROTOCOL_VERSION,
934            daemon_protocol_version: PROTOCOL_VERSION,
935            metrics: None,
936            request_id: frame.request_id,
937        }
938    } else if handover_requested {
939        if handover_accepted {
940            DaemonResponseFrame {
941                ok: true,
942                result: None,
943                error: None,
944                error_detail: None,
945                namespace_mismatch: false,
946                config_mismatch: false,
947                served_config_id,
948                version_mismatch: false,
949                daemon_protocol_version: PROTOCOL_VERSION,
950                metrics: None,
951                request_id: frame.request_id,
952            }
953        } else {
954            DaemonResponseFrame {
955                ok: false,
956                result: None,
957                error: Some("supervisor handover refused".to_string()),
958                error_detail: Some(serde_json::json!({
959                    "kind": "protocol",
960                    "code": "supervisor_handover_refused",
961                    "message": "supervisor handover refused",
962                    "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
963                })),
964                namespace_mismatch: false,
965                config_mismatch: false,
966                served_config_id,
967                version_mismatch: false,
968                daemon_protocol_version: PROTOCOL_VERSION,
969                metrics: None,
970                request_id: frame.request_id,
971            }
972        }
973    } else if frame.metrics_only && !frame.plan {
974        // Process-global gauge read: namespace/config-agnostic, so this is
975        // handled BEFORE the `config_id` equality reject below (unlike every
976        // other arm) — a metrics probe must work regardless of which
977        // client's config is asking, since it never touches the dispatcher's
978        // packs/db/embed registry. READ-ONLY: builds a snapshot and returns
979        // immediately, never reaching the ops-dispatch arm.
980        DaemonResponseFrame {
981            ok: true,
982            result: None,
983            error: None,
984            error_detail: None,
985            namespace_mismatch: false,
986            config_mismatch: false,
987            served_config_id,
988            version_mismatch: false,
989            daemon_protocol_version: PROTOCOL_VERSION,
990            metrics: Some({
991                let mut metrics = build_metrics_snapshot(&dispatcher);
992                metrics.lifecycle = lifecycle.as_ref().map(|state| {
993                    let mut snapshot = state.snapshot();
994                    snapshot.idle_blockers = idle_retirement_blockers(&dispatcher);
995                    snapshot
996                });
997                metrics.connections = lifecycle.as_ref().map(|state| state.connections.snapshot());
998                metrics
999            }),
1000            request_id: frame.request_id,
1001        }
1002    // There is no `frame.namespace != dispatcher.namespace()` reject here.
1003    // The daemon accepts and serves the request under the frame's own
1004    // identity (namespace / actor / visible_namespaces, built into a
1005    // `RequestIdentity` below) over its one shared warm registry, rather
1006    // than rejecting a differently-attributed same-uid connection to a cold
1007    // local-dispatch fallback. `config_id`: which governs packs/db/embed
1008    // coherence for the shared warm engine: remains a hard reject for every
1009    // field; only legacy IDs allow a superset of the client's extra embedders.
1010    } else if !config_ids_compatible(&frame.config_id, dispatcher.config_id()) {
1011        DaemonResponseFrame {
1012            ok: false,
1013            result: None,
1014            error: None,
1015            error_detail: Some(serde_json::json!({
1016                "kind": "protocol",
1017                "code": "config_mismatch",
1018                "message": "daemon configuration does not match the request",
1019                "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
1020            })),
1021            namespace_mismatch: false,
1022            config_mismatch: true,
1023            served_config_id,
1024            version_mismatch: false,
1025            daemon_protocol_version: PROTOCOL_VERSION,
1026            metrics: None,
1027            request_id: frame.request_id,
1028        }
1029    } else if frame.plan {
1030        DaemonResponseFrame {
1031            ok: true,
1032            result: Some(dispatcher.plan(&frame.ops)),
1033            error: None,
1034            error_detail: None,
1035            namespace_mismatch: false,
1036            config_mismatch: false,
1037            served_config_id,
1038            version_mismatch: false,
1039            daemon_protocol_version: PROTOCOL_VERSION,
1040            metrics: None,
1041            request_id: None,
1042        }
1043    } else if frame.probe_only {
1044        // Probe-only request: identity checks passed; return immediately without
1045        // dispatching any verb. The client uses this to confirm the daemon is
1046        // alive and identity-matching without triggering any mutation.
1047        DaemonResponseFrame {
1048            ok: true,
1049            result: None,
1050            error: None,
1051            error_detail: None,
1052            namespace_mismatch: false,
1053            config_mismatch: false,
1054            served_config_id,
1055            version_mismatch: false,
1056            daemon_protocol_version: PROTOCOL_VERSION,
1057            metrics: None,
1058            request_id: frame.request_id,
1059        }
1060    } else {
1061        if let Some(lifecycle) = &lifecycle {
1062            ordinary_admission = lifecycle.admit();
1063            if ordinary_admission.is_none() {
1064                let refusal = DaemonResponseFrame {
1065                    ok: false,
1066                    result: None,
1067                    error: Some("daemon is draining; request was not admitted".to_owned()),
1068                    error_detail: Some(serde_json::json!({
1069                        "kind": "runtime", "code": "daemon_draining",
1070                        "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
1071                    })),
1072                    request_id: frame.request_id,
1073                    daemon_protocol_version: PROTOCOL_VERSION,
1074                    namespace_mismatch: false,
1075                    config_mismatch: false,
1076                    served_config_id: Some(dispatcher.config_id().to_owned()),
1077                    version_mismatch: false,
1078                    metrics: None,
1079                };
1080                if let Ok(payload) = serde_json::to_vec(&refusal) {
1081                    let _ = write_response_frame(&mut peer_write, &payload).await;
1082                }
1083                return;
1084            }
1085        }
1086        // Build the per-request identity context from the frame so the
1087        // implementor mints the storage/gate token from the CALLER's
1088        // identity, not the dispatcher's own construction-baked scalars.
1089        // This is always `Some` here: every frame that reaches this arm
1090        // carries a `namespace` (required on the wire) plus whatever
1091        // `actor_id`/`visible_namespaces` the client resolved (defaulting to
1092        // `None`/`vec![]` for an older, field-absent payload, which is
1093        // exactly the prior anonymous/no-extra-visibility behavior).
1094        // The caller's actor namespace joins default reads where the registry
1095        // mints the token (ADR-007 Rev 4 Rule 3b), the one seam every identity
1096        // path shares; the frame's list is forwarded as sent.
1097        let identity = RequestIdentity {
1098            namespace: frame.namespace.clone(),
1099            actor_id: frame.actor_id.clone(),
1100            visible_namespaces: frame.visible_namespaces.clone(),
1101            process_ref: frame.process_ref.clone(),
1102            request_id: frame.request_id,
1103        };
1104        tracing::debug!(
1105            request_id = frame.request_id,
1106            "daemon RequestIdentity constructed"
1107        );
1108        let (read_cancel_tx, read_cancel_rx) = tokio::sync::watch::channel(false);
1109        // The connection's own ceiling nests inside the dispatcher's, and a
1110        // nested scope keeps the earlier deadline, so the allowance must be
1111        // granted here or a long poll times out at the operator ceiling.
1112        let read_timeout = dispatcher.request_read_timeout(&frame.ops);
1113        let excluded_embedder_names =
1114            config_id_extra_embedder_exclusions(&frame.config_id, dispatcher.config_id())
1115                .expect("compatible configuration ids must expose their extra embedder sets");
1116        let dispatch = crate::runtime::scope_request_embedder_exclusions(
1117            excluded_embedder_names,
1118            khive_storage::scope_request_read_cancellation(
1119                shutdown,
1120                khive_storage::scope_request_read_cancellation(
1121                    read_cancel_rx,
1122                    khive_storage::scope_request_read_deadline(
1123                        read_timeout,
1124                        dispatcher.dispatch_with_error_detail(
1125                            frame.ops,
1126                            frame.presentation,
1127                            frame.presentation_per_op,
1128                            frame.format,
1129                            frame.format_per_op,
1130                            frame.from_wire,
1131                            Some(identity),
1132                        ),
1133                    ),
1134                ),
1135            ),
1136        );
1137        tokio::pin!(dispatch);
1138        let dispatch_result = tokio::select! {
1139            result = &mut dispatch => result,
1140            _ = wait_for_peer_disconnect(&mut peer_read) => {
1141                let _ = read_cancel_tx.send(true);
1142                dispatch.await
1143            }
1144        };
1145        match dispatch_result {
1146            Ok(result) => {
1147                let (result, detail) = take_daemon_lexical_timeout_marker(result);
1148                DaemonResponseFrame {
1149                    ok: true,
1150                    result: Some(result),
1151                    error: None,
1152                    error_detail: detail,
1153                    namespace_mismatch: false,
1154                    config_mismatch: false,
1155                    served_config_id,
1156                    version_mismatch: false,
1157                    daemon_protocol_version: PROTOCOL_VERSION,
1158                    metrics: None,
1159                    request_id: frame.request_id,
1160                }
1161            }
1162            Err(error) => {
1163                let error = DaemonDispatchError::new(error.message, Some(error.error_detail));
1164                DaemonResponseFrame {
1165                    ok: false,
1166                    result: None,
1167                    error: Some(error.message),
1168                    error_detail: Some(error.error_detail),
1169                    namespace_mismatch: false,
1170                    config_mismatch: false,
1171                    served_config_id,
1172                    version_mismatch: false,
1173                    daemon_protocol_version: PROTOCOL_VERSION,
1174                    metrics: None,
1175                    request_id: frame.request_id,
1176                }
1177            }
1178        }
1179    };
1180
1181    let payload = if supervisor_probe {
1182        serde_json::to_value(&resp).and_then(|mut value| {
1183            if let Some(claim) = current_supervisor_claim() {
1184                value["supervisor_claim"] = serde_json::Value::String(claim);
1185            }
1186            if handover_accepted {
1187                value["supervisor_handover_accepted"] = serde_json::Value::Bool(true);
1188            }
1189            serde_json::to_vec(&value)
1190        })
1191    } else {
1192        serde_json::to_vec(&resp)
1193    };
1194    let mut handover_ack_written = false;
1195    match payload {
1196        Ok(payload) => {
1197            if payload.len() > MAX_FRAME_BYTES {
1198                // The serialized response exceeds the IPC frame cap.  Send a
1199                // small explicit error frame so the client can distinguish a
1200                // per-request payload-size failure from a daemon crash.  A
1201                // client that receives this error frame will NOT trigger
1202                // stale-daemon kill/respawn (ParseFailure requires a read_frame
1203                // error, not an ok=false result).
1204                tracing::warn!(
1205                    bytes = payload.len(),
1206                    limit = MAX_FRAME_BYTES,
1207                    "daemon response exceeds MAX_FRAME_BYTES; sending explicit error frame"
1208                );
1209                let message = format!(
1210                    "response too large: {} bytes exceeds {} byte IPC cap",
1211                    payload.len(),
1212                    MAX_FRAME_BYTES,
1213                );
1214                // One frame may aggregate successful, failed, and aborted operations.
1215                let err_resp = DaemonResponseFrame {
1216                    ok: false,
1217                    result: None,
1218                    error: Some(message.clone()),
1219                    error_detail: Some(serde_json::json!({
1220                        "kind": "transport",
1221                        "code": "response_frame_size_limit",
1222                        "message": message,
1223                        "domain_disposition": crate::DomainDisposition::Unknown.as_str(),
1224                    })),
1225                    namespace_mismatch: false,
1226                    config_mismatch: false,
1227                    served_config_id: resp.served_config_id,
1228                    version_mismatch: false,
1229                    daemon_protocol_version: PROTOCOL_VERSION,
1230                    metrics: None,
1231                    request_id: resp.request_id,
1232                };
1233                if let Ok(err_payload) = serde_json::to_vec(&err_resp) {
1234                    if let Err(e) = write_response_frame(&mut peer_write, &err_payload).await {
1235                        tracing::debug!(error = %e, "failed to write oversized-response error frame");
1236                    }
1237                }
1238            } else {
1239                match write_response_frame(&mut peer_write, &payload).await {
1240                    Ok(()) => handover_ack_written = true,
1241                    Err(e) => tracing::debug!(error = %e, "failed to write daemon response frame"),
1242                }
1243            }
1244        }
1245        Err(e) => tracing::warn!(error = %e, "failed to serialize daemon response frame"),
1246    }
1247    if handover_accepted && handover_ack_written {
1248        // Signal this process, not a PID observed earlier over a socket.
1249        // Production installed its SIGTERM handler before binding the socket.
1250        if unsafe { libc::raise(libc::SIGTERM) } != 0 {
1251            tracing::error!(error = %std::io::Error::last_os_error(), "self-directed handover signal failed");
1252        }
1253    }
1254    drop(ordinary_admission);
1255}
1256
1257/// An accepted connection owns a drain slot from the accept loop until its
1258/// handler future is dropped. The claim happens before spawning so shutdown
1259/// cannot observe zero in the gap between `accept()` and the task's first poll.
1260#[cfg(unix)]
1261struct ActiveConnectionGuard {
1262    active: Arc<std::sync::atomic::AtomicUsize>,
1263}
1264
1265#[cfg(unix)]
1266impl ActiveConnectionGuard {
1267    fn claim(active: Arc<std::sync::atomic::AtomicUsize>) -> Self {
1268        active.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1269        Self { active }
1270    }
1271}
1272
1273#[cfg(unix)]
1274impl Drop for ActiveConnectionGuard {
1275    fn drop(&mut self) {
1276        self.active
1277            .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
1278    }
1279}
1280
1281#[cfg(unix)]
1282fn spawn_connection_task<F>(
1283    active: Arc<std::sync::atomic::AtomicUsize>,
1284    future: F,
1285) -> tokio::task::JoinHandle<()>
1286where
1287    F: std::future::Future<Output = ()> + Send + 'static,
1288{
1289    let guard = ActiveConnectionGuard::claim(active);
1290    tokio::spawn(async move {
1291        let _guard = guard;
1292        future.await;
1293    })
1294}
1295
1296/// Run the daemon: bind the socket, warm in the background, serve request
1297/// frames until SIGTERM/SIGINT.
1298///
1299/// Fatally acquires its own startup lock, which only protects
1300/// cleanup→pid-claim→bind — `dispatcher` has already run migrations and
1301/// applied pack schema plans while constructing itself, unguarded. Production
1302/// boot must go through [`run_daemon_with_boot_guard`] instead, which extends
1303/// the same lock back over construction. This entry point is for callers
1304/// (and tests) that build the dispatcher and start serving as one atomic
1305/// step with no separate boot-guard window to protect.
1306#[cfg(unix)]
1307pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
1308    let boot_guard = Some(acquire_daemon_boot_guard()?);
1309    run_daemon_with_boot_guard_inner(
1310        dispatcher,
1311        boot_guard,
1312        false,
1313        DaemonOptions::default(),
1314        |_| DaemonStartupReport::default(),
1315    )
1316    .await
1317}
1318
1319/// Run a real daemon server for an in-process multi-launch test.
1320///
1321/// Separate production daemon candidates have distinct PIDs, so the boot fence
1322/// recognizes a live incumbent and makes later candidates exit. Parallel
1323/// test launchers share one OS process and therefore one PID; this explicit
1324/// fault-injection entry point preserves the production fence semantics by
1325/// allowing a live same-PID incumbent to win. Ordinary daemon startup
1326/// continues to treat a same-PID rendezvous as stale, protecting PID-reuse
1327/// cleanup behavior.
1328///
1329/// A losing candidate still follows the ordinary daemon-exit path and cancels
1330/// the process-wide component shutdown token. Callers must therefore use a
1331/// component-free dispatcher; this seam validates socket/PID ownership, not
1332/// multi-candidate component lifecycle.
1333#[cfg(all(unix, any(test, feature = "fault-injection")))]
1334#[doc(hidden)]
1335pub async fn run_daemon_in_process_test<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
1336    let boot_guard = Some(acquire_daemon_boot_guard()?);
1337    run_daemon_with_boot_guard_inner(
1338        dispatcher,
1339        boot_guard,
1340        true,
1341        DaemonOptions::default(),
1342        |_| DaemonStartupReport::default(),
1343    )
1344    .await
1345}
1346
1347#[cfg(unix)]
1348#[derive(Clone, Copy, PartialEq, Eq)]
1349enum RendezvousPathRole {
1350    Socket,
1351    PidFile,
1352}
1353
1354#[cfg(unix)]
1355impl RendezvousPathRole {
1356    fn env_name(self) -> &'static str {
1357        match self {
1358            Self::Socket => SOCKET_PATH_ENV,
1359            Self::PidFile => PID_PATH_ENV,
1360        }
1361    }
1362
1363    fn directory_name(self) -> &'static str {
1364        match self {
1365            Self::Socket => "socket directory",
1366            Self::PidFile => "PID-file directory",
1367        }
1368    }
1369
1370    fn path_name(self) -> &'static str {
1371        match self {
1372            Self::Socket => "socket path",
1373            Self::PidFile => "PID-file path",
1374        }
1375    }
1376
1377    fn path_component_name(self) -> &'static str {
1378        match self {
1379            Self::Socket => "socket-path",
1380            Self::PidFile => "PID-file-path",
1381        }
1382    }
1383}
1384
1385/// Vet the socket's parent directory, re-permissioning it only when it is the
1386/// directory khive owns by convention.
1387///
1388/// `KHIVE_SOCKET` takes an arbitrary path, and the previous unconditional
1389/// chmod-0700 of its parent was wrong in both directions: pointed at a shared
1390/// parent like `/tmp` it either failed outright for an ordinary user, or —
1391/// worse — succeeded when privileged and stripped access for every other
1392/// process on the machine. A directory we did not create is never modified.
1393///
1394/// What the directory must actually prevent is a *takeover of the socket
1395/// path*: the connection gate is the 0600 socket plus the accept-time
1396/// peer-uid check, but both defend the daemon's own socket — neither helps
1397/// once another local user can put *their* listener at the path clients
1398/// resolve. There are two ways a shared directory allows that, and the
1399/// sticky bit closes neither: a writer can *pre-bind* the predictable path
1400/// before this daemon starts (the sticky bit restricts unlinking, not
1401/// creating), and the directory's *owner* can unlink and rebind even in a
1402/// 1777 directory (sticky exempts the directory owner). So a caller-chosen
1403/// directory is served only when it is trusted end to end: owned by this
1404/// daemon's euid or root, and not writable by group or other at all. The
1405/// umask-default 0755 stays acceptable; shared sticky directories like
1406/// `/tmp` do not.
1407#[cfg(unix)]
1408pub(crate) fn ensure_socket_dir_is_trusted(parent: &std::path::Path) -> anyhow::Result<()> {
1409    // SAFETY: `geteuid` is always successful and takes no arguments.
1410    let daemon_euid = unsafe { libc::geteuid() } as u32;
1411    ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::Socket, daemon_euid, true)
1412}
1413
1414/// Vet the parent directory of a PID file before reading, locking, or writing
1415/// it. The file's lock only protects the inode currently named by its path;
1416/// every directory component must therefore be as swap-resistant as the
1417/// socket rendezvous.
1418#[cfg(unix)]
1419pub fn ensure_pid_file_dir_is_trusted(pid_file: &std::path::Path) -> anyhow::Result<()> {
1420    let parent = pid_file
1421        .parent()
1422        .filter(|parent| !parent.as_os_str().is_empty())
1423        .unwrap_or_else(|| std::path::Path::new("."));
1424    // SAFETY: `geteuid` is always successful and takes no arguments.
1425    let daemon_euid = unsafe { libc::geteuid() } as u32;
1426    ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::PidFile, daemon_euid, false)
1427}
1428
1429#[cfg(unix)]
1430fn ensure_rendezvous_dir_is_trusted(
1431    parent: &std::path::Path,
1432    role: RendezvousPathRole,
1433    daemon_euid: u32,
1434    repair_owned_default: bool,
1435) -> anyhow::Result<()> {
1436    let env_name = role.env_name();
1437    let directory_name = role.directory_name();
1438
1439    if repair_owned_default && parent == khive_dir() {
1440        std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
1441            anyhow::anyhow!(
1442                "refusing to start: cannot chmod 0700 {}: {e}. The khive directory must be \
1443                 owner-only as the {directory_name} for {env_name}; it is part of the \
1444                 same-uid guarantee this daemon enforces.",
1445                parent.display()
1446            )
1447        })?;
1448        return ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role);
1449    }
1450
1451    // Fail closed on the stat itself: not being able to read the metadata is
1452    // not the same as the directory passing.
1453    let meta = std::fs::metadata(parent).map_err(|e| {
1454        anyhow::anyhow!(
1455            "refusing to start: cannot stat {directory_name} {} for {env_name}: {e}. \
1456             It gates rendezvous-path safety, and unreadable metadata is not a passing state.",
1457            parent.display()
1458        )
1459    })?;
1460
1461    use std::os::unix::fs::MetadataExt;
1462    let owner = meta.uid();
1463    if owner != daemon_euid && owner != 0 {
1464        anyhow::bail!(
1465            "refusing to start: {directory_name} {} for {env_name} is owned by uid {owner}, \
1466             not this daemon's uid ({daemon_euid}) or root. A directory owner can replace \
1467             the rendezvous path regardless of mode bits. Point {env_name} at a directory \
1468             you own, or unset it for the default.",
1469            parent.display()
1470        );
1471    }
1472
1473    let mode = meta.permissions().mode();
1474    if mode & 0o022 != 0 {
1475        anyhow::bail!(
1476            "refusing to start: {directory_name} {} for {env_name} is mode {:04o} — writable \
1477             by group or other, so another local user could replace the rendezvous path. \
1478             Use a directory only you can write, or unset {env_name} for the default. \
1479             This daemon is not changing the permissions of a directory it does not own.",
1480            parent.display(),
1481            mode & 0o7777
1482        );
1483    }
1484
1485    ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role)
1486}
1487
1488/// Socket-role form of [`ensure_rendezvous_path_is_swap_resistant`], used by the
1489/// socket-path tests.
1490#[cfg(all(unix, test))]
1491fn ensure_socket_path_is_swap_resistant(
1492    parent: &std::path::Path,
1493    daemon_euid: u32,
1494) -> anyhow::Result<()> {
1495    ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, RendezvousPathRole::Socket)
1496}
1497
1498/// Walk the socket directory path exactly as the kernel will traverse it at
1499/// bind time — component by component, following symlinks — and refuse any
1500/// node another local user could swap after this validation.
1501///
1502/// Checking only the canonicalized result is not enough: bind and every
1503/// client traverse the *original* path, so a symlink component owned by
1504/// another user can resolve somewhere trusted while it is being validated
1505/// and be retargeted before the socket is bound. Validating the nodes the
1506/// traversal actually visits closes that gap: a symlink component is
1507/// acceptable only when its owner — the only party besides root who can
1508/// retarget it — is this daemon's euid or root, and every directory
1509/// component is held to the ancestor rule below.
1510///
1511/// The directory rule is deliberately weaker than the immediate parent's.
1512/// The parent hosts the socket file, where the threat is *creation* of the
1513/// predictable path — the sticky bit does not restrict creating, so no
1514/// sticky exception is sound there. An ancestor only threatens via
1515/// *rename/unlink of an existing entry we own*, which the sticky bit does
1516/// restrict: in a sticky directory, only the entry's owner, the directory's
1517/// owner, or root may rename it. So a root-owned `/tmp` (1777) is an
1518/// acceptable ancestor of a user-owned 0700 socket directory, while a
1519/// non-sticky group/other-writable ancestor, or one owned by a third uid
1520/// (who could rename the entry, or chmod the directory first), is refused.
1521/// Every stat failure fails closed.
1522#[cfg(unix)]
1523fn ensure_rendezvous_path_is_swap_resistant(
1524    parent: &std::path::Path,
1525    daemon_euid: u32,
1526    role: RendezvousPathRole,
1527) -> anyhow::Result<()> {
1528    use std::os::unix::fs::MetadataExt;
1529
1530    let env_name = role.env_name();
1531    let directory_name = role.directory_name();
1532    let path_name = role.path_name();
1533    let component_name = role.path_component_name();
1534
1535    let absolute = if parent.is_absolute() {
1536        parent.to_path_buf()
1537    } else {
1538        std::env::current_dir()
1539            .map_err(|e| {
1540                anyhow::anyhow!(
1541                    "refusing to start: cannot resolve the working directory to absolutize \
1542                     {directory_name} {} for {env_name}: {e}.",
1543                    parent.display()
1544                )
1545            })?
1546            .join(parent)
1547    };
1548
1549    fn push_components(stack: &mut Vec<std::ffi::OsString>, path: &std::path::Path) {
1550        let components: Vec<_> = path
1551            .components()
1552            .map(|c| c.as_os_str().to_os_string())
1553            .collect();
1554        stack.extend(components.into_iter().rev());
1555    }
1556
1557    let mut stack: Vec<std::ffi::OsString> = Vec::new();
1558    push_components(&mut stack, &absolute);
1559    let mut resolved = std::path::PathBuf::new();
1560    let mut symlinks_followed = 0u32;
1561
1562    while let Some(component) = stack.pop() {
1563        if component == "/" {
1564            resolved = std::path::PathBuf::from("/");
1565            continue;
1566        }
1567        if component == "." {
1568            continue;
1569        }
1570        if component == ".." {
1571            resolved.pop();
1572            continue;
1573        }
1574        let candidate = resolved.join(&component);
1575        let meta = std::fs::symlink_metadata(&candidate).map_err(|e| {
1576            anyhow::anyhow!(
1577                "refusing to start: cannot stat {component_name} component {} for {env_name}: \
1578                 {e}. An unreadable component is not a passing one.",
1579                candidate.display()
1580            )
1581        })?;
1582        let owner = meta.uid();
1583
1584        if meta.file_type().is_symlink() {
1585            symlinks_followed += 1;
1586            if symlinks_followed > 40 {
1587                anyhow::bail!(
1588                    "refusing to start: {path_name} for {env_name} resolves through more than \
1589                     40 symlinks at {} — treating this as a loop.",
1590                    candidate.display()
1591                );
1592            }
1593            if owner != daemon_euid && owner != 0 {
1594                anyhow::bail!(
1595                    "refusing to start: {component_name} symlink component {} for {env_name} \
1596                     is owned by uid {owner}, not this daemon's uid ({daemon_euid}) or root — \
1597                     its owner could retarget it after this check and re-root the {path_name}. \
1598                     Point {env_name} somewhere trusted end to end, or unset it for the default.",
1599                    candidate.display()
1600                );
1601            }
1602            let target = std::fs::read_link(&candidate).map_err(|e| {
1603                anyhow::anyhow!(
1604                    "refusing to start: cannot read {component_name} symlink component {} \
1605                     for {env_name}: {e}.",
1606                    candidate.display()
1607                )
1608            })?;
1609            push_components(&mut stack, &target);
1610            continue;
1611        }
1612
1613        if meta.is_dir() {
1614            let mode = meta.permissions().mode();
1615            let sticky = mode & 0o1000 != 0;
1616            if owner != daemon_euid && owner != 0 {
1617                anyhow::bail!(
1618                    "refusing to start: {component_name} ancestor {} for {env_name} is owned by \
1619                     uid {owner}, not this daemon's uid ({daemon_euid}) or root — its owner \
1620                     could rename the next path component and re-root the {path_name}. Point \
1621                     {env_name} somewhere trusted end to end, or unset it for the default.",
1622                    candidate.display()
1623                );
1624            }
1625            if mode & 0o022 != 0 && !sticky {
1626                anyhow::bail!(
1627                    "refusing to start: {component_name} ancestor {} for {env_name} is mode \
1628                     {:04o} — writable by group or other without the sticky bit, so another \
1629                     local user could rename the next path component and re-root the {path_name}. \
1630                     Point {env_name} somewhere trusted end to end, or unset it for the default.",
1631                    candidate.display(),
1632                    mode & 0o7777
1633                );
1634            }
1635            resolved = candidate;
1636            continue;
1637        }
1638
1639        anyhow::bail!(
1640            "refusing to start: {component_name} component {} for {env_name} is neither a \
1641             directory nor a symlink — the {path_name} cannot traverse it.",
1642            candidate.display()
1643        );
1644    }
1645
1646    Ok(())
1647}
1648
1649/// Run the daemon using a startup lock acquired by the caller *before*
1650/// building `dispatcher`, so a second process racing to boot (e.g. two
1651/// `kkernel mcp --daemon` spawns before either has bound its socket) cannot
1652/// run migrations/FTS DDL concurrently against the same database file.
1653/// `boot_guard` is only `None` on non-unix targets, where there is no
1654/// advisory boot lock to hold in the first place; every unix daemon-mode
1655/// caller passes `Some`.
1656///
1657/// The guard is held across cleanup → pid-claim → bind, then dropped. The
1658/// caller must not still be holding a *different* handle to the same lock
1659/// file when this function is entered — see the "Deadlock note" on the
1660/// `_startup_lock` binding below for why that would self-deadlock on `flock`.
1661#[cfg(unix)]
1662pub async fn run_daemon_with_boot_guard<D: DaemonDispatch>(
1663    dispatcher: D,
1664    boot_guard: Option<std::fs::File>,
1665) -> anyhow::Result<()> {
1666    run_daemon_with_boot_guard_inner(
1667        dispatcher,
1668        boot_guard,
1669        false,
1670        DaemonOptions::default(),
1671        |_| DaemonStartupReport::default(),
1672    )
1673    .await
1674}
1675
1676/// Run the daemon and start host-owned background work only after the socket
1677/// is bound, permissions are restricted, and this process owns the PID file.
1678/// The callback runs once while the startup lock and teardown guard are held;
1679/// setup failures never invoke it. Work started by the callback must use the
1680/// daemon shutdown token and tracked-task drain contract.
1681#[cfg(unix)]
1682pub async fn run_daemon_with_boot_guard_and_start<D, F>(
1683    dispatcher: D,
1684    boot_guard: Option<std::fs::File>,
1685    start: F,
1686) -> anyhow::Result<()>
1687where
1688    D: DaemonDispatch,
1689    F: FnOnce(&D) + Send,
1690{
1691    run_daemon_with_options_and_boot_guard_and_start(
1692        dispatcher,
1693        boot_guard,
1694        DaemonOptions::default(),
1695        |dispatcher| {
1696            start(dispatcher);
1697            DaemonStartupReport::default()
1698        },
1699    )
1700    .await
1701}
1702
1703/// Start with an explicit launch mode and collect the host's startup inventory.
1704#[cfg(unix)]
1705pub async fn run_daemon_with_options_and_boot_guard_and_start<D, F>(
1706    dispatcher: D,
1707    boot_guard: Option<std::fs::File>,
1708    options: DaemonOptions,
1709    start: F,
1710) -> anyhow::Result<()>
1711where
1712    D: DaemonDispatch,
1713    F: FnOnce(&D) -> DaemonStartupReport + Send,
1714{
1715    anyhow::ensure!(
1716        !options.idle_interval.is_zero(),
1717        "daemon idle interval must be positive"
1718    );
1719    run_daemon_with_boot_guard_inner(dispatcher, boot_guard, false, options, start).await
1720}
1721
1722#[cfg(unix)]
1723async fn run_daemon_with_boot_guard_inner<D, F>(
1724    dispatcher: D,
1725    boot_guard: Option<std::fs::File>,
1726    allow_same_process_incumbent: bool,
1727    options: DaemonOptions,
1728    start: F,
1729) -> anyhow::Result<()>
1730where
1731    D: DaemonDispatch,
1732    F: FnOnce(&D) -> DaemonStartupReport + Send,
1733{
1734    // Cancel on every exit, including setup failure and unwinding from the
1735    // post-ownership startup callback. The guard precedes all fallible work
1736    // so even a process that never becomes the daemon relinquishes its
1737    // process-lifetime shutdown token; restarting requires exec.
1738    struct ComponentTeardown;
1739    impl Drop for ComponentTeardown {
1740        fn drop(&mut self) {
1741            daemon_shutdown_token().cancel();
1742        }
1743    }
1744    let _component_teardown = ComponentTeardown;
1745
1746    // Placed after the teardown guard so this refusal keeps the ADR-119
1747    // contract every other pre-bind error path has, and before the paths are
1748    // resolved so a split rendezvous never reaches cleanup/bind/pid-write.
1749    ensure_rendezvous_overrides_paired()?;
1750
1751    let sock = socket_path();
1752    let pid_file = pid_path();
1753    let socket_parent = sock.parent();
1754    let pid_parent = pid_file.parent();
1755
1756    if let Some(parent) = socket_parent {
1757        std::fs::create_dir_all(parent)?;
1758        ensure_socket_dir_is_trusted(parent)?;
1759    }
1760    // Identical parent paths traverse the same components, so the socket
1761    // check above also vets the PID-file parent. Aliased paths are checked
1762    // independently because each original path is traversed by file access.
1763    if pid_parent != socket_parent {
1764        ensure_pid_file_dir_is_trusted(&pid_file)?;
1765    }
1766
1767    // Hold the startup lock across cleanup → pid-claim → bind so a concurrent
1768    // client's kill_and_respawn (which also holds this lock) cannot remove the
1769    // rendezvous paths during setup. The PID file's own lock is retained after
1770    // this shared startup lock is released, including while the listener drains
1771    // during shutdown.
1772    //
1773    // Deadlock note: the client holds this lock only during kill+spawn and
1774    // releases it before the spawned daemon process starts (the lock guard is
1775    // dropped when kill_and_respawn returns, before the readiness probe loop).
1776    // The daemon holds exactly one handle to this lock for its whole boot
1777    // sequence (received as `boot_guard`, extended from before `dispatcher`
1778    // was constructed) — never a second, independently-acquired handle in the
1779    // same process, which would self-deadlock on `flock`.
1780    let _startup_lock = boot_guard;
1781
1782    // A second daemon must refuse loudly rather than exit successfully while
1783    // another daemon owns this rendezvous. Only `Stale` lets the caller proceed.
1784    match cleanup_stale_daemon(
1785        &sock,
1786        &pid_file,
1787        allow_same_process_incumbent,
1788        dispatcher.config_id(),
1789    )
1790    .await
1791    {
1792        Incumbent::Serving(incumbent_pid) => {
1793            tracing::error!(
1794                pid = incumbent_pid,
1795                socket = ?sock,
1796                "refusing to start: a khived instance is already serving this socket"
1797            );
1798            anyhow::bail!(
1799                "refusing to start: khived is already running as pid {incumbent_pid}, \
1800                 serving socket {}. Stop that instance first if you intend to replace it.",
1801                sock.display()
1802            );
1803        }
1804        Incumbent::Live(incumbent_pid) => {
1805            tracing::error!(
1806                pid = incumbent_pid,
1807                socket = ?sock,
1808                "refusing to start: a live process owns the PID file but no khived answered"
1809            );
1810            anyhow::bail!(
1811                "refusing to start: pid {incumbent_pid} owns the daemon PID file and is alive, \
1812                 but nothing answered the khived protocol on {}. It may be draining. Nothing \
1813                 was removed; stop that process first if you intend to replace it.",
1814                sock.display()
1815            );
1816        }
1817        Incumbent::Stale => {}
1818    }
1819
1820    // Register the sender recorder before the shutdown streams below. The
1821    // signal registry runs a signal's actions in registration order, so the
1822    // recorder stores the sender before tokio's action wakes the shutdown
1823    // path; registered after it, the exit record can say sender_pid=unknown.
1824    if let Err(error) = exit_record::capture_signal_senders() {
1825        tracing::warn!(target: "khive.boot", error = %error, "signal sender capture unavailable; the exit record will say sender_pid=unknown");
1826    }
1827    // Install signal streams before publishing either rendezvous file. A
1828    // supervisor may stop us as soon as connect/pid checks succeed, before
1829    // the accept loop or shutdown future has been polled. Keep these streams
1830    // alive so a signal during the rest of startup reaches normal cleanup.
1831    let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
1832    let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
1833
1834    let pid_file_guard = match write_pid_file_exclusive(&pid_file) {
1835        Ok(guard) => guard,
1836        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1837            // A PID file appeared between cleanup and our claim. Never touch
1838            // the winner's files; defer only if it already answers as khived.
1839            if pid_file_names_a_reachable_daemon(
1840                &pid_file,
1841                &sock,
1842                allow_same_process_incumbent,
1843                dispatcher.config_id(),
1844            )
1845            .await
1846            {
1847                tracing::info!(
1848                    "a replacement khived already claimed the pid/socket rendezvous; exiting"
1849                );
1850                return Ok(());
1851            }
1852            anyhow::bail!(
1853                "failed to claim daemon pid file at {pid_file:?}: it already exists \
1854                 and does not name a reachable daemon"
1855            );
1856        }
1857        Err(e) => return Err(e.into()),
1858    };
1859    // After the claim, never before: only the PID file's owner writes the record.
1860    let exit_record = exit_record::ExitRecord::claim(&pid_file);
1861
1862    let listener = match UnixListener::bind(&sock) {
1863        Ok(listener) => listener,
1864        Err(e) => {
1865            exit_record.exiting(exit_record::ExitReason::Error);
1866            remove_pid_file_if_owned(&pid_file, &pid_file_guard);
1867            return Err(e.into());
1868        }
1869    };
1870    // Fail closed, same reason as the directory above. If this chmod fails the
1871    // socket is world-reachable in a way the accepted design never covered, so
1872    // the bound listener is dropped and the entry removed rather than served.
1873    if let Err(e) = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)) {
1874        drop(listener);
1875        let _ = std::fs::remove_file(&sock);
1876        exit_record.exiting(exit_record::ExitReason::Error);
1877        remove_pid_file_if_owned(&pid_file, &pid_file_guard);
1878        return Err(anyhow::anyhow!(
1879            "refusing to start: cannot chmod 0600 {}: {e}. The daemon socket must be owner-only \
1880             — it is half of the single-principal guarantee this daemon enforces.",
1881            sock.display()
1882        ));
1883    }
1884    // Captured while still holding the startup lock, immediately after
1885    // bind, so shutdown cleanup can later prove "this is still the same socket
1886    // I bound" rather than trusting the path alone.
1887    let bound_identity = socket_identity(&sock);
1888
1889    let lifecycle = Arc::new(DaemonLifecycle::new(options, start(&dispatcher)));
1890
1891    // Release the shared startup lock now that the listener is bound. The
1892    // locked PID file continues to identify this daemon through shutdown.
1893    drop(_startup_lock);
1894    tracing::info!(
1895        socket = ?sock,
1896        pid = std::process::id(),
1897        source_revision = crate::BUILD_INFO.source_revision,
1898        build_time = crate::BUILD_INFO.build_time,
1899        "khived listening"
1900    );
1901
1902    {
1903        let warm = dispatcher.clone();
1904        track_named_background_task("daemon_warmup", async move {
1905            warm.warm_all().await;
1906        });
1907    }
1908
1909    // The checkpoint task's own strong-count-based exit is unreachable
1910    // whenever `event_store_for_checkpoint()` returns `Some` (the ordinary
1911    // production shape), because the `SqlEventStore` it wraps retains its
1912    // own clone of the same pool. An explicit watch channel replaces that
1913    // mechanism: the sender is held for the remainder of this function's
1914    // scope and signalled as the first action once shutdown is observed,
1915    // below.
1916    let (checkpoint_shutdown_tx, checkpoint_shutdown_rx) = tokio::sync::watch::channel(());
1917    // ADR-091 Amendment 3: one checkpoint task per file-backed backend the
1918    // dispatcher wired — the primary pool plus every entry
1919    // `secondary_pools_for_checkpoint` returns — sharing this one shutdown
1920    // channel (the sender broadcasts to every receiver clone), so the single
1921    // send below stops every spawned task before `drain()`.
1922    let checkpoint_tasks = checkpoint_task_specs(
1923        dispatcher.pool_for_checkpoint(),
1924        dispatcher.secondary_pools_for_checkpoint(),
1925        dispatcher.event_store_for_checkpoint(),
1926        dispatcher.namespace().to_string(),
1927    );
1928    if !checkpoint_tasks.is_empty() {
1929        let cfg = CheckpointConfig::from_env();
1930        let checkpoint_task_count = checkpoint_tasks.len();
1931        for task in checkpoint_tasks {
1932            track_named_background_task(
1933                "wal_checkpoint",
1934                run_checkpoint_task(
1935                    task.pool,
1936                    cfg.clone(),
1937                    task.lifecycle_owner,
1938                    checkpoint_shutdown_rx.clone(),
1939                    task.is_main,
1940                ),
1941            );
1942        }
1943        tracing::info!(checkpoint_task_count, "WAL checkpoint task(s) started");
1944    }
1945
1946    let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1947    let connection_tasks = Arc::new(std::sync::Mutex::new(
1948        Vec::<tokio::task::JoinHandle<()>>::new(),
1949    ));
1950    let (request_shutdown_tx, request_shutdown_rx) = tokio::sync::watch::channel(false);
1951
1952    let shutdown = async {
1953        let signal = tokio::select! {
1954            _ = sigterm.recv() => "SIGTERM",
1955            _ = sigint.recv() => "SIGINT",
1956        };
1957        tracing::info!("received {signal}");
1958        // Tokio retains its process-wide handlers after the streams are dropped.
1959        // This daemon cannot restart without exec; a repeat signal must terminate
1960        // even if shutdown is blocked in synchronous recovery-lock acquisition.
1961        for signal in [libc::SIGTERM, libc::SIGINT] {
1962            // SAFETY: setting SIG_DFL for these valid signals needs no handler
1963            // pointer or shared Rust state and applies to the whole process.
1964            if unsafe { libc::signal(signal, libc::SIG_DFL) } == libc::SIG_ERR {
1965                return Err(std::io::Error::last_os_error());
1966            }
1967        }
1968        Ok::<&'static str, std::io::Error>(signal)
1969    };
1970    tokio::pin!(shutdown);
1971    lifecycle.ready();
1972
1973    // SAFETY: `geteuid` is always successful and takes no arguments.
1974    let daemon_euid = unsafe { libc::geteuid() } as u32;
1975
1976    let reason = tokio::select! {
1977        _ = async {
1978            let mut accept_error_backoff = None;
1979            let mut last_accept_error_log: Option<std::time::Instant> = None;
1980            loop {
1981                match listener.accept().await {
1982                    Ok((mut stream, _)) => {
1983                        let initial_frame_deadline =
1984                            tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT;
1985                        accept_error_backoff = None;
1986                        last_accept_error_log = None;
1987                        // Refuse a foreign uid before any frame is read.
1988                        // Fails CLOSED: an error reading peer credentials is
1989                        // "cannot prove same-uid", which is the same answer as
1990                        // "is not same-uid" — never a pass.
1991                        //
1992                        // Scope: this is a same-UID check, which is strictly
1993                        // weaker than same-PRINCIPAL. Several distinct actors
1994                        // running under one uid all pass here, so this does not
1995                        // by itself establish that the daemon serves a single
1996                        // principal, and nothing downstream refuses or degrades
1997                        // on observing more than one. Do not describe it as a
1998                        // multi-principal guard — it bounds the process boundary,
1999                        // not the identity one.
2000                        match peer_uid(&stream) {
2001                            Ok(peer) if uid_is_permitted(peer, daemon_euid) => {}
2002                            Ok(peer) => {
2003                                tracing::error!(
2004                                    peer_uid = peer,
2005                                    daemon_euid,
2006                                    "refusing connection from a foreign uid: this daemon accepts \
2007                                     only peers running as its own uid"
2008                                );
2009                                drop(stream);
2010                                continue;
2011                            }
2012                            Err(e) => {
2013                                tracing::error!(
2014                                    error = %e,
2015                                    "refusing connection: cannot read peer credentials, so \
2016                                     same-uid cannot be proven"
2017                                );
2018                                drop(stream);
2019                                continue;
2020                            }
2021                        }
2022                        // One permit per connection, taken before the task is
2023                        // spawned. Past the cap the peer is answered with a busy
2024                        // error and the stream is dropped; connections already
2025                        // admitted keep their permits and are not affected.
2026                        let Some(permit) = admit_or_refuse_busy(
2027                            &lifecycle.connections,
2028                            &mut stream,
2029                            dispatcher.config_id(),
2030                        )
2031                        .await
2032                        else {
2033                            continue;
2034                        };
2035                        // Keep the acceptance-time deadline across the
2036                        // credential check and connection-task scheduling.
2037                        let d = dispatcher.clone();
2038                        let shutdown = request_shutdown_rx.clone();
2039                        let lifecycle = Arc::clone(&lifecycle);
2040                        let handle = spawn_connection_task(Arc::clone(&active), async move {
2041                            // Released when the handler ends, whether it returns,
2042                            // panics or is aborted at shutdown.
2043                            let _permit = permit;
2044                            handle_conn_with_lifecycle(
2045                                stream,
2046                                d,
2047                                Some(shutdown),
2048                                initial_frame_deadline,
2049                                Some(lifecycle),
2050                            )
2051                            .await;
2052                        });
2053                        let mut tasks = connection_tasks
2054                            .lock()
2055                            .unwrap_or_else(std::sync::PoisonError::into_inner);
2056                        tasks.retain(|task| !task.is_finished());
2057                        tasks.push(handle);
2058                    }
2059                    Err(e) => {
2060                        let delay = next_accept_error_backoff(accept_error_backoff);
2061                        accept_error_backoff = Some(delay);
2062                        let capacity_exhausted = matches!(
2063                            e.raw_os_error(),
2064                            Some(libc::EMFILE) | Some(libc::ENFILE)
2065                        );
2066                        if last_accept_error_log.is_none_or(|last| {
2067                            last.elapsed() >= std::time::Duration::from_secs(30)
2068                        }) {
2069                            tracing::error!(
2070                                error = %e,
2071                                capacity_exhausted,
2072                                retry_ms = delay.as_millis(),
2073                                "daemon accept failed; retrying with bounded backoff"
2074                            );
2075                            last_accept_error_log = Some(std::time::Instant::now());
2076                        }
2077                        tokio::time::sleep(delay).await;
2078                    }
2079                }
2080            }
2081        } => DaemonShutdownReason::Signal,
2082        result = &mut shutdown => {
2083            exit_record.exiting(exit_record::ExitReason::Signal(result?));
2084            DaemonShutdownReason::Signal
2085        },
2086        _ = wait_for_idle(&dispatcher, &lifecycle) => {
2087            exit_record.exiting(exit_record::ExitReason::Idle);
2088            DaemonShutdownReason::Idle
2089        },
2090    };
2091
2092    lifecycle.draining(reason);
2093
2094    // A listening backlog is not admitted work. Close it before draining so
2095    // new clients cannot finish writing to a socket nobody will accept.
2096    drop(listener);
2097
2098    // Signal the checkpoint task to exit before draining, so `drain()`
2099    // actually waits on it via `track_background_task` rather than the
2100    // task outliving the drain window (or the process) unsignalled.
2101    let _ = checkpoint_shutdown_tx.send(());
2102
2103    // Per-run signal: read scopes stop promptly, admitted writes ignore it and
2104    // retain the rest of the configured drain window to commit or roll back.
2105    if reason == DaemonShutdownReason::Signal {
2106        let _ = request_shutdown_tx.send(true);
2107    }
2108
2109    // Same ordering contract for ADR-119 daemon components: cancel before
2110    // drain, so each component's supervisor (itself a tracked task) can run
2111    // its bounded shutdown inside the drain wait.
2112    daemon_shutdown_token().cancel();
2113
2114    let drained = if reason == DaemonShutdownReason::Idle {
2115        tokio::select! {
2116            _ = drain_for_idle(&active, drain_timeout()) => true,
2117            result = &mut shutdown => {
2118                exit_record.exiting(exit_record::ExitReason::Signal(result?));
2119                lifecycle.draining(DaemonShutdownReason::Signal);
2120                let _ = request_shutdown_tx.send(true);
2121                drain(&active).await
2122            }
2123        }
2124    } else {
2125        drain(&active).await
2126    };
2127    let tasks = {
2128        let mut retained = connection_tasks
2129            .lock()
2130            .unwrap_or_else(std::sync::PoisonError::into_inner);
2131        std::mem::take(&mut *retained)
2132    };
2133    finish_connection_tasks(tasks, drained).await;
2134
2135    // A concurrent client's `kill_and_respawn` may have already decided
2136    // this daemon looked stale, killed it, and spawned a replacement that
2137    // bound the same socket/PID paths while this daemon was draining above.
2138    // Reacquire the recovery lock (the same one that serializes startup) and
2139    // only unlink if the PID file still names this process AND the socket at
2140    // `sock` is still the exact one this daemon bound — otherwise a
2141    // replacement daemon owns those paths now and unlinking would delete its
2142    // live socket/PID out from under it.
2143    match acquire_recovery_lock() {
2144        Some(_shutdown_lock) => {
2145            shutdown_cleanup_if_owned(&sock, &pid_file, bound_identity);
2146        }
2147        None => {
2148            tracing::warn!(
2149                "could not acquire recovery lock for shutdown cleanup; \
2150                 skipping unlink to avoid deleting a replacement daemon's paths"
2151            );
2152        }
2153    }
2154    lifecycle.stopped();
2155    tracing::info!("khived stopped");
2156    Ok(())
2157}
2158
2159/// Remove `sock`/`pid_file` only if they still belong to this process: the PID
2160/// file must name `std::process::id()` AND the socket currently at `sock` must
2161/// still be the exact one identified by `bound_identity` (dev/ino, not path).
2162///
2163/// Returns `true` if cleanup ran, `false` if it was skipped because a
2164/// replacement daemon already owns those paths. The caller must hold
2165/// the recovery lock across this call — the same lock daemon startup holds
2166/// across cleanup+bind+pid-write — so no replacement can bind between this
2167/// function's checks and its unlinks.
2168#[cfg(unix)]
2169fn shutdown_cleanup_if_owned(
2170    sock: &std::path::Path,
2171    pid_file: &std::path::Path,
2172    bound_identity: Option<SocketIdentity>,
2173) -> bool {
2174    let pid_is_ours = std::fs::read_to_string(pid_file)
2175        .ok()
2176        .and_then(|s| s.trim().parse::<u32>().ok())
2177        == Some(std::process::id());
2178    let socket_is_ours = bound_identity.is_some() && socket_identity(sock) == bound_identity;
2179    if pid_is_ours && socket_is_ours {
2180        let _ = std::fs::remove_file(sock);
2181        let _ = std::fs::remove_file(pid_file);
2182        true
2183    } else {
2184        tracing::warn!(
2185            socket = ?sock,
2186            pid_file = ?pid_file,
2187            "skipping shutdown cleanup — a replacement daemon already owns this socket/PID"
2188        );
2189        false
2190    }
2191}
2192
2193// ── helpers ───────────────────────────────────────────────────────────────────
2194
2195/// Liveness verdict for a `kill(pid, 0)` probe.
2196#[cfg(unix)]
2197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2198enum PidLiveness {
2199    /// errno 0 — signal delivery succeeded, the process exists and this
2200    /// caller may signal it.
2201    Alive,
2202    /// ESRCH (or any other non-EPERM errno) — no such process.
2203    Dead,
2204    /// EPERM — the process exists but this caller lacks permission to
2205    /// signal it. Unknown-safe: treated as running so stale-daemon cleanup
2206    /// never unlinks a live daemon's socket/PID file just because it is
2207    /// owned by a different user/uid.
2208    PermissionDenied,
2209}
2210
2211#[cfg(unix)]
2212impl PidLiveness {
2213    fn is_running(self) -> bool {
2214        !matches!(self, PidLiveness::Dead)
2215    }
2216}
2217
2218/// Maps a `kill(pid, 0)` outcome (return code + errno) to a [`PidLiveness`].
2219/// Pure and side-effect-free so the errno mapping can be unit tested without
2220/// a real process probe.
2221#[cfg(unix)]
2222fn classify_kill_result(rc: i32, errno: i32) -> PidLiveness {
2223    if rc == 0 {
2224        return PidLiveness::Alive;
2225    }
2226    match errno {
2227        libc::EPERM => PidLiveness::PermissionDenied,
2228        _ => PidLiveness::Dead,
2229    }
2230}
2231
2232#[cfg(unix)]
2233fn is_process_running(pid: u32) -> bool {
2234    let Ok(pid) = i32::try_from(pid) else {
2235        return false;
2236    };
2237    if pid <= 0 {
2238        return false;
2239    }
2240    // SAFETY: signal 0 is an existence/permission probe with no side effects.
2241    let rc = unsafe { libc::kill(pid, 0) };
2242    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
2243    classify_kill_result(rc, errno).is_running()
2244}
2245
2246/// Whether a PID may identify an incumbent from this candidate's point of view.
2247///
2248/// Production rejects the current PID so a stale rendezvous left by a prior
2249/// process whose PID was reused cannot protect an unrelated socket. The
2250/// in-process daemon harness opts in to the same-PID case because all of its
2251/// otherwise independent boot candidates necessarily share one OS process.
2252#[cfg(unix)]
2253fn pid_can_name_incumbent(pid: u32, current_pid: u32, allow_same_process_incumbent: bool) -> bool {
2254    allow_same_process_incumbent || pid != current_pid
2255}
2256
2257/// Bounded timeout for the protocol-identity probe used by duplicate-daemon
2258/// detection. Short enough that a hung or foreign listener does not stall
2259/// startup; long enough for a live khived under normal load to answer a
2260/// `probe_only` frame.
2261#[cfg(unix)]
2262const DUPLICATE_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
2263
2264/// Whether the listener at `sock` actually speaks the khived wire protocol
2265/// **as a khived this process can defer to** — identified by a configuration
2266/// compatible with `expected_config_id`.
2267///
2268/// A live PID plus an accepting Unix socket is not proof of khived: any
2269/// unrelated process that happens to have bound the same path also answers
2270/// `connect()`. Nor is any well-formed [`DaemonResponseFrame`] proof: a
2271/// `config_mismatch`/`version_mismatch` response, a `metrics_only` snapshot
2272/// response, or a legacy pre-probe daemon that falls through to normal
2273/// dispatch on the empty `ops` string, all deserialize cleanly without being
2274/// the unambiguous "yes, alive and identity-matching" answer this check
2275/// needs — the daemon's `metrics_only` arm in particular echoes the same
2276/// `ok=true, result=None, error=None`, all-mismatch-flags-false, matching
2277/// protocol version and `served_config_id` shape as the probe-ack arm, and
2278/// is distinguished only by carrying `metrics: Some(...)`. This sends a
2279/// bounded `probe_only` frame (the same identity probe the client-side
2280/// recovery path uses, `crates/khive-mcp/src/daemon.rs::probe_daemon_identity`)
2281/// carrying this process's own `config_id`, and requires a compatible
2282/// probe-branch shape back: `ok=true`, `result=None`, `error=None`,
2283/// `metrics=None`, `request_id=None` (this probe frame never sets one), no
2284/// mismatch flags, matching protocol version, and matching
2285/// `served_config_id` — mirroring the client probe's `is_probe_ack` check so
2286/// both sides of the protocol agree on what "alive" means. Connect, write,
2287/// and read are all inside the one bounded timeout: `UnixStream::connect`
2288/// itself awaits write readiness, so a listener with a saturated accept
2289/// backlog could otherwise hold this call open past the advertised bound.
2290/// A connect that succeeds but never answers, times out, or answers with
2291/// non-protocol bytes, a mismatched identity, a `metrics_only` snapshot, or
2292/// any other non-probe-shaped response is not treated as the same khived
2293/// and falls through to the stale-socket recovery path instead.
2294#[cfg(unix)]
2295async fn socket_speaks_khived_protocol(sock: &std::path::Path, expected_config_id: &str) -> bool {
2296    let probe = DaemonRequestFrame {
2297        probe_only: true,
2298        protocol_version: PROTOCOL_VERSION,
2299        config_id: expected_config_id.to_string(),
2300        ..Default::default()
2301    };
2302    let Ok(payload) = serde_json::to_vec(&probe) else {
2303        return false;
2304    };
2305    let response = tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, async {
2306        let mut stream = UnixStream::connect(sock).await.ok()?;
2307        write_frame(&mut stream, &payload).await.ok()?;
2308        let raw = read_frame(&mut stream).await.ok()?;
2309        serde_json::from_slice::<DaemonResponseFrame>(&raw).ok()
2310    })
2311    .await
2312    .ok()
2313    .flatten();
2314
2315    let Some(resp) = response else {
2316        return false;
2317    };
2318    let is_probe_ack = resp.ok
2319        && resp.result.is_none()
2320        && resp.error.is_none()
2321        && resp.metrics.is_none()
2322        && resp.request_id.is_none();
2323    is_probe_ack
2324        && !resp.version_mismatch
2325        && !resp.namespace_mismatch
2326        && !resp.config_mismatch
2327        && resp.daemon_protocol_version == PROTOCOL_VERSION
2328        && resp
2329            .served_config_id
2330            .as_deref()
2331            .is_some_and(|served| config_ids_compatible(expected_config_id, served))
2332}
2333
2334/// Whether connecting to an existing socket path is definitely unreachable.
2335/// Timeouts and other errors remain ambiguous so cleanup fails closed.
2336#[cfg(unix)]
2337async fn socket_is_unreachable(sock: &std::path::Path) -> bool {
2338    match tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, UnixStream::connect(sock)).await {
2339        Ok(Err(error)) => matches!(
2340            error.kind(),
2341            std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
2342        ),
2343        _ => false,
2344    }
2345}
2346
2347/// What owns the daemon PID file, from the point of view of a process that wants
2348/// to start. A live owner is never cleaned up: a draining incumbent closes its
2349/// listener before it releases writers, so an unanswered socket is ambiguous and
2350/// deleting its PID file is how two daemons end up on one store.
2351#[cfg(unix)]
2352enum Incumbent {
2353    /// A live process that answered the khived protocol on the socket.
2354    Serving(u32),
2355    /// A live PID still has an active or ambiguous rendezvous. Nothing removed.
2356    Live(u32),
2357    /// Nothing live owns the store; the socket and PID file were removed.
2358    Stale,
2359}
2360
2361/// Check whether `pid_file`/`sock` already name a live daemon and, if not,
2362/// remove the stale rendezvous files so the caller may bind fresh.
2363///
2364/// A live protocol responder, reachable socket, or live holder of the PID-file
2365/// lock means the caller must refuse to start. An unlocked PID with no listener
2366/// is a reused PID and may be reclaimed.
2367#[cfg(unix)]
2368async fn cleanup_stale_daemon(
2369    sock: &std::path::Path,
2370    pid_file: &std::path::Path,
2371    allow_same_process_incumbent: bool,
2372    expected_config_id: &str,
2373) -> Incumbent {
2374    let mut stale_pid_file_guard = None;
2375    if let Ok(pid_str) = std::fs::read_to_string(pid_file) {
2376        if let Ok(pid) = pid_str.trim().parse::<u32>() {
2377            if pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
2378                && is_process_running(pid)
2379            {
2380                if sock.exists() && socket_speaks_khived_protocol(sock, expected_config_id).await {
2381                    return Incumbent::Serving(pid);
2382                }
2383                if sock.exists() && !socket_is_unreachable(sock).await {
2384                    return Incumbent::Live(pid);
2385                }
2386                match try_acquire_pid_file_lock(pid_file) {
2387                    Ok(Some(guard)) => stale_pid_file_guard = Some(guard),
2388                    Ok(None) => return Incumbent::Live(pid),
2389                    Err(e) => {
2390                        tracing::warn!(
2391                            error = %e,
2392                            path = ?pid_file,
2393                            "cannot check daemon PID-file lock"
2394                        );
2395                        return Incumbent::Live(pid);
2396                    }
2397                }
2398            }
2399        }
2400    }
2401    if sock.exists() {
2402        if let Err(e) = std::fs::remove_file(sock) {
2403            tracing::warn!(error = %e, path = ?sock, "failed to remove stale socket");
2404        }
2405    }
2406    if pid_file.exists() {
2407        if let Err(e) = std::fs::remove_file(pid_file) {
2408            tracing::warn!(error = %e, path = ?pid_file, "failed to remove stale PID file");
2409        }
2410    }
2411    drop(stale_pid_file_guard);
2412    Incumbent::Stale
2413}
2414
2415/// Create and lock `pid_file` exclusively (`O_EXCL`) and write this process's PID.
2416///
2417/// Uses `create_new(true)` rather than `create(true).truncate(true)` so
2418/// this can never silently overwrite a PID file another process created —
2419/// the held file lock also identifies a starting or draining daemon when its
2420/// socket is not yet reachable.
2421#[cfg(unix)]
2422fn write_pid_file_exclusive(pid_file: &std::path::Path) -> std::io::Result<std::fs::File> {
2423    use std::os::unix::fs::OpenOptionsExt;
2424
2425    let mut opts = std::fs::OpenOptions::new();
2426    opts.write(true).create_new(true).mode(0o600);
2427    let mut f = opts.open(pid_file)?;
2428    // SAFETY: flock is a POSIX advisory lock with no memory side effects.
2429    let rc = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
2430    if rc != 0 {
2431        return Err(std::io::Error::last_os_error());
2432    }
2433    f.write_all(std::process::id().to_string().as_bytes())?;
2434    Ok(f)
2435}
2436
2437/// Try to lock an existing PID file without creating it. `Some(file)` means
2438/// there is no daemon lock holder; `None` means a daemon still owns the file.
2439#[cfg(unix)]
2440fn try_acquire_pid_file_lock(pid_file: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
2441    let file = std::fs::OpenOptions::new()
2442        .read(true)
2443        .write(true)
2444        .open(pid_file)?;
2445    // SAFETY: flock is a POSIX advisory lock with no memory side effects.
2446    let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
2447    if rc == 0 {
2448        return Ok(Some(file));
2449    }
2450    let error = std::io::Error::last_os_error();
2451    if error.kind() == std::io::ErrorKind::WouldBlock
2452        || error.raw_os_error() == Some(libc::EWOULDBLOCK)
2453    {
2454        Ok(None)
2455    } else {
2456        Err(error)
2457    }
2458}
2459
2460/// Remove the PID file on setup failure only while its path still names the
2461/// file this start attempt created.
2462#[cfg(unix)]
2463fn remove_pid_file_if_owned(pid_file: &std::path::Path, guard: &std::fs::File) {
2464    let Ok(owned) = guard.metadata() else {
2465        return;
2466    };
2467    let Ok(current) = std::fs::metadata(pid_file) else {
2468        return;
2469    };
2470    if owned.dev() == current.dev() && owned.ino() == current.ino() {
2471        if let Err(e) = std::fs::remove_file(pid_file) {
2472            tracing::warn!(error = %e, path = ?pid_file, "failed to remove unbound PID file");
2473        }
2474    }
2475}
2476
2477/// Return `true` if `pid_file` currently names an eligible live process that
2478/// still answers on `sock` — i.e. a daemon already owns this rendezvous and it
2479/// is safe to defer to it rather than treat the `AlreadyExists` PID-file
2480/// collision as a boot failure. Eligibility requires a different PID in
2481/// production; the explicit in-process harness may allow the current PID.
2482#[cfg(unix)]
2483async fn pid_file_names_a_reachable_daemon(
2484    pid_file: &std::path::Path,
2485    sock: &std::path::Path,
2486    allow_same_process_incumbent: bool,
2487    expected_config_id: &str,
2488) -> bool {
2489    let Ok(pid_str) = std::fs::read_to_string(pid_file) else {
2490        return false;
2491    };
2492    let Ok(pid) = pid_str.trim().parse::<u32>() else {
2493        return false;
2494    };
2495    pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
2496        && is_process_running(pid)
2497        && sock.exists()
2498        && socket_speaks_khived_protocol(sock, expected_config_id).await
2499}
2500
2501#[cfg(unix)]
2502async fn drain(active: &std::sync::atomic::AtomicUsize) -> bool {
2503    drain_with_timeout(active, drain_timeout()).await
2504}
2505
2506/// Voluntary retirement keeps admitted workers and rendezvous ownership alive.
2507#[cfg(unix)]
2508async fn drain_for_idle(active: &std::sync::atomic::AtomicUsize, timeout: std::time::Duration) {
2509    let deadline = tokio::time::Instant::now() + timeout;
2510    let mut warned = false;
2511    while active.load(std::sync::atomic::Ordering::SeqCst) + background_task_count() != 0 {
2512        if !warned && tokio::time::Instant::now() >= deadline {
2513            tracing::warn!(
2514                "idle drain interval elapsed; retaining workers and rendezvous until settled"
2515            );
2516            warned = true;
2517        }
2518        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2519    }
2520}
2521
2522#[cfg(unix)]
2523async fn drain_with_timeout(
2524    active: &std::sync::atomic::AtomicUsize,
2525    timeout: std::time::Duration,
2526) -> bool {
2527    use std::sync::atomic::Ordering;
2528    // One sequentially-consistent order spans connection handoff and tracked
2529    // task publication. Drain must not observe an ended connection and a
2530    // stale pre-publication background count as two simultaneous zeroes.
2531    let remaining = || active.load(Ordering::SeqCst) + background_task_count();
2532    if remaining() == 0 {
2533        return true;
2534    }
2535    let deadline = tokio::time::Instant::now() + timeout;
2536    while remaining() > 0 {
2537        if tokio::time::Instant::now() >= deadline {
2538            tracing::warn!(
2539                remaining_connections = active.load(Ordering::SeqCst),
2540                remaining_background_tasks = background_task_count(),
2541                outstanding_background_tasks = %background_task_names().join(", "),
2542                "drain timeout reached; forcing shutdown"
2543            );
2544            return false;
2545        }
2546        tokio::select! {
2547            _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
2548            _ = tokio::time::sleep_until(deadline) => {}
2549        }
2550    }
2551    true
2552}
2553
2554#[cfg(unix)]
2555async fn finish_connection_tasks(tasks: Vec<tokio::task::JoinHandle<()>>, drained: bool) {
2556    if !drained {
2557        for task in &tasks {
2558            if !task.is_finished() {
2559                task.abort();
2560            }
2561        }
2562    }
2563    for task in tasks {
2564        let _ = task.await;
2565    }
2566}
2567
2568/// The bound `drain()` waits for tracked background tasks at daemon shutdown
2569/// (`KHIVE_DRAIN_TIMEOUT_SECS`, default 10s). Public so component supervision
2570/// can clamp per-component shutdown timeouts against it — a component timeout
2571/// longer than the drain bound could never complete its abort/state
2572/// transition before the daemon returns.
2573pub fn drain_timeout() -> std::time::Duration {
2574    let secs = khive_db::env::env_parse_or("KHIVE_DRAIN_TIMEOUT_SECS", DEFAULT_DRAIN_TIMEOUT_SECS);
2575    std::time::Duration::from_secs(secs)
2576}
2577
2578/// Returns `true` for non-empty env values that are not `"0"` or `"false"`.
2579#[cfg(unix)]
2580pub fn env_truthy(key: &str) -> bool {
2581    std::env::var(key)
2582        .map(|v| {
2583            let v = v.trim();
2584            !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
2585        })
2586        .unwrap_or(false)
2587}
2588
2589include!("daemon_khive_root_tests.rs");
2590
2591/// Serve one already-admitted test connection through the production frame handler.
2592///
2593/// This seam owns no socket path, PID, boot guard, background components, or
2594/// process-wide shutdown state. The caller owns and joins the connection task.
2595/// It deliberately does not exercise listener admission or daemon lifecycle.
2596#[cfg(all(unix, any(test, feature = "test-internals")))]
2597#[doc(hidden)]
2598pub async fn serve_connection_for_test<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
2599    handle_conn_with_shutdown(
2600        stream,
2601        dispatcher,
2602        None,
2603        tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
2604    )
2605    .await;
2606}
2607
2608#[cfg(all(test, unix))]
2609#[path = "daemon_tests.rs"]
2610mod tests;