Skip to main content

khive_runtime/daemon/
lifecycle.rs

1use std::path::PathBuf;
2#[cfg(unix)]
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6
7#[cfg(all(doc, unix))]
8use super::{acquire_daemon_boot_guard, cleanup_stale_daemon};
9#[cfg(unix)]
10use super::{supervisor_marker_path, ConnectionAdmission};
11
12/// ADR-049 Amendment 11's disclosed initial demand idle interval.
13pub const DEFAULT_DEMAND_IDLE_SECS: u64 = 1_800;
14
15/// A launch-time choice, never inferred from process ancestry or environment.
16#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
17#[serde(rename_all = "snake_case")]
18pub enum DaemonLifetime {
19    Demand,
20    #[default]
21    Persistent,
22}
23
24/// Immutable daemon options. Existing entry points use persistent mode.
25#[derive(Debug, Clone, Copy)]
26pub struct DaemonOptions {
27    pub lifetime: DaemonLifetime,
28    pub idle_interval: std::time::Duration,
29}
30
31impl Default for DaemonOptions {
32    fn default() -> Self {
33        Self {
34            lifetime: DaemonLifetime::Persistent,
35            idle_interval: std::time::Duration::from_secs(DEFAULT_DEMAND_IDLE_SECS),
36        }
37    }
38}
39
40/// Host-owned startup decisions disclosed by lifecycle diagnostics.
41#[derive(Debug, Clone, Default)]
42pub struct DaemonStartupReport {
43    pub skipped_components: Vec<String>,
44    /// A named unknown inventory or service obligation prevents retirement.
45    pub idle_ineligible_reasons: Vec<String>,
46}
47
48#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
49#[serde(rename_all = "snake_case")]
50pub enum DaemonLifecyclePhase {
51    Serving,
52    Draining,
53    Stopped,
54}
55
56#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
57#[serde(rename_all = "snake_case")]
58pub enum DaemonShutdownReason {
59    Idle,
60    Signal,
61}
62
63/// Additive diagnostics for one daemon incarnation.
64#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
65pub struct DaemonLifecycleSnapshot {
66    pub lifetime: DaemonLifetime,
67    pub instance_generation: String,
68    pub effective_idle_interval_ms: u64,
69    pub phase: DaemonLifecyclePhase,
70    pub shutdown_reason: Option<DaemonShutdownReason>,
71    pub skipped_components: Vec<String>,
72    pub idle_ineligible_reasons: Vec<String>,
73    pub ordinary_requests: usize,
74    pub idle_blockers: Vec<String>,
75}
76
77#[cfg(unix)]
78pub(super) struct DaemonLifecycle {
79    pub(super) options: DaemonOptions,
80    state: std::sync::Mutex<DaemonLifecycleState>,
81    /// Admission for new connections on the daemon socket.
82    pub(super) connections: ConnectionAdmission,
83}
84
85#[cfg(unix)]
86struct DaemonLifecycleState {
87    snapshot: DaemonLifecycleSnapshot,
88    last_request_completion: Option<tokio::time::Instant>,
89}
90
91#[cfg(unix)]
92impl DaemonLifecycle {
93    pub(super) fn new(options: DaemonOptions, report: DaemonStartupReport) -> Self {
94        Self {
95            options,
96            state: std::sync::Mutex::new(DaemonLifecycleState {
97                snapshot: DaemonLifecycleSnapshot {
98                    lifetime: options.lifetime,
99                    instance_generation: uuid::Uuid::new_v4().to_string(),
100                    effective_idle_interval_ms: options
101                        .idle_interval
102                        .as_millis()
103                        .min(u128::from(u64::MAX))
104                        as u64,
105                    phase: DaemonLifecyclePhase::Serving,
106                    shutdown_reason: None,
107                    skipped_components: report.skipped_components,
108                    idle_ineligible_reasons: report.idle_ineligible_reasons,
109                    ordinary_requests: 0,
110                    idle_blockers: Vec::new(),
111                },
112                last_request_completion: None,
113            }),
114            connections: ConnectionAdmission::from_env(),
115        }
116    }
117
118    pub(super) fn snapshot(&self) -> DaemonLifecycleSnapshot {
119        self.state
120            .lock()
121            .unwrap_or_else(std::sync::PoisonError::into_inner)
122            .snapshot
123            .clone()
124    }
125
126    pub(super) fn ready(&self) {
127        self.state
128            .lock()
129            .unwrap_or_else(std::sync::PoisonError::into_inner)
130            .last_request_completion = Some(tokio::time::Instant::now());
131    }
132
133    pub(super) fn admit(self: &Arc<Self>) -> Option<OrdinaryRequestGuard> {
134        let mut state = self
135            .state
136            .lock()
137            .unwrap_or_else(std::sync::PoisonError::into_inner);
138        if state.snapshot.phase != DaemonLifecyclePhase::Serving {
139            return None;
140        }
141        state.snapshot.ordinary_requests += 1;
142        Some(OrdinaryRequestGuard(Arc::clone(self)))
143    }
144
145    /// The same mutex orders ordinary admission and the irreversible idle decision.
146    pub(super) fn try_idle(&self, blockers: impl FnOnce() -> Vec<String>) -> bool {
147        let mut state = self
148            .state
149            .lock()
150            .unwrap_or_else(std::sync::PoisonError::into_inner);
151        if self.options.lifetime != DaemonLifetime::Demand
152            || state.snapshot.phase != DaemonLifecyclePhase::Serving
153            || state.snapshot.ordinary_requests != 0
154            || !state.snapshot.idle_ineligible_reasons.is_empty()
155            || state
156                .last_request_completion
157                .is_none_or(|last| last.elapsed() < self.options.idle_interval)
158        {
159            return false;
160        }
161        state.snapshot.idle_blockers = blockers();
162        if !state.snapshot.idle_blockers.is_empty() {
163            return false;
164        }
165        state.snapshot.phase = DaemonLifecyclePhase::Draining;
166        state.snapshot.shutdown_reason = Some(DaemonShutdownReason::Idle);
167        true
168    }
169
170    pub(super) fn draining(&self, reason: DaemonShutdownReason) {
171        let mut state = self
172            .state
173            .lock()
174            .unwrap_or_else(std::sync::PoisonError::into_inner);
175        if state.snapshot.phase != DaemonLifecyclePhase::Stopped {
176            state.snapshot.phase = DaemonLifecyclePhase::Draining;
177            state.snapshot.shutdown_reason = Some(reason);
178        }
179    }
180
181    pub(super) fn stopped(&self) {
182        self.state
183            .lock()
184            .unwrap_or_else(std::sync::PoisonError::into_inner)
185            .snapshot
186            .phase = DaemonLifecyclePhase::Stopped;
187    }
188}
189
190#[cfg(unix)]
191pub(super) struct OrdinaryRequestGuard(Arc<DaemonLifecycle>);
192
193#[cfg(unix)]
194impl Drop for OrdinaryRequestGuard {
195    fn drop(&mut self) {
196        let mut state = self
197            .0
198            .state
199            .lock()
200            .unwrap_or_else(std::sync::PoisonError::into_inner);
201        state.snapshot.ordinary_requests -= 1;
202        // The guard covers response transport and cleanup, not just dispatch.
203        state.last_request_completion = Some(tokio::time::Instant::now());
204    }
205}
206
207// ── paths ─────────────────────────────────────────────────────────────────────
208
209/// Base `.khive` directory used to anchor every advisory lock/socket/pid
210/// path below. Pure path computation — portable on every target, even
211/// though most of its callers (socket/pid paths) are unix-only.
212///
213/// Resolution order: `HOME`, then `USERPROFILE` (the conventional Windows
214/// home variable), then a platform-specific last resort. On non-unix targets
215/// the last resort is the OS temp directory (per-user on Windows), so the
216/// lock path can never be working-directory-relative there: two processes
217/// opening the same database from different working directories must resolve
218/// the same lock file. On unix the last resort stays the historical `"."` —
219/// a shared world-writable anchor such as `/tmp` would be worse, since a
220/// local attacker could pre-claim the directory and the socket/lock files
221/// under it before the daemon's first run.
222pub(super) fn khive_dir() -> PathBuf {
223    khive_root_from(
224        std::env::var("HOME").ok(),
225        std::env::var("USERPROFILE").ok(),
226    )
227}
228
229/// Env-free core of [`khive_dir`], split out so the fallback chain is
230/// testable without mutating process-global environment variables.
231pub(super) fn khive_root_from(home: Option<String>, userprofile: Option<String>) -> PathBuf {
232    home.filter(|v| !v.trim().is_empty())
233        .or_else(|| userprofile.filter(|v| !v.trim().is_empty()))
234        .map(PathBuf::from)
235        .unwrap_or_else(last_resort_root)
236        .join(".khive")
237}
238
239/// The directory for the SQLite volume lock files, from the single rule in
240/// [`khive_db::default_volume_lock_dir`]: `KHIVE_VOLUME_LOCK_DIR` when set,
241/// else `<home>/.khive/sqlite-volume-locks`. Unlike `khive_dir` it has no
242/// last-resort root: without a home directory the result is a configuration
243/// error, because a working-directory-relative lock directory would give two
244/// processes two different lock files.
245pub fn volume_lock_dir() -> Result<PathBuf, khive_db::SqliteError> {
246    khive_db::default_volume_lock_dir()
247}
248
249/// See [`khive_dir`] for why the two arms differ.
250#[cfg(unix)]
251fn last_resort_root() -> PathBuf {
252    PathBuf::from(".")
253}
254
255#[cfg(not(unix))]
256fn last_resort_root() -> PathBuf {
257    std::env::temp_dir()
258}
259
260/// Env var overriding the socket half of the daemon rendezvous.
261#[cfg(unix)]
262pub(super) const SOCKET_PATH_ENV: &str = "KHIVE_SOCKET";
263
264/// Env var overriding the PID-file half of the daemon rendezvous.
265#[cfg(unix)]
266pub(super) const PID_PATH_ENV: &str = "KHIVE_PID";
267
268/// Read a path override, treating an empty value as unset.
269///
270/// One predicate for "the operator set this variable", shared by the path
271/// resolvers and [`ensure_rendezvous_overrides_paired`]. A pairing check that
272/// disagreed with the resolvers about what counts as set would either refuse
273/// boots that resolve consistently, or admit the split rendezvous it exists
274/// to stop.
275#[cfg(unix)]
276fn path_override(key: &str) -> Option<PathBuf> {
277    match std::env::var(key) {
278        Ok(p) if !p.is_empty() => Some(PathBuf::from(p)),
279        _ => None,
280    }
281}
282
283#[cfg(unix)]
284pub(super) fn default_socket_path() -> PathBuf {
285    khive_dir().join("khived.sock")
286}
287
288#[cfg(unix)]
289fn default_pid_path() -> PathBuf {
290    khive_dir().join("khived.pid")
291}
292
293/// Unix socket path the daemon binds and clients connect to.
294///
295/// Overridable via the `KHIVE_SOCKET` env var (for tests and ops), which must
296/// be set together with `KHIVE_PID`: the daemon refuses to boot when exactly
297/// one of the two is set.
298#[cfg(unix)]
299pub fn socket_path() -> PathBuf {
300    path_override(SOCKET_PATH_ENV).unwrap_or_else(default_socket_path)
301}
302
303/// PID file path written by the daemon.
304///
305/// Overridable via the `KHIVE_PID` env var, which must be set together with
306/// `KHIVE_SOCKET`: the daemon refuses to boot when exactly one of the two is
307/// set.
308#[cfg(unix)]
309pub fn pid_path() -> PathBuf {
310    path_override(PID_PATH_ENV).unwrap_or_else(default_pid_path)
311}
312
313/// The two halves of the daemon rendezvous when exactly one of
314/// `KHIVE_SOCKET` / `KHIVE_PID` is set: the variable that was set, the one
315/// that was not, and the socket and PID file that would be used together.
316#[cfg(unix)]
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct RendezvousSplit {
319    /// The override that is set.
320    pub set: &'static str,
321    /// The override that is not set.
322    pub unset: &'static str,
323    /// The socket path this process resolves.
324    pub socket: PathBuf,
325    /// The PID-file path this process resolves.
326    pub pid_file: PathBuf,
327}
328
329/// `Some` when exactly one of `KHIVE_SOCKET` / `KHIVE_PID` is set.
330///
331/// The one predicate for a split rendezvous: the daemon refuses to boot on it
332/// (`ensure_rendezvous_overrides_paired`, #2656) and a client refuses
333/// lifecycle recovery on it (ADR-185 Amendment 5), because recovery reads a
334/// supervisor marker derived from the socket and signals the process named by
335/// the PID file, and with a split rendezvous those belong to different daemons.
336#[cfg(unix)]
337pub fn rendezvous_split() -> Option<RendezvousSplit> {
338    match (path_override(SOCKET_PATH_ENV), path_override(PID_PATH_ENV)) {
339        (Some(socket), None) => Some(RendezvousSplit {
340            set: SOCKET_PATH_ENV,
341            unset: PID_PATH_ENV,
342            socket,
343            pid_file: default_pid_path(),
344        }),
345        (None, Some(pid_file)) => Some(RendezvousSplit {
346            set: PID_PATH_ENV,
347            unset: SOCKET_PATH_ENV,
348            socket: default_socket_path(),
349            pid_file,
350        }),
351        _ => None,
352    }
353}
354
355/// Refuse to boot when exactly one of `KHIVE_SOCKET` / `KHIVE_PID` is set
356/// (#2656).
357///
358/// The socket and the PID file are the two halves of one rendezvous, but they
359/// resolve independently: with only `KHIVE_SOCKET` set a daemon binds a
360/// private socket while still claiming the shared PID file, and with only
361/// `KHIVE_PID` set it writes a private PID file while binding the shared
362/// socket. Either way [`cleanup_stale_daemon`] reads an incumbent's pid out of
363/// one instance's file and judges it by probing the other instance's socket,
364/// so both of its branches are wrong: a live incumbent produces a refusal
365/// naming a pid that has nothing to do with the socket being started, and a
366/// pid that is no longer running makes this process delete a rendezvous file
367/// another daemon's `shutdown_cleanup_if_owned` still expects to own.
368///
369/// Setting both variables (a fully private rendezvous) and setting neither
370/// (the default rendezvous) are both unchanged.
371#[cfg(unix)]
372pub(super) fn ensure_rendezvous_overrides_paired() -> anyhow::Result<()> {
373    match rendezvous_split() {
374        Some(RendezvousSplit {
375            set: SOCKET_PATH_ENV,
376            socket,
377            pid_file,
378            ..
379        }) => anyhow::bail!(
380            "refusing to start: {SOCKET_PATH_ENV} is set to {} but {PID_PATH_ENV} is not set. \
381             The socket and the PID file are two halves of one daemon rendezvous and must move \
382             together: with only {SOCKET_PATH_ENV} set, this daemon would bind a private socket \
383             while claiming the shared PID file at {}, which belongs to the default rendezvous \
384             served on {}. Set {PID_PATH_ENV} to a private path beside the socket, or unset \
385             {SOCKET_PATH_ENV} to share the default rendezvous.",
386            socket.display(),
387            pid_file.display(),
388            default_socket_path().display(),
389        ),
390        Some(RendezvousSplit {
391            socket, pid_file, ..
392        }) => anyhow::bail!(
393            "refusing to start: {PID_PATH_ENV} is set to {} but {SOCKET_PATH_ENV} is not set. \
394             The socket and the PID file are two halves of one daemon rendezvous and must move \
395             together: with only {PID_PATH_ENV} set, this daemon would write a private PID file \
396             while binding the shared socket at {}, the default rendezvous whose owner is \
397             recorded in {}. Set {SOCKET_PATH_ENV} to a private path beside the PID file, or \
398             unset {PID_PATH_ENV} to share the default rendezvous.",
399            pid_file.display(),
400            socket.display(),
401            default_pid_path().display(),
402        ),
403        None => Ok(()),
404    }
405}
406
407/// Advisory lock file used to serialize stale-daemon recovery across concurrent
408/// clients (flock/`File::lock` on the file; released when the lock file
409/// handle is dropped). Path computation is portable; `kkernel exec`'s
410/// non-unix local-construction guard (`kkernel::exec::acquire_local_construction_guard`)
411/// shares this exact path with the unix daemon-boot guard so the two stay
412/// mutually exclusive.
413///
414/// Overridable via the `KHIVE_LOCK` env var (for tests).
415pub fn lock_path() -> PathBuf {
416    if let Ok(p) = std::env::var("KHIVE_LOCK") {
417        if !p.is_empty() {
418            return PathBuf::from(p);
419        }
420    }
421    khive_dir().join("khived.recovery.lock")
422}
423
424/// Advisory lock file used to serialize RECOVERY (kill+respawn) attempts
425/// across concurrent clients only — the daemon's own boot sequence never
426/// acquires this file ([`lock_path`] / [`acquire_daemon_boot_guard`] is the
427/// boot-side lock). A recoverer holding this lock across dead-confirmation
428/// → kill → spawn (khive-mcp's `kill_and_respawn`) therefore can never
429/// deadlock against a peer daemon's boot, unlike holding the shared boot
430/// lock for that whole span would.
431///
432/// Overridable via the `KHIVE_RECOVERER_LOCK` env var (for tests).
433#[cfg(unix)]
434pub fn recoverer_lock_path() -> PathBuf {
435    if let Ok(p) = std::env::var("KHIVE_RECOVERER_LOCK") {
436        if !p.is_empty() {
437            return PathBuf::from(p);
438        }
439    }
440    khive_dir().join("khived.recoverer.lock")
441}
442
443#[cfg(unix)]
444pub const SUPERVISOR_CLAIM_ENV: &str = "KHIVE_SUPERVISOR_CLAIM";
445
446#[cfg(unix)]
447pub(super) fn read_supervisor_marker_claim() -> Option<(u32, String)> {
448    use std::io::Read;
449    use std::os::unix::fs::OpenOptionsExt;
450
451    let file = std::fs::OpenOptions::new()
452        .read(true)
453        .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
454        .open(supervisor_marker_path())
455        .ok()?;
456    if !file.metadata().ok()?.is_file() {
457        return None;
458    }
459    let mut marker = String::new();
460    file.take(4097).read_to_string(&mut marker).ok()?;
461    if marker.len() > 4096 {
462        return None;
463    }
464    let mut lines = marker.lines();
465    if lines.next()?.is_empty() {
466        return None;
467    }
468    let pid = lines.next()?.parse::<u32>().ok().filter(|pid| *pid > 0)?;
469    lines
470        .next()?
471        .parse::<u64>()
472        .ok()
473        .filter(|seconds| *seconds > 0)?;
474    let claim = lines.next()?.to_string();
475    if lines.next().is_some() {
476        return None;
477    }
478    let parsed = uuid::Uuid::parse_str(&claim).ok()?;
479    if parsed.get_version() != Some(uuid::Version::Random) || parsed.to_string() != claim {
480        return None;
481    }
482    Some((pid, claim))
483}
484
485#[cfg(unix)]
486pub(super) fn current_supervisor_claim() -> Option<String> {
487    let claim = std::env::var(SUPERVISOR_CLAIM_ENV).ok()?;
488    let (pid, published_claim) = read_supervisor_marker_claim()?;
489    (pid == std::process::id() && claim == published_claim).then_some(claim)
490}