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
12pub const DEFAULT_DEMAND_IDLE_SECS: u64 = 1_800;
14
15#[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#[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#[derive(Debug, Clone, Default)]
42pub struct DaemonStartupReport {
43 pub skipped_components: Vec<String>,
44 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#[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 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 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 state.last_request_completion = Some(tokio::time::Instant::now());
204 }
205}
206
207pub(super) fn khive_dir() -> PathBuf {
223 khive_root_from(
224 std::env::var("HOME").ok(),
225 std::env::var("USERPROFILE").ok(),
226 )
227}
228
229pub(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
239pub fn volume_lock_dir() -> Result<PathBuf, khive_db::SqliteError> {
246 khive_db::default_volume_lock_dir()
247}
248
249#[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#[cfg(unix)]
262pub(super) const SOCKET_PATH_ENV: &str = "KHIVE_SOCKET";
263
264#[cfg(unix)]
266pub(super) const PID_PATH_ENV: &str = "KHIVE_PID";
267
268#[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#[cfg(unix)]
299pub fn socket_path() -> PathBuf {
300 path_override(SOCKET_PATH_ENV).unwrap_or_else(default_socket_path)
301}
302
303#[cfg(unix)]
309pub fn pid_path() -> PathBuf {
310 path_override(PID_PATH_ENV).unwrap_or_else(default_pid_path)
311}
312
313#[cfg(unix)]
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct RendezvousSplit {
319 pub set: &'static str,
321 pub unset: &'static str,
323 pub socket: PathBuf,
325 pub pid_file: PathBuf,
327}
328
329#[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#[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
407pub 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#[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}