1use std::sync::Arc;
12
13mod store_guard;
14mod store_identity;
15#[cfg(unix)]
16use store_guard::ensure_claimed_parent_identity;
17#[cfg(unix)]
18pub use store_guard::{acquire_daemon_store_guards, bind_daemon_store_files, claim_stores};
19pub use store_guard::{assert_daemon_store_identities, DaemonStoreGuard};
20#[cfg(unix)]
21pub use store_identity::claimed_daemon_store_identity;
22mod supervisor_marker;
23#[cfg(unix)]
24pub use supervisor_marker::supervisor_marker_path;
25
26#[cfg(unix)]
27use std::io::Write as _;
28#[cfg(unix)]
29use std::os::unix::fs::{MetadataExt, PermissionsExt};
30#[cfg(unix)]
31use std::os::unix::io::AsRawFd;
32use std::path::PathBuf;
33
34#[cfg(unix)]
35use async_trait::async_trait;
36#[cfg(unix)]
37use libc;
38use serde::{Deserialize, Serialize};
39#[cfg(unix)]
40use tokio::io::{AsyncReadExt, AsyncWriteExt};
41#[cfg(unix)]
42use tokio::net::{UnixListener, UnixStream};
43
44#[cfg(unix)]
45use crate::pack::RequestIdentity;
46#[cfg(unix)]
47use khive_db::{run_checkpoint_task, CheckpointConfig, CheckpointLifecycleOwner, ConnectionPool};
48
49mod load_limits;
50#[cfg(unix)]
51use load_limits::{admit_or_refuse_busy, ConnectionAdmission};
52pub use load_limits::{
53 recall_ledger_snapshot, track_recall_ledger_task, ConnectionCapSnapshot, RecallLedgerSnapshot,
54};
55
56pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
58
59pub const PROTOCOL_VERSION: u32 = 8;
68
69pub const DEFAULT_DEMAND_IDLE_SECS: u64 = 1_800;
71
72#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq)]
74#[serde(rename_all = "snake_case")]
75pub enum DaemonLifetime {
76 Demand,
77 #[default]
78 Persistent,
79}
80
81#[derive(Debug, Clone, Copy)]
83pub struct DaemonOptions {
84 pub lifetime: DaemonLifetime,
85 pub idle_interval: std::time::Duration,
86}
87
88impl Default for DaemonOptions {
89 fn default() -> Self {
90 Self {
91 lifetime: DaemonLifetime::Persistent,
92 idle_interval: std::time::Duration::from_secs(DEFAULT_DEMAND_IDLE_SECS),
93 }
94 }
95}
96
97#[derive(Debug, Clone, Default)]
99pub struct DaemonStartupReport {
100 pub skipped_components: Vec<String>,
101 pub idle_ineligible_reasons: Vec<String>,
103}
104
105#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
106#[serde(rename_all = "snake_case")]
107pub enum DaemonLifecyclePhase {
108 Serving,
109 Draining,
110 Stopped,
111}
112
113#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
114#[serde(rename_all = "snake_case")]
115pub enum DaemonShutdownReason {
116 Idle,
117 Signal,
118}
119
120#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
122pub struct DaemonLifecycleSnapshot {
123 pub lifetime: DaemonLifetime,
124 pub instance_generation: String,
125 pub effective_idle_interval_ms: u64,
126 pub phase: DaemonLifecyclePhase,
127 pub shutdown_reason: Option<DaemonShutdownReason>,
128 pub skipped_components: Vec<String>,
129 pub idle_ineligible_reasons: Vec<String>,
130 pub ordinary_requests: usize,
131 pub idle_blockers: Vec<String>,
132}
133
134#[cfg(unix)]
135struct DaemonLifecycle {
136 options: DaemonOptions,
137 state: std::sync::Mutex<DaemonLifecycleState>,
138 connections: ConnectionAdmission,
140}
141
142#[cfg(unix)]
143struct DaemonLifecycleState {
144 snapshot: DaemonLifecycleSnapshot,
145 last_request_completion: Option<tokio::time::Instant>,
146}
147
148#[cfg(unix)]
149impl DaemonLifecycle {
150 fn new(options: DaemonOptions, report: DaemonStartupReport) -> Self {
151 Self {
152 options,
153 state: std::sync::Mutex::new(DaemonLifecycleState {
154 snapshot: DaemonLifecycleSnapshot {
155 lifetime: options.lifetime,
156 instance_generation: uuid::Uuid::new_v4().to_string(),
157 effective_idle_interval_ms: options
158 .idle_interval
159 .as_millis()
160 .min(u128::from(u64::MAX))
161 as u64,
162 phase: DaemonLifecyclePhase::Serving,
163 shutdown_reason: None,
164 skipped_components: report.skipped_components,
165 idle_ineligible_reasons: report.idle_ineligible_reasons,
166 ordinary_requests: 0,
167 idle_blockers: Vec::new(),
168 },
169 last_request_completion: None,
170 }),
171 connections: ConnectionAdmission::from_env(),
172 }
173 }
174
175 fn snapshot(&self) -> DaemonLifecycleSnapshot {
176 self.state
177 .lock()
178 .unwrap_or_else(std::sync::PoisonError::into_inner)
179 .snapshot
180 .clone()
181 }
182
183 fn ready(&self) {
184 self.state
185 .lock()
186 .unwrap_or_else(std::sync::PoisonError::into_inner)
187 .last_request_completion = Some(tokio::time::Instant::now());
188 }
189
190 fn admit(self: &Arc<Self>) -> Option<OrdinaryRequestGuard> {
191 let mut state = self
192 .state
193 .lock()
194 .unwrap_or_else(std::sync::PoisonError::into_inner);
195 if state.snapshot.phase != DaemonLifecyclePhase::Serving {
196 return None;
197 }
198 state.snapshot.ordinary_requests += 1;
199 Some(OrdinaryRequestGuard(Arc::clone(self)))
200 }
201
202 fn try_idle(&self, blockers: impl FnOnce() -> Vec<String>) -> bool {
204 let mut state = self
205 .state
206 .lock()
207 .unwrap_or_else(std::sync::PoisonError::into_inner);
208 if self.options.lifetime != DaemonLifetime::Demand
209 || state.snapshot.phase != DaemonLifecyclePhase::Serving
210 || state.snapshot.ordinary_requests != 0
211 || !state.snapshot.idle_ineligible_reasons.is_empty()
212 || state
213 .last_request_completion
214 .is_none_or(|last| last.elapsed() < self.options.idle_interval)
215 {
216 return false;
217 }
218 state.snapshot.idle_blockers = blockers();
219 if !state.snapshot.idle_blockers.is_empty() {
220 return false;
221 }
222 state.snapshot.phase = DaemonLifecyclePhase::Draining;
223 state.snapshot.shutdown_reason = Some(DaemonShutdownReason::Idle);
224 true
225 }
226
227 fn draining(&self, reason: DaemonShutdownReason) {
228 let mut state = self
229 .state
230 .lock()
231 .unwrap_or_else(std::sync::PoisonError::into_inner);
232 if state.snapshot.phase != DaemonLifecyclePhase::Stopped {
233 state.snapshot.phase = DaemonLifecyclePhase::Draining;
234 state.snapshot.shutdown_reason = Some(reason);
235 }
236 }
237
238 fn stopped(&self) {
239 self.state
240 .lock()
241 .unwrap_or_else(std::sync::PoisonError::into_inner)
242 .snapshot
243 .phase = DaemonLifecyclePhase::Stopped;
244 }
245}
246
247#[cfg(unix)]
248struct OrdinaryRequestGuard(Arc<DaemonLifecycle>);
249
250#[cfg(unix)]
251impl Drop for OrdinaryRequestGuard {
252 fn drop(&mut self) {
253 let mut state = self
254 .0
255 .state
256 .lock()
257 .unwrap_or_else(std::sync::PoisonError::into_inner);
258 state.snapshot.ordinary_requests -= 1;
259 state.last_request_completion = Some(tokio::time::Instant::now());
261 }
262}
263
264#[doc(hidden)]
268pub const DAEMON_LEXICAL_TIMEOUT_MARKER: &str = "__khive_daemon_lexical_timeout";
269
270const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 10;
271#[cfg(unix)]
275const INITIAL_FRAME_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
276
277#[cfg(unix)]
278fn next_accept_error_backoff(previous: Option<std::time::Duration>) -> std::time::Duration {
279 previous
280 .map(|delay| delay.saturating_mul(2))
281 .unwrap_or_else(|| std::time::Duration::from_millis(10))
282 .min(std::time::Duration::from_secs(1))
283}
284
285fn khive_dir() -> PathBuf {
301 khive_root_from(
302 std::env::var("HOME").ok(),
303 std::env::var("USERPROFILE").ok(),
304 )
305}
306
307fn khive_root_from(home: Option<String>, userprofile: Option<String>) -> PathBuf {
310 home.filter(|v| !v.trim().is_empty())
311 .or_else(|| userprofile.filter(|v| !v.trim().is_empty()))
312 .map(PathBuf::from)
313 .unwrap_or_else(last_resort_root)
314 .join(".khive")
315}
316
317pub fn volume_lock_dir() -> Result<PathBuf, khive_db::SqliteError> {
324 khive_db::default_volume_lock_dir()
325}
326
327#[cfg(unix)]
329fn last_resort_root() -> PathBuf {
330 PathBuf::from(".")
331}
332
333#[cfg(not(unix))]
334fn last_resort_root() -> PathBuf {
335 std::env::temp_dir()
336}
337
338#[cfg(unix)]
340const SOCKET_PATH_ENV: &str = "KHIVE_SOCKET";
341
342#[cfg(unix)]
344const PID_PATH_ENV: &str = "KHIVE_PID";
345
346#[cfg(unix)]
354fn path_override(key: &str) -> Option<PathBuf> {
355 match std::env::var(key) {
356 Ok(p) if !p.is_empty() => Some(PathBuf::from(p)),
357 _ => None,
358 }
359}
360
361#[cfg(unix)]
362fn default_socket_path() -> PathBuf {
363 khive_dir().join("khived.sock")
364}
365
366#[cfg(unix)]
367fn default_pid_path() -> PathBuf {
368 khive_dir().join("khived.pid")
369}
370
371#[cfg(unix)]
377pub fn socket_path() -> PathBuf {
378 path_override(SOCKET_PATH_ENV).unwrap_or_else(default_socket_path)
379}
380
381#[cfg(unix)]
387pub fn pid_path() -> PathBuf {
388 path_override(PID_PATH_ENV).unwrap_or_else(default_pid_path)
389}
390
391#[cfg(unix)]
408fn ensure_rendezvous_overrides_paired() -> anyhow::Result<()> {
409 match (path_override(SOCKET_PATH_ENV), path_override(PID_PATH_ENV)) {
410 (Some(socket), None) => anyhow::bail!(
411 "refusing to start: {SOCKET_PATH_ENV} is set to {} but {PID_PATH_ENV} is not set. \
412 The socket and the PID file are two halves of one daemon rendezvous and must move \
413 together: with only {SOCKET_PATH_ENV} set, this daemon would bind a private socket \
414 while claiming the shared PID file at {}, which belongs to the default rendezvous \
415 served on {}. Set {PID_PATH_ENV} to a private path beside the socket, or unset \
416 {SOCKET_PATH_ENV} to share the default rendezvous.",
417 socket.display(),
418 default_pid_path().display(),
419 default_socket_path().display(),
420 ),
421 (None, Some(pid)) => anyhow::bail!(
422 "refusing to start: {PID_PATH_ENV} is set to {} but {SOCKET_PATH_ENV} is not set. \
423 The socket and the PID file are two halves of one daemon rendezvous and must move \
424 together: with only {PID_PATH_ENV} set, this daemon would write a private PID file \
425 while binding the shared socket at {}, the default rendezvous whose owner is \
426 recorded in {}. Set {SOCKET_PATH_ENV} to a private path beside the PID file, or \
427 unset {PID_PATH_ENV} to share the default rendezvous.",
428 pid.display(),
429 default_socket_path().display(),
430 default_pid_path().display(),
431 ),
432 _ => Ok(()),
433 }
434}
435
436pub fn lock_path() -> PathBuf {
445 if let Ok(p) = std::env::var("KHIVE_LOCK") {
446 if !p.is_empty() {
447 return PathBuf::from(p);
448 }
449 }
450 khive_dir().join("khived.recovery.lock")
451}
452
453#[cfg(unix)]
463pub fn recoverer_lock_path() -> PathBuf {
464 if let Ok(p) = std::env::var("KHIVE_RECOVERER_LOCK") {
465 if !p.is_empty() {
466 return PathBuf::from(p);
467 }
468 }
469 khive_dir().join("khived.recoverer.lock")
470}
471
472#[cfg(unix)]
473pub const SUPERVISOR_CLAIM_ENV: &str = "KHIVE_SUPERVISOR_CLAIM";
474
475#[cfg(unix)]
476fn read_supervisor_marker_claim() -> Option<(u32, String)> {
477 use std::io::Read;
478 use std::os::unix::fs::OpenOptionsExt;
479
480 let file = std::fs::OpenOptions::new()
481 .read(true)
482 .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
483 .open(supervisor_marker_path())
484 .ok()?;
485 if !file.metadata().ok()?.is_file() {
486 return None;
487 }
488 let mut marker = String::new();
489 file.take(4097).read_to_string(&mut marker).ok()?;
490 if marker.len() > 4096 {
491 return None;
492 }
493 let mut lines = marker.lines();
494 if lines.next()?.is_empty() {
495 return None;
496 }
497 let pid = lines.next()?.parse::<u32>().ok().filter(|pid| *pid > 0)?;
498 lines
499 .next()?
500 .parse::<u64>()
501 .ok()
502 .filter(|seconds| *seconds > 0)?;
503 let claim = lines.next()?.to_string();
504 if lines.next().is_some() {
505 return None;
506 }
507 let parsed = uuid::Uuid::parse_str(&claim).ok()?;
508 if parsed.get_version() != Some(uuid::Version::Random) || parsed.to_string() != claim {
509 return None;
510 }
511 Some((pid, claim))
512}
513
514#[cfg(unix)]
515fn current_supervisor_claim() -> Option<String> {
516 let claim = std::env::var(SUPERVISOR_CLAIM_ENV).ok()?;
517 let (pid, published_claim) = read_supervisor_marker_claim()?;
518 (pid == std::process::id() && claim == published_claim).then_some(claim)
519}
520
521#[cfg(unix)]
522fn open_lock_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
523 if let Some(parent) = path.parent() {
524 let _ = std::fs::create_dir_all(parent);
525 }
526 std::fs::OpenOptions::new()
527 .create(true)
528 .truncate(false)
529 .write(true)
530 .open(path)
531}
532
533#[cfg(unix)]
534fn acquire_flock_blocking(path: &std::path::Path, label: &str) -> Option<std::fs::File> {
535 let file = match open_lock_file(path) {
536 Ok(f) => f,
537 Err(e) => {
538 tracing::warn!(error = %e, path = ?path, "cannot open {label} lock file");
539 return None;
540 }
541 };
542 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
544 if rc != 0 {
545 tracing::warn!("flock LOCK_EX failed on {label} lock");
546 return None;
547 }
548 Some(file)
549}
550
551#[cfg(unix)]
558pub fn acquire_recovery_lock() -> Option<std::fs::File> {
559 acquire_flock_blocking(&lock_path(), "recovery")
560}
561
562#[cfg(unix)]
577fn try_acquire_flock_until(
578 path: &std::path::Path,
579 deadline: std::time::Instant,
580) -> std::io::Result<Option<std::fs::File>> {
581 let file = open_lock_file(path)?;
582 let poll_interval = std::time::Duration::from_millis(10);
583 loop {
584 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
586 if rc == 0 {
587 return Ok(Some(file));
588 }
589 let err = std::io::Error::last_os_error();
590 if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
591 return Err(err);
592 }
593 let now = std::time::Instant::now();
594 if now >= deadline {
595 return Ok(None);
596 }
597 std::thread::sleep(poll_interval.min(deadline - now));
598 }
599}
600
601#[cfg(unix)]
608pub fn try_acquire_daemon_boot_guard_until(
609 deadline: std::time::Instant,
610) -> std::io::Result<Option<DaemonBootGuard>> {
611 try_acquire_flock_until(&lock_path(), deadline)
612}
613
614#[cfg(unix)]
620pub fn try_acquire_recoverer_lock_until(
621 deadline: std::time::Instant,
622) -> std::io::Result<Option<std::fs::File>> {
623 try_acquire_flock_until(&recoverer_lock_path(), deadline)
624}
625
626#[cfg(unix)]
630pub type DaemonBootGuard = std::fs::File;
631
632#[cfg(unix)]
642pub fn acquire_daemon_boot_guard() -> anyhow::Result<DaemonBootGuard> {
643 acquire_recovery_lock()
644 .ok_or_else(|| anyhow::anyhow!("failed to acquire daemon boot/recovery lock"))
645}
646
647#[cfg(unix)]
654#[derive(Clone, Copy, PartialEq, Eq)]
655struct SocketIdentity {
656 dev: u64,
657 ino: u64,
658}
659
660#[cfg(unix)]
661fn socket_identity(path: &std::path::Path) -> Option<SocketIdentity> {
662 use std::os::unix::fs::MetadataExt;
663 let meta = std::fs::metadata(path).ok()?;
664 Some(SocketIdentity {
665 dev: meta.dev(),
666 ino: meta.ino(),
667 })
668}
669
670#[cfg(unix)]
684pub(crate) fn peer_uid(stream: &UnixStream) -> std::io::Result<u32> {
685 use std::os::fd::AsRawFd;
686 let fd = stream.as_raw_fd();
687
688 #[cfg(any(target_os = "macos", target_os = "ios", target_vendor = "apple"))]
689 {
690 let mut uid: libc::uid_t = 0;
691 let mut gid: libc::gid_t = 0;
692 let rc = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
695 if rc != 0 {
696 return Err(std::io::Error::last_os_error());
697 }
698 Ok(uid as u32)
699 }
700
701 #[cfg(target_os = "linux")]
702 {
703 let mut cred = libc::ucred {
704 pid: 0,
705 uid: 0,
706 gid: 0,
707 };
708 let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
709 let rc = unsafe {
713 libc::getsockopt(
714 fd,
715 libc::SOL_SOCKET,
716 libc::SO_PEERCRED,
717 (&mut cred as *mut libc::ucred).cast::<libc::c_void>(),
718 &mut len,
719 )
720 };
721 if rc != 0 {
722 return Err(std::io::Error::last_os_error());
723 }
724 Ok(cred.uid)
725 }
726
727 #[cfg(not(any(
728 target_os = "linux",
729 target_os = "macos",
730 target_os = "ios",
731 target_vendor = "apple"
732 )))]
733 {
734 let _ = fd;
735 Err(std::io::Error::new(
736 std::io::ErrorKind::Unsupported,
737 "peer-credential capture is not implemented for this platform",
738 ))
739 }
740}
741
742#[cfg(unix)]
763pub(crate) fn uid_is_permitted(peer: u32, daemon_euid: u32) -> bool {
764 peer == daemon_euid
765}
766
767mod config_id;
770#[cfg(test)]
771use config_id::parse_config_id;
772pub use config_id::{
773 config_id_extra_embedder_exclusions, config_ids_compatible, first_config_mismatch_field,
774};
775
776#[derive(Serialize, Deserialize, Default)]
778pub struct DaemonRequestFrame {
779 pub ops: String,
780 #[serde(default)]
782 pub plan: bool,
783 #[serde(skip_serializing_if = "Option::is_none")]
784 pub presentation: Option<String>,
785 #[serde(skip_serializing_if = "Option::is_none")]
786 pub presentation_per_op: Option<Vec<Option<String>>>,
787 pub namespace: String,
793 #[serde(default)]
798 pub actor_id: Option<String>,
799 #[serde(default, skip_serializing_if = "Option::is_none")]
805 pub process_ref: Option<String>,
806 #[serde(default)]
814 pub visible_namespaces: Vec<String>,
815 #[serde(default)]
822 pub config_id: String,
823 #[serde(default)]
827 pub protocol_version: u32,
828 #[serde(default)]
834 pub probe_only: bool,
835 #[serde(default)]
845 pub metrics_only: bool,
846 #[serde(default)]
849 #[serde(skip_serializing_if = "Option::is_none")]
850 pub format: Option<String>,
851 #[serde(default)]
853 #[serde(skip_serializing_if = "Option::is_none")]
854 pub format_per_op: Option<Vec<Option<String>>>,
855 #[serde(default)]
866 pub from_wire: bool,
867 #[serde(default)]
877 #[serde(skip_serializing_if = "Option::is_none")]
878 pub request_id: Option<u64>,
879}
880
881#[derive(Debug, Clone)]
883pub struct DaemonDispatchError {
884 pub message: String,
885 pub error_detail: serde_json::Value,
886}
887
888pub const ERROR_DETAIL_NESTING_DEPTH_LIMIT: usize = 64;
890
891fn error_detail_value_within_limit(value: &serde_json::Value) -> bool {
892 let mut pending = vec![(value, 0_usize)];
893 while let Some((value, depth)) = pending.pop() {
894 match value {
895 serde_json::Value::Array(items) if depth < ERROR_DETAIL_NESTING_DEPTH_LIMIT => {
896 pending.extend(items.iter().map(|child| (child, depth + 1)));
897 }
898 serde_json::Value::Object(fields) if depth < ERROR_DETAIL_NESTING_DEPTH_LIMIT => {
899 pending.extend(fields.values().map(|child| (child, depth + 1)));
900 }
901 serde_json::Value::Array(_) | serde_json::Value::Object(_) => return false,
902 _ => {}
903 }
904 }
905 true
906}
907
908fn drop_error_detail_iteratively(value: serde_json::Value) {
909 let mut pending = vec![value];
910 while let Some(value) = pending.pop() {
911 match value {
912 serde_json::Value::Array(items) => pending.extend(items),
913 serde_json::Value::Object(fields) => pending.extend(fields.into_values()),
914 _ => {}
915 }
916 }
917}
918
919impl DaemonDispatchError {
920 pub fn new(message: impl Into<String>, error_detail: Option<serde_json::Value>) -> Self {
922 let message = message.into();
923 let mut fields = match error_detail {
924 Some(serde_json::Value::Object(fields)) => fields,
925 Some(data) => serde_json::Map::from_iter([("data".to_string(), data)]),
926 None => serde_json::Map::new(),
927 };
928 let disposition = match fields
929 .get("domain_disposition")
930 .and_then(serde_json::Value::as_str)
931 {
932 Some("committed") => crate::DomainDisposition::Committed,
933 Some("not_committed") => crate::DomainDisposition::NotCommitted,
934 _ => crate::DomainDisposition::Unknown,
935 };
936 if disposition != crate::DomainDisposition::Committed {
937 if let Some(result) = fields.remove("domain_result") {
938 drop_error_detail_iteratively(result);
939 }
940 }
941 let rejected: Vec<String> = fields
942 .iter()
943 .filter(|(_, value)| !error_detail_value_within_limit(value))
944 .map(|(name, _)| name.clone())
945 .collect();
946 let omitted_result = rejected.iter().any(|name| name == "domain_result");
947 let omitted_detail = !rejected.is_empty();
948 for name in rejected {
949 if let Some(value) = fields.remove(&name) {
950 drop_error_detail_iteratively(value);
951 }
952 }
953 let mut error_detail = serde_json::Value::Object(fields);
954 if error_detail["kind"].as_str().is_none() {
955 error_detail["kind"] = serde_json::json!("internal");
956 }
957 if error_detail["message"].as_str().is_none() {
958 error_detail["message"] = serde_json::json!(message);
959 }
960 error_detail["domain_disposition"] = serde_json::json!(disposition.as_str());
961 if omitted_detail {
962 error_detail["code"] = serde_json::json!(if omitted_result {
963 "result_too_deep"
964 } else {
965 "error_detail_too_deep"
966 });
967 }
968 Self {
969 message,
970 error_detail,
971 }
972 }
973}
974
975#[derive(Serialize, Deserialize, Debug)]
977pub struct DaemonResponseFrame {
978 pub ok: bool,
979 pub result: Option<String>,
980 pub error: Option<String>,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub error_detail: Option<serde_json::Value>,
986 pub namespace_mismatch: bool,
987 #[serde(default)]
991 pub config_mismatch: bool,
992 #[serde(default)]
999 pub served_config_id: Option<String>,
1000 #[serde(default)]
1005 pub version_mismatch: bool,
1006 #[serde(default)]
1010 pub daemon_protocol_version: u32,
1011 #[serde(default, skip_serializing_if = "Option::is_none")]
1017 pub metrics: Option<MetricsSnapshot>,
1018 #[serde(default)]
1025 pub request_id: Option<u64>,
1026}
1027
1028#[cfg(unix)]
1035fn take_daemon_lexical_timeout_marker(raw: String) -> (String, Option<serde_json::Value>) {
1036 if !raw.contains(DAEMON_LEXICAL_TIMEOUT_MARKER) {
1037 return (raw, None);
1038 }
1039 let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&raw) else {
1040 return (raw, None);
1041 };
1042 let Some(fields) = value.as_object_mut() else {
1043 return (raw, None);
1044 };
1045 if !fields
1046 .get("results")
1047 .is_some_and(serde_json::Value::is_array)
1048 {
1049 return (raw, None);
1050 }
1051 let Some(marker) = fields.remove(DAEMON_LEXICAL_TIMEOUT_MARKER) else {
1052 return (raw, None);
1053 };
1054 let detail =
1055 (marker.as_bool() == Some(true)).then(|| serde_json::json!({"lexical_timeout": true}));
1056 (
1057 serde_json::to_string(&value).expect("serde_json::Value is serializable"),
1058 detail,
1059 )
1060}
1061
1062#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
1067#[serde(default)]
1068pub struct CheckpointStoreMetrics {
1069 pub store_id: String,
1070 pub role: String,
1071 pub database: Option<String>,
1072 #[serde(flatten)]
1073 pub timing: khive_db::checkpoint::CheckpointTiming,
1074}
1075
1076#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
1087pub struct MetricsSnapshot {
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1090 pub lifecycle: Option<DaemonLifecycleSnapshot>,
1091 pub wal_pages: Option<u64>,
1096 #[serde(default)]
1099 pub wal_log_frames: Option<u64>,
1100 #[serde(default)]
1102 pub wal_checkpointed_frames: Option<u64>,
1103 #[serde(default)]
1105 pub wal_pending_frames: Option<u64>,
1106 #[serde(default)]
1108 pub wal_physical_bytes: Option<u64>,
1109 #[serde(default)]
1111 pub wal_observed_at_unix_ms: Option<u64>,
1112 #[serde(default)]
1116 pub wal_checkpoint_stores: Vec<CheckpointStoreMetrics>,
1117 pub wal_truncate_attempts: u64,
1121 pub wal_truncate_consecutive_failures: u64,
1125 #[serde(default)]
1130 pub wal_checkpoint_skipped_ticks: u64,
1131 #[serde(default)]
1133 pub wal_checkpoint_consecutive_skips: u64,
1134 #[serde(default)]
1137 pub wal_checkpoint_last_skip_wal_pages: Option<u64>,
1138 pub oldest_pinned_tx_micros: Option<u64>,
1142 pub oldest_pinned_tx_label: Option<String>,
1145 pub open_tx_count: usize,
1147 pub write_queue_depth: Option<usize>,
1152 pub write_queue_capacity: Option<usize>,
1156 #[serde(default)]
1158 pub write_last_queue_wait_micros: Option<u64>,
1159 #[serde(default)]
1161 pub write_last_transaction_acquire_micros: Option<u64>,
1162 #[serde(default)]
1164 pub write_last_body_micros: Option<u64>,
1165 #[serde(default)]
1167 pub write_last_commit_micros: Option<u64>,
1168 #[serde(default)]
1171 pub write_last_total_micros: Option<u64>,
1172 #[serde(default)]
1174 pub write_last_observed_at_unix_ms: Option<u64>,
1175 #[serde(default, skip_serializing_if = "Option::is_none")]
1179 pub connections: Option<ConnectionCapSnapshot>,
1180 #[serde(default, skip_serializing_if = "Option::is_none")]
1183 pub recall_ledger: Option<RecallLedgerSnapshot>,
1184}
1185
1186#[cfg(unix)]
1190pub async fn read_frame<R>(stream: &mut R) -> std::io::Result<Vec<u8>>
1191where
1192 R: tokio::io::AsyncRead + Unpin,
1193{
1194 let mut len_buf = [0u8; 4];
1195 stream.read_exact(&mut len_buf).await?;
1196 let len = u32::from_be_bytes(len_buf) as usize;
1197 if len > MAX_FRAME_BYTES {
1198 return Err(std::io::Error::new(
1199 std::io::ErrorKind::InvalidData,
1200 format!("daemon frame of {len} bytes exceeds {MAX_FRAME_BYTES} cap"),
1201 ));
1202 }
1203 let mut buf = vec![0u8; len];
1204 stream.read_exact(&mut buf).await?;
1205 Ok(buf)
1206}
1207
1208#[cfg(unix)]
1209fn initial_frame_timeout_error() -> std::io::Error {
1210 std::io::Error::new(
1211 std::io::ErrorKind::TimedOut,
1212 "daemon initial request frame read timed out",
1213 )
1214}
1215
1216#[cfg(unix)]
1217async fn read_initial_frame<R>(
1218 stream: &mut R,
1219 deadline: tokio::time::Instant,
1220) -> std::io::Result<Vec<u8>>
1221where
1222 R: tokio::io::AsyncRead + Unpin,
1223{
1224 if tokio::time::Instant::now() >= deadline {
1228 return Err(initial_frame_timeout_error());
1229 }
1230 let raw = tokio::time::timeout_at(deadline, read_frame(stream))
1231 .await
1232 .map_err(|_| initial_frame_timeout_error())??;
1233 if tokio::time::Instant::now() >= deadline {
1234 return Err(initial_frame_timeout_error());
1235 }
1236 Ok(raw)
1237}
1238
1239#[cfg(unix)]
1241pub async fn write_frame<W>(stream: &mut W, payload: &[u8]) -> std::io::Result<()>
1242where
1243 W: tokio::io::AsyncWrite + Unpin,
1244{
1245 if payload.len() > MAX_FRAME_BYTES {
1246 return Err(std::io::Error::new(
1247 std::io::ErrorKind::InvalidData,
1248 format!(
1249 "daemon frame of {} bytes exceeds {MAX_FRAME_BYTES} cap",
1250 payload.len()
1251 ),
1252 ));
1253 }
1254 let len = (payload.len() as u32).to_be_bytes();
1255 stream.write_all(&len).await?;
1256 stream.write_all(payload).await?;
1257 stream.flush().await?;
1258 Ok(())
1259}
1260
1261#[cfg(unix)]
1270#[async_trait]
1271pub trait DaemonDispatch: Clone + Send + Sync + 'static {
1272 fn idle_retirement_blockers(&self) -> Vec<String> {
1275 vec!["dispatcher_resource_inventory_unknown".to_owned()]
1276 }
1277
1278 fn plan(&self, ops: &str) -> String;
1280
1281 #[allow(clippy::too_many_arguments)]
1296 async fn dispatch(
1297 &self,
1298 ops: String,
1299 presentation: Option<String>,
1300 presentation_per_op: Option<Vec<Option<String>>>,
1301 format: Option<String>,
1302 format_per_op: Option<Vec<Option<String>>>,
1303 from_wire: bool,
1304 identity: Option<RequestIdentity>,
1305 ) -> Result<String, String>;
1306
1307 fn request_read_timeout(&self, _ops: &str) -> std::time::Duration {
1311 khive_storage::request_read_timeout_from_env()
1312 }
1313
1314 #[allow(clippy::too_many_arguments)]
1316 async fn dispatch_with_error_detail(
1317 &self,
1318 ops: String,
1319 presentation: Option<String>,
1320 presentation_per_op: Option<Vec<Option<String>>>,
1321 format: Option<String>,
1322 format_per_op: Option<Vec<Option<String>>>,
1323 from_wire: bool,
1324 identity: Option<RequestIdentity>,
1325 ) -> Result<String, DaemonDispatchError> {
1326 self.dispatch(
1327 ops,
1328 presentation,
1329 presentation_per_op,
1330 format,
1331 format_per_op,
1332 from_wire,
1333 identity,
1334 )
1335 .await
1336 .map_err(|message| DaemonDispatchError::new(message, None))
1337 }
1338
1339 async fn warm_all(&self);
1341
1342 fn namespace(&self) -> &str;
1344
1345 fn config_id(&self) -> &str;
1350
1351 fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
1359 None
1360 }
1361
1362 fn secondary_pools_for_checkpoint(&self) -> Vec<Arc<ConnectionPool>> {
1372 Vec::new()
1373 }
1374
1375 fn event_store_for_checkpoint(&self) -> Option<Arc<dyn khive_storage::EventStore>> {
1385 None
1386 }
1387}
1388
1389#[cfg(unix)]
1390struct CheckpointTaskSpec {
1391 pool: Arc<ConnectionPool>,
1392 lifecycle_owner: Option<CheckpointLifecycleOwner>,
1393 is_main: bool,
1394}
1395
1396#[cfg(unix)]
1403fn checkpoint_task_specs(
1404 main_pool: Option<Arc<ConnectionPool>>,
1405 secondary_pools: Vec<Arc<ConnectionPool>>,
1406 event_store: Option<Arc<dyn khive_storage::EventStore>>,
1407 namespace: String,
1408) -> Vec<CheckpointTaskSpec> {
1409 let mut tasks = Vec::with_capacity(usize::from(main_pool.is_some()) + secondary_pools.len());
1410 if let Some(pool) = main_pool {
1411 tasks.push(CheckpointTaskSpec {
1412 pool,
1413 lifecycle_owner: None,
1414 is_main: true,
1415 });
1416 }
1417 tasks.extend(secondary_pools.into_iter().map(|pool| CheckpointTaskSpec {
1418 pool,
1419 lifecycle_owner: None,
1420 is_main: false,
1421 }));
1422
1423 if let (Some(task), Some(event_store)) = (tasks.first_mut(), event_store) {
1424 task.lifecycle_owner = Some(CheckpointLifecycleOwner::new(event_store, namespace));
1425 }
1426 tasks
1427}
1428
1429static WARM_INDEX_HOST: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
1443
1444pub fn mark_warm_index_host() {
1448 WARM_INDEX_HOST.store(true, std::sync::atomic::Ordering::Release);
1449}
1450
1451pub fn is_warm_index_host() -> bool {
1460 WARM_INDEX_HOST.load(std::sync::atomic::Ordering::Acquire)
1461}
1462
1463static BACKGROUND_TASKS: std::sync::OnceLock<Arc<std::sync::atomic::AtomicUsize>> =
1464 std::sync::OnceLock::new();
1465
1466fn background_tasks() -> &'static Arc<std::sync::atomic::AtomicUsize> {
1467 BACKGROUND_TASKS.get_or_init(|| Arc::new(std::sync::atomic::AtomicUsize::new(0)))
1468}
1469
1470static BACKGROUND_TASK_NAMES: std::sync::OnceLock<
1486 std::sync::Mutex<std::collections::HashMap<&'static str, usize>>,
1487> = std::sync::OnceLock::new();
1488
1489fn background_task_names_registry(
1490) -> &'static std::sync::Mutex<std::collections::HashMap<&'static str, usize>> {
1491 BACKGROUND_TASK_NAMES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
1492}
1493
1494pub const UNNAMED_BACKGROUND_TASK: &str = "unnamed";
1497
1498fn register_background_task_name(name: &'static str) {
1499 let mut names = background_task_names_registry()
1500 .lock()
1501 .unwrap_or_else(std::sync::PoisonError::into_inner);
1502 *names.entry(name).or_insert(0) += 1;
1503}
1504
1505fn release_background_task_name(name: &'static str) {
1506 let mut names = background_task_names_registry()
1507 .lock()
1508 .unwrap_or_else(std::sync::PoisonError::into_inner);
1509 if let Some(count) = names.get_mut(name) {
1510 *count -= 1;
1511 if *count == 0 {
1512 names.remove(name);
1513 }
1514 }
1515}
1516
1517pub fn background_task_names() -> Vec<String> {
1521 let names = background_task_names_registry()
1522 .lock()
1523 .unwrap_or_else(std::sync::PoisonError::into_inner);
1524 let mut out: Vec<String> = names.keys().map(|name| (*name).to_string()).collect();
1525 out.sort();
1526 out
1527}
1528
1529#[cfg(unix)]
1530fn idle_retirement_blockers<D: DaemonDispatch>(dispatcher: &D) -> Vec<String> {
1531 let mut blockers = dispatcher.idle_retirement_blockers();
1532 if !khive_storage::tx_registry::snapshot().is_empty() {
1533 blockers.push("open_sql_transaction".to_owned());
1534 }
1535 blockers.extend(
1536 active_phase_names()
1537 .into_iter()
1538 .map(|name| format!("active_phase:{name}")),
1539 );
1540 let count = background_task_count();
1541 let names = background_task_names_registry()
1542 .lock()
1543 .unwrap_or_else(std::sync::PoisonError::into_inner);
1544 if names.values().sum::<usize>() != count {
1545 blockers.push("tracked_worker_inventory_unsettled".to_owned());
1546 }
1547 for name in names.keys() {
1550 if !matches!(
1551 *name,
1552 "wal_checkpoint" | "memory_ann_rotation_watch" | "knowledge_ann_rotation_watch"
1553 ) {
1554 blockers.push(format!("unsettled_worker:{name}"));
1555 }
1556 }
1557 blockers.sort();
1558 blockers.dedup();
1559 blockers
1560}
1561
1562#[cfg(unix)]
1563async fn wait_for_idle<D: DaemonDispatch>(dispatcher: &D, lifecycle: &DaemonLifecycle) {
1564 if lifecycle.options.lifetime == DaemonLifetime::Persistent {
1565 std::future::pending::<()>().await;
1566 }
1567 loop {
1568 if lifecycle.try_idle(|| idle_retirement_blockers(dispatcher)) {
1569 return;
1570 }
1571 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1572 }
1573}
1574
1575struct BackgroundTaskGuard {
1576 counter: Arc<std::sync::atomic::AtomicUsize>,
1577 name: &'static str,
1578}
1579
1580impl Drop for BackgroundTaskGuard {
1581 fn drop(&mut self) {
1582 release_background_task_name(self.name);
1583 self.counter
1584 .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
1585 }
1586}
1587
1588pub fn spawn_tracked_task<F, T>(fut: F) -> tokio::task::JoinHandle<T>
1594where
1595 F: std::future::Future<Output = T> + Send + 'static,
1596 T: Send + 'static,
1597{
1598 spawn_named_tracked_task(UNNAMED_BACKGROUND_TASK, fut)
1599}
1600
1601pub fn spawn_named_tracked_task<F, T>(name: &'static str, fut: F) -> tokio::task::JoinHandle<T>
1608where
1609 F: std::future::Future<Output = T> + Send + 'static,
1610 T: Send + 'static,
1611{
1612 background_tasks().fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1613 register_background_task_name(name);
1614 let guard = BackgroundTaskGuard {
1615 counter: background_tasks().clone(),
1616 name,
1617 };
1618 tokio::spawn(async move {
1619 let _guard = guard;
1620 fut.await
1621 })
1622}
1623
1624pub fn track_background_task<F>(fut: F)
1629where
1630 F: std::future::Future<Output = ()> + Send + 'static,
1631{
1632 track_named_background_task(UNNAMED_BACKGROUND_TASK, fut);
1633}
1634
1635pub fn track_named_background_task<F>(name: &'static str, fut: F)
1638where
1639 F: std::future::Future<Output = ()> + Send + 'static,
1640{
1641 drop(spawn_named_tracked_task(name, fut));
1642}
1643
1644pub fn background_task_count() -> usize {
1647 background_tasks().load(std::sync::atomic::Ordering::SeqCst)
1648}
1649
1650pub fn daemon_shutdown_token() -> tokio_util::sync::CancellationToken {
1661 static TOKEN: std::sync::OnceLock<tokio_util::sync::CancellationToken> =
1662 std::sync::OnceLock::new();
1663 TOKEN
1664 .get_or_init(tokio_util::sync::CancellationToken::new)
1665 .clone()
1666}
1667
1668static ACTIVE_PHASES: std::sync::OnceLock<
1679 std::sync::Mutex<std::collections::HashMap<String, usize>>,
1680> = std::sync::OnceLock::new();
1681
1682fn active_phases() -> &'static std::sync::Mutex<std::collections::HashMap<String, usize>> {
1683 ACTIVE_PHASES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
1684}
1685
1686pub struct PhaseGuard {
1692 name: String,
1693}
1694
1695impl Drop for PhaseGuard {
1696 fn drop(&mut self) {
1697 let mut map = active_phases()
1698 .lock()
1699 .unwrap_or_else(std::sync::PoisonError::into_inner);
1700 if let Some(count) = map.get_mut(&self.name) {
1701 *count -= 1;
1702 if *count == 0 {
1703 map.remove(&self.name);
1704 }
1705 }
1706 }
1707}
1708
1709pub fn register_active_phase(name: &str) -> PhaseGuard {
1714 let mut map = active_phases()
1715 .lock()
1716 .unwrap_or_else(std::sync::PoisonError::into_inner);
1717 *map.entry(name.to_string()).or_insert(0) += 1;
1718 PhaseGuard {
1719 name: name.to_string(),
1720 }
1721}
1722
1723pub fn active_phase_names() -> Vec<String> {
1726 let map = active_phases()
1727 .lock()
1728 .unwrap_or_else(std::sync::PoisonError::into_inner);
1729 let mut names: Vec<String> = map.keys().cloned().collect();
1730 names.sort();
1731 names
1732}
1733
1734#[cfg(unix)]
1742fn build_metrics_snapshot<D: DaemonDispatch>(dispatcher: &D) -> MetricsSnapshot {
1743 let open_tx_count = khive_storage::tx_registry::snapshot().len();
1744 let (oldest_pinned_tx_micros, oldest_pinned_tx_label) =
1751 match khive_storage::tx_registry::oldest() {
1752 Some((_id, age, label)) => (Some(age.as_micros() as u64), label),
1753 None => (None, None),
1754 };
1755
1756 let checkpoint_pool = dispatcher.pool_for_checkpoint();
1757 let mut secondary_index = 0;
1758 let wal_checkpoint_stores = checkpoint_task_specs(
1759 checkpoint_pool.clone(),
1760 dispatcher.secondary_pools_for_checkpoint(),
1761 None,
1762 String::new(),
1763 )
1764 .into_iter()
1765 .map(|task| {
1766 let (store_id, role) = if task.is_main {
1767 ("main".to_string(), "main".to_string())
1768 } else {
1769 let store_id = format!("secondary:{secondary_index}");
1770 secondary_index += 1;
1771 (store_id, "secondary".to_string())
1772 };
1773 CheckpointStoreMetrics {
1774 store_id,
1775 role,
1776 database: task
1777 .pool
1778 .canonical_path()
1779 .and_then(std::path::Path::file_name)
1780 .map(|name| name.to_string_lossy().into_owned()),
1781 timing: khive_db::checkpoint::checkpoint_timing(&task.pool),
1782 }
1783 })
1784 .collect();
1785 let routine_wal = checkpoint_pool
1786 .as_deref()
1787 .and_then(khive_db::checkpoint::routine_wal_observation);
1788 let writer_stages = checkpoint_pool
1789 .as_deref()
1790 .and_then(khive_db::writer_task::last_writer_stage_observation);
1791 let (write_queue_depth, write_queue_capacity) = checkpoint_pool
1792 .as_ref()
1793 .and_then(|pool| pool.writer_task_handle().ok().flatten())
1794 .map(|handle| (Some(handle.queue_depth()), Some(handle.capacity())))
1795 .unwrap_or((None, None));
1796
1797 MetricsSnapshot {
1798 lifecycle: None,
1799 wal_pages: routine_wal.as_ref().map(|sample| sample.log_frames),
1800 wal_log_frames: routine_wal.as_ref().map(|sample| sample.log_frames),
1801 wal_checkpointed_frames: routine_wal
1802 .as_ref()
1803 .map(|sample| sample.checkpointed_frames),
1804 wal_pending_frames: routine_wal.as_ref().map(|sample| sample.pending_frames),
1805 wal_physical_bytes: routine_wal
1806 .as_ref()
1807 .and_then(|sample| sample.physical_wal_bytes),
1808 wal_observed_at_unix_ms: routine_wal
1809 .as_ref()
1810 .map(|sample| sample.observed_at_unix_ms),
1811 wal_checkpoint_stores,
1812 wal_truncate_attempts: khive_db::checkpoint::truncate_attempts(),
1813 wal_truncate_consecutive_failures: khive_db::checkpoint::truncate_consecutive_failures(),
1814 wal_checkpoint_skipped_ticks: khive_db::checkpoint::checkpoint_skipped_ticks(),
1815 wal_checkpoint_consecutive_skips: khive_db::checkpoint::checkpoint_consecutive_skips(),
1816 wal_checkpoint_last_skip_wal_pages: khive_db::checkpoint::checkpoint_last_skip_wal_pages(),
1817 oldest_pinned_tx_micros,
1818 oldest_pinned_tx_label,
1819 open_tx_count,
1820 write_queue_depth,
1821 write_queue_capacity,
1822 write_last_queue_wait_micros: writer_stages
1823 .as_ref()
1824 .map(|sample| sample.queue_wait_micros),
1825 write_last_transaction_acquire_micros: writer_stages
1826 .as_ref()
1827 .map(|sample| sample.transaction_acquire_micros),
1828 write_last_body_micros: writer_stages.as_ref().map(|sample| sample.body_micros),
1829 write_last_commit_micros: writer_stages.as_ref().map(|sample| sample.commit_micros),
1830 write_last_total_micros: writer_stages.as_ref().map(|sample| sample.total_micros),
1831 write_last_observed_at_unix_ms: writer_stages
1832 .as_ref()
1833 .map(|sample| sample.observed_at_unix_ms),
1834 connections: None,
1835 recall_ledger: Some(recall_ledger_snapshot()),
1836 }
1837}
1838
1839#[cfg(unix)]
1840async fn write_response_frame<W>(stream: &mut W, payload: &[u8]) -> std::io::Result<()>
1841where
1842 W: tokio::io::AsyncWrite + Unpin,
1843{
1844 tokio::time::timeout(INITIAL_FRAME_READ_TIMEOUT, write_frame(stream, payload))
1845 .await
1846 .map_err(|_| {
1847 std::io::Error::new(
1848 std::io::ErrorKind::TimedOut,
1849 "daemon response write timed out",
1850 )
1851 })?
1852}
1853
1854#[cfg(unix)]
1855async fn wait_for_peer_disconnect(read: &mut tokio::net::unix::OwnedReadHalf) {
1856 let mut byte = [0u8; 1];
1857 let _ = read.read(&mut byte).await;
1861}
1862
1863#[cfg(all(unix, test))]
1864async fn handle_conn<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
1865 handle_conn_with_shutdown(
1866 stream,
1867 dispatcher,
1868 None,
1869 tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
1870 )
1871 .await;
1872}
1873
1874#[cfg(all(unix, feature = "fault-injection"))]
1875#[doc(hidden)]
1876pub async fn handle_conn_for_test<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
1877 handle_conn_with_shutdown(
1878 stream,
1879 dispatcher,
1880 None,
1881 tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
1882 )
1883 .await;
1884}
1885
1886#[cfg(unix)]
1887fn plan_frame_companion(raw: &[u8]) -> Option<&'static str> {
1888 let value: serde_json::Value = serde_json::from_slice(raw).ok()?;
1889 if value.get("plan").and_then(serde_json::Value::as_bool) != Some(true)
1890 || value
1891 .get("protocol_version")
1892 .and_then(serde_json::Value::as_u64)
1893 != Some(u64::from(PROTOCOL_VERSION))
1894 {
1895 return None;
1896 }
1897 [
1898 "presentation",
1899 "presentation_per_op",
1900 "format",
1901 "format_per_op",
1902 "request_id",
1903 ]
1904 .into_iter()
1905 .find(|field| value.get(*field).is_some())
1906}
1907
1908#[cfg(all(
1909 unix,
1910 any(test, feature = "fault-injection", feature = "test-internals")
1911))]
1912async fn handle_conn_with_shutdown<D: DaemonDispatch>(
1913 stream: UnixStream,
1914 dispatcher: D,
1915 shutdown: Option<tokio::sync::watch::Receiver<bool>>,
1916 initial_frame_deadline: tokio::time::Instant,
1917) {
1918 handle_conn_with_lifecycle(stream, dispatcher, shutdown, initial_frame_deadline, None).await;
1919}
1920
1921#[cfg(unix)]
1922async fn handle_conn_with_lifecycle<D: DaemonDispatch>(
1923 mut stream: UnixStream,
1924 dispatcher: D,
1925 shutdown: Option<tokio::sync::watch::Receiver<bool>>,
1926 initial_frame_deadline: tokio::time::Instant,
1927 lifecycle: Option<Arc<DaemonLifecycle>>,
1928) {
1929 let mut ordinary_admission = None;
1930 let production_shutdown = shutdown.is_some();
1931 let handover_peer_allowed = peer_uid(&stream)
1935 .ok()
1936 .is_some_and(|uid| uid == unsafe { libc::geteuid() } as u32);
1937 let (local_shutdown_tx, local_shutdown_rx) = tokio::sync::watch::channel(false);
1938 let shutdown = shutdown.unwrap_or(local_shutdown_rx);
1939 let _local_shutdown_tx = local_shutdown_tx;
1942 let raw = match read_initial_frame(&mut stream, initial_frame_deadline).await {
1943 Ok(r) => r,
1944 Err(e) => {
1945 tracing::debug!(error = %e, "failed to read daemon request frame");
1946 return;
1947 }
1948 };
1949 #[derive(Deserialize)]
1950 struct SupervisorRequestEnvelope {
1951 #[serde(flatten)]
1952 frame: DaemonRequestFrame,
1953 #[serde(default)]
1954 supervisor_handover: bool,
1955 }
1956 let decoded: Result<SupervisorRequestEnvelope, _> = serde_json::from_slice(&raw);
1957 if decoded.as_ref().ok().is_none_or(|item| item.frame.plan) {
1958 if let Some(field) = plan_frame_companion(&raw) {
1959 let response = DaemonResponseFrame {
1960 ok: false,
1961 result: None,
1962 error: Some(format!(
1963 "invalid_params: plan=true cannot be combined with {field}"
1964 )),
1965 error_detail: Some(serde_json::json!({
1966 "kind": "protocol",
1967 "code": "invalid_params",
1968 "message": format!("plan=true cannot be combined with {field}"),
1969 "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
1970 })),
1971 namespace_mismatch: false,
1972 config_mismatch: false,
1973 served_config_id: Some(dispatcher.config_id().to_string()),
1974 version_mismatch: false,
1975 daemon_protocol_version: PROTOCOL_VERSION,
1976 metrics: None,
1977 request_id: None,
1978 };
1979 if let Ok(payload) = serde_json::to_vec(&response) {
1980 if let Err(error) = write_response_frame(&mut stream, &payload).await {
1981 tracing::debug!(%error, "failed to write plan envelope refusal");
1982 }
1983 }
1984 return;
1985 }
1986 }
1987 let (frame, handover_requested) = match decoded {
1988 Ok(item) => (item.frame, item.supervisor_handover),
1989 Err(e) => {
1990 tracing::debug!(error = %e, "failed to decode daemon request frame");
1991 return;
1992 }
1993 };
1994 let supervisor_probe = frame.probe_only;
1995 let handover_accepted = handover_requested
1996 && supervisor_probe
1997 && production_shutdown
1998 && handover_peer_allowed
1999 && frame.protocol_version == PROTOCOL_VERSION
2000 && !frame.plan
2001 && !frame.metrics_only
2002 && frame.ops.is_empty()
2003 && read_supervisor_marker_claim().is_some_and(|(pid, _)| pid != std::process::id());
2004 let (mut peer_read, mut peer_write) = stream.into_split();
2005
2006 let served_config_id = Some(dispatcher.config_id().to_string());
2007 let resp = if frame.protocol_version != PROTOCOL_VERSION {
2008 let msg = format!(
2009 "daemon protocol mismatch: client={} daemon={} — \
2010 rebuild/update the client binary (make local)",
2011 frame.protocol_version, PROTOCOL_VERSION,
2012 );
2013 tracing::warn!(
2014 client_version = frame.protocol_version,
2015 daemon_version = PROTOCOL_VERSION,
2016 "daemon protocol version mismatch"
2017 );
2018 DaemonResponseFrame {
2019 ok: false,
2020 result: None,
2021 error: Some(msg.clone()),
2022 error_detail: Some(serde_json::json!({
2023 "kind": "protocol",
2024 "code": "version_mismatch",
2025 "message": msg,
2026 "domain_disposition": crate::DomainDisposition::Unknown.as_str(),
2027 })),
2028 namespace_mismatch: false,
2029 config_mismatch: false,
2030 served_config_id,
2031 version_mismatch: frame.protocol_version > PROTOCOL_VERSION,
2046 daemon_protocol_version: PROTOCOL_VERSION,
2047 metrics: None,
2048 request_id: frame.request_id,
2049 }
2050 } else if handover_requested {
2051 if handover_accepted {
2052 DaemonResponseFrame {
2053 ok: true,
2054 result: None,
2055 error: None,
2056 error_detail: None,
2057 namespace_mismatch: false,
2058 config_mismatch: false,
2059 served_config_id,
2060 version_mismatch: false,
2061 daemon_protocol_version: PROTOCOL_VERSION,
2062 metrics: None,
2063 request_id: frame.request_id,
2064 }
2065 } else {
2066 DaemonResponseFrame {
2067 ok: false,
2068 result: None,
2069 error: Some("supervisor handover refused".to_string()),
2070 error_detail: Some(serde_json::json!({
2071 "kind": "protocol",
2072 "code": "supervisor_handover_refused",
2073 "message": "supervisor handover refused",
2074 "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
2075 })),
2076 namespace_mismatch: false,
2077 config_mismatch: false,
2078 served_config_id,
2079 version_mismatch: false,
2080 daemon_protocol_version: PROTOCOL_VERSION,
2081 metrics: None,
2082 request_id: frame.request_id,
2083 }
2084 }
2085 } else if frame.metrics_only && !frame.plan {
2086 DaemonResponseFrame {
2093 ok: true,
2094 result: None,
2095 error: None,
2096 error_detail: None,
2097 namespace_mismatch: false,
2098 config_mismatch: false,
2099 served_config_id,
2100 version_mismatch: false,
2101 daemon_protocol_version: PROTOCOL_VERSION,
2102 metrics: Some({
2103 let mut metrics = build_metrics_snapshot(&dispatcher);
2104 metrics.lifecycle = lifecycle.as_ref().map(|state| {
2105 let mut snapshot = state.snapshot();
2106 snapshot.idle_blockers = idle_retirement_blockers(&dispatcher);
2107 snapshot
2108 });
2109 metrics.connections = lifecycle.as_ref().map(|state| state.connections.snapshot());
2110 metrics
2111 }),
2112 request_id: frame.request_id,
2113 }
2114 } else if !config_ids_compatible(&frame.config_id, dispatcher.config_id()) {
2123 DaemonResponseFrame {
2124 ok: false,
2125 result: None,
2126 error: None,
2127 error_detail: Some(serde_json::json!({
2128 "kind": "protocol",
2129 "code": "config_mismatch",
2130 "message": "daemon configuration does not match the request",
2131 "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
2132 })),
2133 namespace_mismatch: false,
2134 config_mismatch: true,
2135 served_config_id,
2136 version_mismatch: false,
2137 daemon_protocol_version: PROTOCOL_VERSION,
2138 metrics: None,
2139 request_id: frame.request_id,
2140 }
2141 } else if frame.plan {
2142 DaemonResponseFrame {
2143 ok: true,
2144 result: Some(dispatcher.plan(&frame.ops)),
2145 error: None,
2146 error_detail: None,
2147 namespace_mismatch: false,
2148 config_mismatch: false,
2149 served_config_id,
2150 version_mismatch: false,
2151 daemon_protocol_version: PROTOCOL_VERSION,
2152 metrics: None,
2153 request_id: None,
2154 }
2155 } else if frame.probe_only {
2156 DaemonResponseFrame {
2160 ok: true,
2161 result: None,
2162 error: None,
2163 error_detail: None,
2164 namespace_mismatch: false,
2165 config_mismatch: false,
2166 served_config_id,
2167 version_mismatch: false,
2168 daemon_protocol_version: PROTOCOL_VERSION,
2169 metrics: None,
2170 request_id: frame.request_id,
2171 }
2172 } else {
2173 if let Some(lifecycle) = &lifecycle {
2174 ordinary_admission = lifecycle.admit();
2175 if ordinary_admission.is_none() {
2176 let refusal = DaemonResponseFrame {
2177 ok: false,
2178 result: None,
2179 error: Some("daemon is draining; request was not admitted".to_owned()),
2180 error_detail: Some(serde_json::json!({
2181 "kind": "runtime", "code": "daemon_draining",
2182 "domain_disposition": crate::DomainDisposition::NotCommitted.as_str(),
2183 })),
2184 request_id: frame.request_id,
2185 daemon_protocol_version: PROTOCOL_VERSION,
2186 namespace_mismatch: false,
2187 config_mismatch: false,
2188 served_config_id: Some(dispatcher.config_id().to_owned()),
2189 version_mismatch: false,
2190 metrics: None,
2191 };
2192 if let Ok(payload) = serde_json::to_vec(&refusal) {
2193 let _ = write_response_frame(&mut peer_write, &payload).await;
2194 }
2195 return;
2196 }
2197 }
2198 let identity = RequestIdentity {
2210 namespace: frame.namespace.clone(),
2211 actor_id: frame.actor_id.clone(),
2212 visible_namespaces: frame.visible_namespaces.clone(),
2213 process_ref: frame.process_ref.clone(),
2214 request_id: frame.request_id,
2215 };
2216 tracing::debug!(
2217 request_id = frame.request_id,
2218 "daemon RequestIdentity constructed"
2219 );
2220 let (read_cancel_tx, read_cancel_rx) = tokio::sync::watch::channel(false);
2221 let read_timeout = dispatcher.request_read_timeout(&frame.ops);
2225 let excluded_embedder_names =
2226 config_id_extra_embedder_exclusions(&frame.config_id, dispatcher.config_id())
2227 .expect("compatible configuration ids must expose their extra embedder sets");
2228 let dispatch = crate::runtime::scope_request_embedder_exclusions(
2229 excluded_embedder_names,
2230 khive_storage::scope_request_read_cancellation(
2231 shutdown,
2232 khive_storage::scope_request_read_cancellation(
2233 read_cancel_rx,
2234 khive_storage::scope_request_read_deadline(
2235 read_timeout,
2236 dispatcher.dispatch_with_error_detail(
2237 frame.ops,
2238 frame.presentation,
2239 frame.presentation_per_op,
2240 frame.format,
2241 frame.format_per_op,
2242 frame.from_wire,
2243 Some(identity),
2244 ),
2245 ),
2246 ),
2247 ),
2248 );
2249 tokio::pin!(dispatch);
2250 let dispatch_result = tokio::select! {
2251 result = &mut dispatch => result,
2252 _ = wait_for_peer_disconnect(&mut peer_read) => {
2253 let _ = read_cancel_tx.send(true);
2254 dispatch.await
2255 }
2256 };
2257 match dispatch_result {
2258 Ok(result) => {
2259 let (result, detail) = take_daemon_lexical_timeout_marker(result);
2260 DaemonResponseFrame {
2261 ok: true,
2262 result: Some(result),
2263 error: None,
2264 error_detail: detail,
2265 namespace_mismatch: false,
2266 config_mismatch: false,
2267 served_config_id,
2268 version_mismatch: false,
2269 daemon_protocol_version: PROTOCOL_VERSION,
2270 metrics: None,
2271 request_id: frame.request_id,
2272 }
2273 }
2274 Err(error) => {
2275 let error = DaemonDispatchError::new(error.message, Some(error.error_detail));
2276 DaemonResponseFrame {
2277 ok: false,
2278 result: None,
2279 error: Some(error.message),
2280 error_detail: Some(error.error_detail),
2281 namespace_mismatch: false,
2282 config_mismatch: false,
2283 served_config_id,
2284 version_mismatch: false,
2285 daemon_protocol_version: PROTOCOL_VERSION,
2286 metrics: None,
2287 request_id: frame.request_id,
2288 }
2289 }
2290 }
2291 };
2292
2293 let payload = if supervisor_probe {
2294 serde_json::to_value(&resp).and_then(|mut value| {
2295 if let Some(claim) = current_supervisor_claim() {
2296 value["supervisor_claim"] = serde_json::Value::String(claim);
2297 }
2298 if handover_accepted {
2299 value["supervisor_handover_accepted"] = serde_json::Value::Bool(true);
2300 }
2301 serde_json::to_vec(&value)
2302 })
2303 } else {
2304 serde_json::to_vec(&resp)
2305 };
2306 let mut handover_ack_written = false;
2307 match payload {
2308 Ok(payload) => {
2309 if payload.len() > MAX_FRAME_BYTES {
2310 tracing::warn!(
2317 bytes = payload.len(),
2318 limit = MAX_FRAME_BYTES,
2319 "daemon response exceeds MAX_FRAME_BYTES; sending explicit error frame"
2320 );
2321 let message = format!(
2322 "response too large: {} bytes exceeds {} byte IPC cap",
2323 payload.len(),
2324 MAX_FRAME_BYTES,
2325 );
2326 let err_resp = DaemonResponseFrame {
2328 ok: false,
2329 result: None,
2330 error: Some(message.clone()),
2331 error_detail: Some(serde_json::json!({
2332 "kind": "transport",
2333 "code": "response_frame_size_limit",
2334 "message": message,
2335 "domain_disposition": crate::DomainDisposition::Unknown.as_str(),
2336 })),
2337 namespace_mismatch: false,
2338 config_mismatch: false,
2339 served_config_id: resp.served_config_id,
2340 version_mismatch: false,
2341 daemon_protocol_version: PROTOCOL_VERSION,
2342 metrics: None,
2343 request_id: resp.request_id,
2344 };
2345 if let Ok(err_payload) = serde_json::to_vec(&err_resp) {
2346 if let Err(e) = write_response_frame(&mut peer_write, &err_payload).await {
2347 tracing::debug!(error = %e, "failed to write oversized-response error frame");
2348 }
2349 }
2350 } else {
2351 match write_response_frame(&mut peer_write, &payload).await {
2352 Ok(()) => handover_ack_written = true,
2353 Err(e) => tracing::debug!(error = %e, "failed to write daemon response frame"),
2354 }
2355 }
2356 }
2357 Err(e) => tracing::warn!(error = %e, "failed to serialize daemon response frame"),
2358 }
2359 if handover_accepted && handover_ack_written {
2360 if unsafe { libc::raise(libc::SIGTERM) } != 0 {
2363 tracing::error!(error = %std::io::Error::last_os_error(), "self-directed handover signal failed");
2364 }
2365 }
2366 drop(ordinary_admission);
2367}
2368
2369#[cfg(unix)]
2373struct ActiveConnectionGuard {
2374 active: Arc<std::sync::atomic::AtomicUsize>,
2375}
2376
2377#[cfg(unix)]
2378impl ActiveConnectionGuard {
2379 fn claim(active: Arc<std::sync::atomic::AtomicUsize>) -> Self {
2380 active.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2381 Self { active }
2382 }
2383}
2384
2385#[cfg(unix)]
2386impl Drop for ActiveConnectionGuard {
2387 fn drop(&mut self) {
2388 self.active
2389 .fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
2390 }
2391}
2392
2393#[cfg(unix)]
2394fn spawn_connection_task<F>(
2395 active: Arc<std::sync::atomic::AtomicUsize>,
2396 future: F,
2397) -> tokio::task::JoinHandle<()>
2398where
2399 F: std::future::Future<Output = ()> + Send + 'static,
2400{
2401 let guard = ActiveConnectionGuard::claim(active);
2402 tokio::spawn(async move {
2403 let _guard = guard;
2404 future.await;
2405 })
2406}
2407
2408#[cfg(unix)]
2419pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
2420 let boot_guard = Some(acquire_daemon_boot_guard()?);
2421 run_daemon_with_boot_guard_inner(
2422 dispatcher,
2423 boot_guard,
2424 false,
2425 DaemonOptions::default(),
2426 |_| DaemonStartupReport::default(),
2427 )
2428 .await
2429}
2430
2431#[cfg(all(unix, any(test, feature = "fault-injection")))]
2446#[doc(hidden)]
2447pub async fn run_daemon_in_process_test<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
2448 let boot_guard = Some(acquire_daemon_boot_guard()?);
2449 run_daemon_with_boot_guard_inner(
2450 dispatcher,
2451 boot_guard,
2452 true,
2453 DaemonOptions::default(),
2454 |_| DaemonStartupReport::default(),
2455 )
2456 .await
2457}
2458
2459#[cfg(unix)]
2460#[derive(Clone, Copy, PartialEq, Eq)]
2461enum RendezvousPathRole {
2462 Socket,
2463 PidFile,
2464}
2465
2466#[cfg(unix)]
2467impl RendezvousPathRole {
2468 fn env_name(self) -> &'static str {
2469 match self {
2470 Self::Socket => SOCKET_PATH_ENV,
2471 Self::PidFile => PID_PATH_ENV,
2472 }
2473 }
2474
2475 fn directory_name(self) -> &'static str {
2476 match self {
2477 Self::Socket => "socket directory",
2478 Self::PidFile => "PID-file directory",
2479 }
2480 }
2481
2482 fn path_name(self) -> &'static str {
2483 match self {
2484 Self::Socket => "socket path",
2485 Self::PidFile => "PID-file path",
2486 }
2487 }
2488
2489 fn path_component_name(self) -> &'static str {
2490 match self {
2491 Self::Socket => "socket-path",
2492 Self::PidFile => "PID-file-path",
2493 }
2494 }
2495}
2496
2497#[cfg(unix)]
2520pub(crate) fn ensure_socket_dir_is_trusted(parent: &std::path::Path) -> anyhow::Result<()> {
2521 let daemon_euid = unsafe { libc::geteuid() } as u32;
2523 ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::Socket, daemon_euid, true)
2524}
2525
2526#[cfg(unix)]
2531pub fn ensure_pid_file_dir_is_trusted(pid_file: &std::path::Path) -> anyhow::Result<()> {
2532 let parent = pid_file
2533 .parent()
2534 .filter(|parent| !parent.as_os_str().is_empty())
2535 .unwrap_or_else(|| std::path::Path::new("."));
2536 let daemon_euid = unsafe { libc::geteuid() } as u32;
2538 ensure_rendezvous_dir_is_trusted(parent, RendezvousPathRole::PidFile, daemon_euid, false)
2539}
2540
2541#[cfg(unix)]
2542fn ensure_rendezvous_dir_is_trusted(
2543 parent: &std::path::Path,
2544 role: RendezvousPathRole,
2545 daemon_euid: u32,
2546 repair_owned_default: bool,
2547) -> anyhow::Result<()> {
2548 let env_name = role.env_name();
2549 let directory_name = role.directory_name();
2550
2551 if repair_owned_default && parent == khive_dir() {
2552 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(|e| {
2553 anyhow::anyhow!(
2554 "refusing to start: cannot chmod 0700 {}: {e}. The khive directory must be \
2555 owner-only as the {directory_name} for {env_name}; it is part of the \
2556 same-uid guarantee this daemon enforces.",
2557 parent.display()
2558 )
2559 })?;
2560 return ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role);
2561 }
2562
2563 let meta = std::fs::metadata(parent).map_err(|e| {
2566 anyhow::anyhow!(
2567 "refusing to start: cannot stat {directory_name} {} for {env_name}: {e}. \
2568 It gates rendezvous-path safety, and unreadable metadata is not a passing state.",
2569 parent.display()
2570 )
2571 })?;
2572
2573 use std::os::unix::fs::MetadataExt;
2574 let owner = meta.uid();
2575 if owner != daemon_euid && owner != 0 {
2576 anyhow::bail!(
2577 "refusing to start: {directory_name} {} for {env_name} is owned by uid {owner}, \
2578 not this daemon's uid ({daemon_euid}) or root. A directory owner can replace \
2579 the rendezvous path regardless of mode bits. Point {env_name} at a directory \
2580 you own, or unset it for the default.",
2581 parent.display()
2582 );
2583 }
2584
2585 let mode = meta.permissions().mode();
2586 if mode & 0o022 != 0 {
2587 anyhow::bail!(
2588 "refusing to start: {directory_name} {} for {env_name} is mode {:04o} — writable \
2589 by group or other, so another local user could replace the rendezvous path. \
2590 Use a directory only you can write, or unset {env_name} for the default. \
2591 This daemon is not changing the permissions of a directory it does not own.",
2592 parent.display(),
2593 mode & 0o7777
2594 );
2595 }
2596
2597 ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, role)
2598}
2599
2600#[cfg(all(unix, test))]
2603fn ensure_socket_path_is_swap_resistant(
2604 parent: &std::path::Path,
2605 daemon_euid: u32,
2606) -> anyhow::Result<()> {
2607 ensure_rendezvous_path_is_swap_resistant(parent, daemon_euid, RendezvousPathRole::Socket)
2608}
2609
2610#[cfg(unix)]
2635fn ensure_rendezvous_path_is_swap_resistant(
2636 parent: &std::path::Path,
2637 daemon_euid: u32,
2638 role: RendezvousPathRole,
2639) -> anyhow::Result<()> {
2640 use std::os::unix::fs::MetadataExt;
2641
2642 let env_name = role.env_name();
2643 let directory_name = role.directory_name();
2644 let path_name = role.path_name();
2645 let component_name = role.path_component_name();
2646
2647 let absolute = if parent.is_absolute() {
2648 parent.to_path_buf()
2649 } else {
2650 std::env::current_dir()
2651 .map_err(|e| {
2652 anyhow::anyhow!(
2653 "refusing to start: cannot resolve the working directory to absolutize \
2654 {directory_name} {} for {env_name}: {e}.",
2655 parent.display()
2656 )
2657 })?
2658 .join(parent)
2659 };
2660
2661 fn push_components(stack: &mut Vec<std::ffi::OsString>, path: &std::path::Path) {
2662 let components: Vec<_> = path
2663 .components()
2664 .map(|c| c.as_os_str().to_os_string())
2665 .collect();
2666 stack.extend(components.into_iter().rev());
2667 }
2668
2669 let mut stack: Vec<std::ffi::OsString> = Vec::new();
2670 push_components(&mut stack, &absolute);
2671 let mut resolved = std::path::PathBuf::new();
2672 let mut symlinks_followed = 0u32;
2673
2674 while let Some(component) = stack.pop() {
2675 if component == "/" {
2676 resolved = std::path::PathBuf::from("/");
2677 continue;
2678 }
2679 if component == "." {
2680 continue;
2681 }
2682 if component == ".." {
2683 resolved.pop();
2684 continue;
2685 }
2686 let candidate = resolved.join(&component);
2687 let meta = std::fs::symlink_metadata(&candidate).map_err(|e| {
2688 anyhow::anyhow!(
2689 "refusing to start: cannot stat {component_name} component {} for {env_name}: \
2690 {e}. An unreadable component is not a passing one.",
2691 candidate.display()
2692 )
2693 })?;
2694 let owner = meta.uid();
2695
2696 if meta.file_type().is_symlink() {
2697 symlinks_followed += 1;
2698 if symlinks_followed > 40 {
2699 anyhow::bail!(
2700 "refusing to start: {path_name} for {env_name} resolves through more than \
2701 40 symlinks at {} — treating this as a loop.",
2702 candidate.display()
2703 );
2704 }
2705 if owner != daemon_euid && owner != 0 {
2706 anyhow::bail!(
2707 "refusing to start: {component_name} symlink component {} for {env_name} \
2708 is owned by uid {owner}, not this daemon's uid ({daemon_euid}) or root — \
2709 its owner could retarget it after this check and re-root the {path_name}. \
2710 Point {env_name} somewhere trusted end to end, or unset it for the default.",
2711 candidate.display()
2712 );
2713 }
2714 let target = std::fs::read_link(&candidate).map_err(|e| {
2715 anyhow::anyhow!(
2716 "refusing to start: cannot read {component_name} symlink component {} \
2717 for {env_name}: {e}.",
2718 candidate.display()
2719 )
2720 })?;
2721 push_components(&mut stack, &target);
2722 continue;
2723 }
2724
2725 if meta.is_dir() {
2726 let mode = meta.permissions().mode();
2727 let sticky = mode & 0o1000 != 0;
2728 if owner != daemon_euid && owner != 0 {
2729 anyhow::bail!(
2730 "refusing to start: {component_name} ancestor {} for {env_name} is owned by \
2731 uid {owner}, not this daemon's uid ({daemon_euid}) or root — its owner \
2732 could rename the next path component and re-root the {path_name}. Point \
2733 {env_name} somewhere trusted end to end, or unset it for the default.",
2734 candidate.display()
2735 );
2736 }
2737 if mode & 0o022 != 0 && !sticky {
2738 anyhow::bail!(
2739 "refusing to start: {component_name} ancestor {} for {env_name} is mode \
2740 {:04o} — writable by group or other without the sticky bit, so another \
2741 local user could rename the next path component and re-root the {path_name}. \
2742 Point {env_name} somewhere trusted end to end, or unset it for the default.",
2743 candidate.display(),
2744 mode & 0o7777
2745 );
2746 }
2747 resolved = candidate;
2748 continue;
2749 }
2750
2751 anyhow::bail!(
2752 "refusing to start: {component_name} component {} for {env_name} is neither a \
2753 directory nor a symlink — the {path_name} cannot traverse it.",
2754 candidate.display()
2755 );
2756 }
2757
2758 Ok(())
2759}
2760
2761#[cfg(unix)]
2774pub async fn run_daemon_with_boot_guard<D: DaemonDispatch>(
2775 dispatcher: D,
2776 boot_guard: Option<std::fs::File>,
2777) -> anyhow::Result<()> {
2778 run_daemon_with_boot_guard_inner(
2779 dispatcher,
2780 boot_guard,
2781 false,
2782 DaemonOptions::default(),
2783 |_| DaemonStartupReport::default(),
2784 )
2785 .await
2786}
2787
2788#[cfg(unix)]
2794pub async fn run_daemon_with_boot_guard_and_start<D, F>(
2795 dispatcher: D,
2796 boot_guard: Option<std::fs::File>,
2797 start: F,
2798) -> anyhow::Result<()>
2799where
2800 D: DaemonDispatch,
2801 F: FnOnce(&D) + Send,
2802{
2803 run_daemon_with_options_and_boot_guard_and_start(
2804 dispatcher,
2805 boot_guard,
2806 DaemonOptions::default(),
2807 |dispatcher| {
2808 start(dispatcher);
2809 DaemonStartupReport::default()
2810 },
2811 )
2812 .await
2813}
2814
2815#[cfg(unix)]
2817pub async fn run_daemon_with_options_and_boot_guard_and_start<D, F>(
2818 dispatcher: D,
2819 boot_guard: Option<std::fs::File>,
2820 options: DaemonOptions,
2821 start: F,
2822) -> anyhow::Result<()>
2823where
2824 D: DaemonDispatch,
2825 F: FnOnce(&D) -> DaemonStartupReport + Send,
2826{
2827 anyhow::ensure!(
2828 !options.idle_interval.is_zero(),
2829 "daemon idle interval must be positive"
2830 );
2831 run_daemon_with_boot_guard_inner(dispatcher, boot_guard, false, options, start).await
2832}
2833
2834#[cfg(unix)]
2835async fn run_daemon_with_boot_guard_inner<D, F>(
2836 dispatcher: D,
2837 boot_guard: Option<std::fs::File>,
2838 allow_same_process_incumbent: bool,
2839 options: DaemonOptions,
2840 start: F,
2841) -> anyhow::Result<()>
2842where
2843 D: DaemonDispatch,
2844 F: FnOnce(&D) -> DaemonStartupReport + Send,
2845{
2846 struct ComponentTeardown;
2851 impl Drop for ComponentTeardown {
2852 fn drop(&mut self) {
2853 daemon_shutdown_token().cancel();
2854 }
2855 }
2856 let _component_teardown = ComponentTeardown;
2857
2858 ensure_rendezvous_overrides_paired()?;
2862
2863 let sock = socket_path();
2864 let pid_file = pid_path();
2865 let socket_parent = sock.parent();
2866 let pid_parent = pid_file.parent();
2867
2868 if let Some(parent) = socket_parent {
2869 std::fs::create_dir_all(parent)?;
2870 ensure_socket_dir_is_trusted(parent)?;
2871 }
2872 if pid_parent != socket_parent {
2876 ensure_pid_file_dir_is_trusted(&pid_file)?;
2877 }
2878
2879 let _startup_lock = boot_guard;
2893
2894 match cleanup_stale_daemon(
2897 &sock,
2898 &pid_file,
2899 allow_same_process_incumbent,
2900 dispatcher.config_id(),
2901 )
2902 .await
2903 {
2904 Incumbent::Serving(incumbent_pid) => {
2905 tracing::error!(
2906 pid = incumbent_pid,
2907 socket = ?sock,
2908 "refusing to start: a khived instance is already serving this socket"
2909 );
2910 anyhow::bail!(
2911 "refusing to start: khived is already running as pid {incumbent_pid}, \
2912 serving socket {}. Stop that instance first if you intend to replace it.",
2913 sock.display()
2914 );
2915 }
2916 Incumbent::Live(incumbent_pid) => {
2917 tracing::error!(
2918 pid = incumbent_pid,
2919 socket = ?sock,
2920 "refusing to start: a live process owns the PID file but no khived answered"
2921 );
2922 anyhow::bail!(
2923 "refusing to start: pid {incumbent_pid} owns the daemon PID file and is alive, \
2924 but nothing answered the khived protocol on {}. It may be draining. Nothing \
2925 was removed; stop that process first if you intend to replace it.",
2926 sock.display()
2927 );
2928 }
2929 Incumbent::Stale => {}
2930 }
2931
2932 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2937 let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2938
2939 let pid_file_guard = match write_pid_file_exclusive(&pid_file) {
2940 Ok(guard) => guard,
2941 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
2942 if pid_file_names_a_reachable_daemon(
2945 &pid_file,
2946 &sock,
2947 allow_same_process_incumbent,
2948 dispatcher.config_id(),
2949 )
2950 .await
2951 {
2952 tracing::info!(
2953 "a replacement khived already claimed the pid/socket rendezvous; exiting"
2954 );
2955 return Ok(());
2956 }
2957 anyhow::bail!(
2958 "failed to claim daemon pid file at {pid_file:?}: it already exists \
2959 and does not name a reachable daemon"
2960 );
2961 }
2962 Err(e) => return Err(e.into()),
2963 };
2964
2965 let listener = match UnixListener::bind(&sock) {
2966 Ok(listener) => listener,
2967 Err(e) => {
2968 remove_pid_file_if_owned(&pid_file, &pid_file_guard);
2969 return Err(e.into());
2970 }
2971 };
2972 if let Err(e) = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)) {
2976 drop(listener);
2977 let _ = std::fs::remove_file(&sock);
2978 remove_pid_file_if_owned(&pid_file, &pid_file_guard);
2979 return Err(anyhow::anyhow!(
2980 "refusing to start: cannot chmod 0600 {}: {e}. The daemon socket must be owner-only \
2981 — it is half of the single-principal guarantee this daemon enforces.",
2982 sock.display()
2983 ));
2984 }
2985 let bound_identity = socket_identity(&sock);
2989
2990 let lifecycle = Arc::new(DaemonLifecycle::new(options, start(&dispatcher)));
2991
2992 drop(_startup_lock);
2995 tracing::info!(
2996 socket = ?sock,
2997 pid = std::process::id(),
2998 source_revision = crate::BUILD_INFO.source_revision,
2999 build_time = crate::BUILD_INFO.build_time,
3000 "khived listening"
3001 );
3002
3003 {
3004 let warm = dispatcher.clone();
3005 track_named_background_task("daemon_warmup", async move {
3006 warm.warm_all().await;
3007 });
3008 }
3009
3010 let (checkpoint_shutdown_tx, checkpoint_shutdown_rx) = tokio::sync::watch::channel(());
3018 let checkpoint_tasks = checkpoint_task_specs(
3024 dispatcher.pool_for_checkpoint(),
3025 dispatcher.secondary_pools_for_checkpoint(),
3026 dispatcher.event_store_for_checkpoint(),
3027 dispatcher.namespace().to_string(),
3028 );
3029 if !checkpoint_tasks.is_empty() {
3030 let cfg = CheckpointConfig::from_env();
3031 let checkpoint_task_count = checkpoint_tasks.len();
3032 for task in checkpoint_tasks {
3033 track_named_background_task(
3034 "wal_checkpoint",
3035 run_checkpoint_task(
3036 task.pool,
3037 cfg.clone(),
3038 task.lifecycle_owner,
3039 checkpoint_shutdown_rx.clone(),
3040 task.is_main,
3041 ),
3042 );
3043 }
3044 tracing::info!(checkpoint_task_count, "WAL checkpoint task(s) started");
3045 }
3046
3047 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3048 let connection_tasks = Arc::new(std::sync::Mutex::new(
3049 Vec::<tokio::task::JoinHandle<()>>::new(),
3050 ));
3051 let (request_shutdown_tx, request_shutdown_rx) = tokio::sync::watch::channel(false);
3052
3053 let shutdown = async {
3054 tokio::select! {
3055 _ = sigterm.recv() => tracing::info!("received SIGTERM"),
3056 _ = sigint.recv() => tracing::info!("received SIGINT"),
3057 }
3058 for signal in [libc::SIGTERM, libc::SIGINT] {
3062 if unsafe { libc::signal(signal, libc::SIG_DFL) } == libc::SIG_ERR {
3065 return Err(std::io::Error::last_os_error());
3066 }
3067 }
3068 Ok::<(), std::io::Error>(())
3069 };
3070 tokio::pin!(shutdown);
3071 lifecycle.ready();
3072
3073 let daemon_euid = unsafe { libc::geteuid() } as u32;
3075
3076 let reason = tokio::select! {
3077 _ = async {
3078 let mut accept_error_backoff = None;
3079 let mut last_accept_error_log: Option<std::time::Instant> = None;
3080 loop {
3081 match listener.accept().await {
3082 Ok((mut stream, _)) => {
3083 let initial_frame_deadline =
3084 tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT;
3085 accept_error_backoff = None;
3086 last_accept_error_log = None;
3087 match peer_uid(&stream) {
3101 Ok(peer) if uid_is_permitted(peer, daemon_euid) => {}
3102 Ok(peer) => {
3103 tracing::error!(
3104 peer_uid = peer,
3105 daemon_euid,
3106 "refusing connection from a foreign uid: this daemon accepts \
3107 only peers running as its own uid"
3108 );
3109 drop(stream);
3110 continue;
3111 }
3112 Err(e) => {
3113 tracing::error!(
3114 error = %e,
3115 "refusing connection: cannot read peer credentials, so \
3116 same-uid cannot be proven"
3117 );
3118 drop(stream);
3119 continue;
3120 }
3121 }
3122 let Some(permit) = admit_or_refuse_busy(
3127 &lifecycle.connections,
3128 &mut stream,
3129 dispatcher.config_id(),
3130 )
3131 .await
3132 else {
3133 continue;
3134 };
3135 let d = dispatcher.clone();
3138 let shutdown = request_shutdown_rx.clone();
3139 let lifecycle = Arc::clone(&lifecycle);
3140 let handle = spawn_connection_task(Arc::clone(&active), async move {
3141 let _permit = permit;
3144 handle_conn_with_lifecycle(
3145 stream,
3146 d,
3147 Some(shutdown),
3148 initial_frame_deadline,
3149 Some(lifecycle),
3150 )
3151 .await;
3152 });
3153 let mut tasks = connection_tasks
3154 .lock()
3155 .unwrap_or_else(std::sync::PoisonError::into_inner);
3156 tasks.retain(|task| !task.is_finished());
3157 tasks.push(handle);
3158 }
3159 Err(e) => {
3160 let delay = next_accept_error_backoff(accept_error_backoff);
3161 accept_error_backoff = Some(delay);
3162 let capacity_exhausted = matches!(
3163 e.raw_os_error(),
3164 Some(libc::EMFILE) | Some(libc::ENFILE)
3165 );
3166 if last_accept_error_log.is_none_or(|last| {
3167 last.elapsed() >= std::time::Duration::from_secs(30)
3168 }) {
3169 tracing::error!(
3170 error = %e,
3171 capacity_exhausted,
3172 retry_ms = delay.as_millis(),
3173 "daemon accept failed; retrying with bounded backoff"
3174 );
3175 last_accept_error_log = Some(std::time::Instant::now());
3176 }
3177 tokio::time::sleep(delay).await;
3178 }
3179 }
3180 }
3181 } => DaemonShutdownReason::Signal,
3182 result = &mut shutdown => { result?; DaemonShutdownReason::Signal },
3183 _ = wait_for_idle(&dispatcher, &lifecycle) => DaemonShutdownReason::Idle,
3184 };
3185
3186 lifecycle.draining(reason);
3187
3188 drop(listener);
3191
3192 let _ = checkpoint_shutdown_tx.send(());
3196
3197 if reason == DaemonShutdownReason::Signal {
3200 let _ = request_shutdown_tx.send(true);
3201 }
3202
3203 daemon_shutdown_token().cancel();
3207
3208 let drained = if reason == DaemonShutdownReason::Idle {
3209 tokio::select! {
3210 _ = drain_for_idle(&active, drain_timeout()) => true,
3211 result = &mut shutdown => {
3212 result?;
3213 lifecycle.draining(DaemonShutdownReason::Signal);
3214 let _ = request_shutdown_tx.send(true);
3215 drain(&active).await
3216 }
3217 }
3218 } else {
3219 drain(&active).await
3220 };
3221 let tasks = {
3222 let mut retained = connection_tasks
3223 .lock()
3224 .unwrap_or_else(std::sync::PoisonError::into_inner);
3225 std::mem::take(&mut *retained)
3226 };
3227 finish_connection_tasks(tasks, drained).await;
3228
3229 match acquire_recovery_lock() {
3238 Some(_shutdown_lock) => {
3239 shutdown_cleanup_if_owned(&sock, &pid_file, bound_identity);
3240 }
3241 None => {
3242 tracing::warn!(
3243 "could not acquire recovery lock for shutdown cleanup; \
3244 skipping unlink to avoid deleting a replacement daemon's paths"
3245 );
3246 }
3247 }
3248 lifecycle.stopped();
3249 tracing::info!("khived stopped");
3250 Ok(())
3251}
3252
3253#[cfg(unix)]
3263fn shutdown_cleanup_if_owned(
3264 sock: &std::path::Path,
3265 pid_file: &std::path::Path,
3266 bound_identity: Option<SocketIdentity>,
3267) -> bool {
3268 let pid_is_ours = std::fs::read_to_string(pid_file)
3269 .ok()
3270 .and_then(|s| s.trim().parse::<u32>().ok())
3271 == Some(std::process::id());
3272 let socket_is_ours = bound_identity.is_some() && socket_identity(sock) == bound_identity;
3273 if pid_is_ours && socket_is_ours {
3274 let _ = std::fs::remove_file(sock);
3275 let _ = std::fs::remove_file(pid_file);
3276 true
3277 } else {
3278 tracing::warn!(
3279 socket = ?sock,
3280 pid_file = ?pid_file,
3281 "skipping shutdown cleanup — a replacement daemon already owns this socket/PID"
3282 );
3283 false
3284 }
3285}
3286
3287#[cfg(unix)]
3291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3292enum PidLiveness {
3293 Alive,
3296 Dead,
3298 PermissionDenied,
3303}
3304
3305#[cfg(unix)]
3306impl PidLiveness {
3307 fn is_running(self) -> bool {
3308 !matches!(self, PidLiveness::Dead)
3309 }
3310}
3311
3312#[cfg(unix)]
3316fn classify_kill_result(rc: i32, errno: i32) -> PidLiveness {
3317 if rc == 0 {
3318 return PidLiveness::Alive;
3319 }
3320 match errno {
3321 libc::EPERM => PidLiveness::PermissionDenied,
3322 _ => PidLiveness::Dead,
3323 }
3324}
3325
3326#[cfg(unix)]
3327fn is_process_running(pid: u32) -> bool {
3328 let Ok(pid) = i32::try_from(pid) else {
3329 return false;
3330 };
3331 if pid <= 0 {
3332 return false;
3333 }
3334 let rc = unsafe { libc::kill(pid, 0) };
3336 let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
3337 classify_kill_result(rc, errno).is_running()
3338}
3339
3340#[cfg(unix)]
3347fn pid_can_name_incumbent(pid: u32, current_pid: u32, allow_same_process_incumbent: bool) -> bool {
3348 allow_same_process_incumbent || pid != current_pid
3349}
3350
3351#[cfg(unix)]
3356const DUPLICATE_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
3357
3358#[cfg(unix)]
3389async fn socket_speaks_khived_protocol(sock: &std::path::Path, expected_config_id: &str) -> bool {
3390 let probe = DaemonRequestFrame {
3391 probe_only: true,
3392 protocol_version: PROTOCOL_VERSION,
3393 config_id: expected_config_id.to_string(),
3394 ..Default::default()
3395 };
3396 let Ok(payload) = serde_json::to_vec(&probe) else {
3397 return false;
3398 };
3399 let response = tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, async {
3400 let mut stream = UnixStream::connect(sock).await.ok()?;
3401 write_frame(&mut stream, &payload).await.ok()?;
3402 let raw = read_frame(&mut stream).await.ok()?;
3403 serde_json::from_slice::<DaemonResponseFrame>(&raw).ok()
3404 })
3405 .await
3406 .ok()
3407 .flatten();
3408
3409 let Some(resp) = response else {
3410 return false;
3411 };
3412 let is_probe_ack = resp.ok
3413 && resp.result.is_none()
3414 && resp.error.is_none()
3415 && resp.metrics.is_none()
3416 && resp.request_id.is_none();
3417 is_probe_ack
3418 && !resp.version_mismatch
3419 && !resp.namespace_mismatch
3420 && !resp.config_mismatch
3421 && resp.daemon_protocol_version == PROTOCOL_VERSION
3422 && resp
3423 .served_config_id
3424 .as_deref()
3425 .is_some_and(|served| config_ids_compatible(expected_config_id, served))
3426}
3427
3428#[cfg(unix)]
3431async fn socket_is_unreachable(sock: &std::path::Path) -> bool {
3432 match tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, UnixStream::connect(sock)).await {
3433 Ok(Err(error)) => matches!(
3434 error.kind(),
3435 std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
3436 ),
3437 _ => false,
3438 }
3439}
3440
3441#[cfg(unix)]
3446enum Incumbent {
3447 Serving(u32),
3449 Live(u32),
3451 Stale,
3453}
3454
3455#[cfg(unix)]
3462async fn cleanup_stale_daemon(
3463 sock: &std::path::Path,
3464 pid_file: &std::path::Path,
3465 allow_same_process_incumbent: bool,
3466 expected_config_id: &str,
3467) -> Incumbent {
3468 let mut stale_pid_file_guard = None;
3469 if let Ok(pid_str) = std::fs::read_to_string(pid_file) {
3470 if let Ok(pid) = pid_str.trim().parse::<u32>() {
3471 if pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
3472 && is_process_running(pid)
3473 {
3474 if sock.exists() && socket_speaks_khived_protocol(sock, expected_config_id).await {
3475 return Incumbent::Serving(pid);
3476 }
3477 if sock.exists() && !socket_is_unreachable(sock).await {
3478 return Incumbent::Live(pid);
3479 }
3480 match try_acquire_pid_file_lock(pid_file) {
3481 Ok(Some(guard)) => stale_pid_file_guard = Some(guard),
3482 Ok(None) => return Incumbent::Live(pid),
3483 Err(e) => {
3484 tracing::warn!(
3485 error = %e,
3486 path = ?pid_file,
3487 "cannot check daemon PID-file lock"
3488 );
3489 return Incumbent::Live(pid);
3490 }
3491 }
3492 }
3493 }
3494 }
3495 if sock.exists() {
3496 if let Err(e) = std::fs::remove_file(sock) {
3497 tracing::warn!(error = %e, path = ?sock, "failed to remove stale socket");
3498 }
3499 }
3500 if pid_file.exists() {
3501 if let Err(e) = std::fs::remove_file(pid_file) {
3502 tracing::warn!(error = %e, path = ?pid_file, "failed to remove stale PID file");
3503 }
3504 }
3505 drop(stale_pid_file_guard);
3506 Incumbent::Stale
3507}
3508
3509#[cfg(unix)]
3516fn write_pid_file_exclusive(pid_file: &std::path::Path) -> std::io::Result<std::fs::File> {
3517 use std::os::unix::fs::OpenOptionsExt;
3518
3519 let mut opts = std::fs::OpenOptions::new();
3520 opts.write(true).create_new(true).mode(0o600);
3521 let mut f = opts.open(pid_file)?;
3522 let rc = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
3524 if rc != 0 {
3525 return Err(std::io::Error::last_os_error());
3526 }
3527 f.write_all(std::process::id().to_string().as_bytes())?;
3528 Ok(f)
3529}
3530
3531#[cfg(unix)]
3534fn try_acquire_pid_file_lock(pid_file: &std::path::Path) -> std::io::Result<Option<std::fs::File>> {
3535 let file = std::fs::OpenOptions::new()
3536 .read(true)
3537 .write(true)
3538 .open(pid_file)?;
3539 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
3541 if rc == 0 {
3542 return Ok(Some(file));
3543 }
3544 let error = std::io::Error::last_os_error();
3545 if error.kind() == std::io::ErrorKind::WouldBlock
3546 || error.raw_os_error() == Some(libc::EWOULDBLOCK)
3547 {
3548 Ok(None)
3549 } else {
3550 Err(error)
3551 }
3552}
3553
3554#[cfg(unix)]
3557fn remove_pid_file_if_owned(pid_file: &std::path::Path, guard: &std::fs::File) {
3558 let Ok(owned) = guard.metadata() else {
3559 return;
3560 };
3561 let Ok(current) = std::fs::metadata(pid_file) else {
3562 return;
3563 };
3564 if owned.dev() == current.dev() && owned.ino() == current.ino() {
3565 if let Err(e) = std::fs::remove_file(pid_file) {
3566 tracing::warn!(error = %e, path = ?pid_file, "failed to remove unbound PID file");
3567 }
3568 }
3569}
3570
3571#[cfg(unix)]
3577async fn pid_file_names_a_reachable_daemon(
3578 pid_file: &std::path::Path,
3579 sock: &std::path::Path,
3580 allow_same_process_incumbent: bool,
3581 expected_config_id: &str,
3582) -> bool {
3583 let Ok(pid_str) = std::fs::read_to_string(pid_file) else {
3584 return false;
3585 };
3586 let Ok(pid) = pid_str.trim().parse::<u32>() else {
3587 return false;
3588 };
3589 pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
3590 && is_process_running(pid)
3591 && sock.exists()
3592 && socket_speaks_khived_protocol(sock, expected_config_id).await
3593}
3594
3595#[cfg(unix)]
3596async fn drain(active: &std::sync::atomic::AtomicUsize) -> bool {
3597 drain_with_timeout(active, drain_timeout()).await
3598}
3599
3600#[cfg(unix)]
3602async fn drain_for_idle(active: &std::sync::atomic::AtomicUsize, timeout: std::time::Duration) {
3603 let deadline = tokio::time::Instant::now() + timeout;
3604 let mut warned = false;
3605 while active.load(std::sync::atomic::Ordering::SeqCst) + background_task_count() != 0 {
3606 if !warned && tokio::time::Instant::now() >= deadline {
3607 tracing::warn!(
3608 "idle drain interval elapsed; retaining workers and rendezvous until settled"
3609 );
3610 warned = true;
3611 }
3612 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
3613 }
3614}
3615
3616#[cfg(unix)]
3617async fn drain_with_timeout(
3618 active: &std::sync::atomic::AtomicUsize,
3619 timeout: std::time::Duration,
3620) -> bool {
3621 use std::sync::atomic::Ordering;
3622 let remaining = || active.load(Ordering::SeqCst) + background_task_count();
3626 if remaining() == 0 {
3627 return true;
3628 }
3629 let deadline = tokio::time::Instant::now() + timeout;
3630 while remaining() > 0 {
3631 if tokio::time::Instant::now() >= deadline {
3632 tracing::warn!(
3633 remaining_connections = active.load(Ordering::SeqCst),
3634 remaining_background_tasks = background_task_count(),
3635 outstanding_background_tasks = %background_task_names().join(", "),
3636 "drain timeout reached; forcing shutdown"
3637 );
3638 return false;
3639 }
3640 tokio::select! {
3641 _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
3642 _ = tokio::time::sleep_until(deadline) => {}
3643 }
3644 }
3645 true
3646}
3647
3648#[cfg(unix)]
3649async fn finish_connection_tasks(tasks: Vec<tokio::task::JoinHandle<()>>, drained: bool) {
3650 if !drained {
3651 for task in &tasks {
3652 if !task.is_finished() {
3653 task.abort();
3654 }
3655 }
3656 }
3657 for task in tasks {
3658 let _ = task.await;
3659 }
3660}
3661
3662pub fn drain_timeout() -> std::time::Duration {
3668 let secs = std::env::var("KHIVE_DRAIN_TIMEOUT_SECS")
3669 .ok()
3670 .and_then(|v| v.parse::<u64>().ok())
3671 .unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS);
3672 std::time::Duration::from_secs(secs)
3673}
3674
3675#[cfg(unix)]
3677pub fn env_truthy(key: &str) -> bool {
3678 std::env::var(key)
3679 .map(|v| {
3680 let v = v.trim();
3681 !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
3682 })
3683 .unwrap_or(false)
3684}
3685
3686include!("daemon_khive_root_tests.rs");
3687
3688#[cfg(all(unix, any(test, feature = "test-internals")))]
3694#[doc(hidden)]
3695pub async fn serve_connection_for_test<D: DaemonDispatch>(stream: UnixStream, dispatcher: D) {
3696 handle_conn_with_shutdown(
3697 stream,
3698 dispatcher,
3699 None,
3700 tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
3701 )
3702 .await;
3703}
3704
3705#[cfg(all(test, unix))]
3706mod tests {
3707 include!("daemon/plan_tests.rs");
3708 mod shutdown_signals {
3709 include!("daemon/shutdown_signal_tests.rs");
3710 }
3711 mod connection_limit_tests;
3712 use super::*;
3713 use serial_test::serial;
3714
3715 #[test]
3716 fn lexical_timeout_detail_hides_marker_from_old_clients_without_changing_frame_fit() {
3717 let public = serde_json::json!({
3718 "results": [{"ok": true, "tool": "knowledge.search", "result": "| name |\n|---|\n| first |\n"}],
3719 "summary": {"total": 1, "succeeded": 1, "failed": 0}
3720 });
3721 let public_raw = public.to_string();
3722 let mut marked = public;
3723 marked[DAEMON_LEXICAL_TIMEOUT_MARKER] = serde_json::json!(true);
3724 let marked_raw = marked.to_string();
3725 let (result, detail) = take_daemon_lexical_timeout_marker(marked_raw.clone());
3726 assert_eq!(result, public_raw);
3727 assert_eq!(detail, Some(serde_json::json!({"lexical_timeout": true})));
3728
3729 let frame = |result, error_detail| DaemonResponseFrame {
3730 ok: true,
3731 result: Some(result),
3732 error: None,
3733 error_detail,
3734 namespace_mismatch: false,
3735 config_mismatch: false,
3736 served_config_id: Some("test".to_string()),
3737 version_mismatch: false,
3738 daemon_protocol_version: PROTOCOL_VERSION,
3739 metrics: None,
3740 request_id: Some(u64::MAX),
3741 };
3742 let internal_len = serde_json::to_vec(&frame(marked_raw, None)).unwrap().len();
3743 let sent = frame(result, detail);
3744 assert_eq!(sent.result.as_deref(), Some(public_raw.as_str()));
3745 assert!(!sent
3746 .result
3747 .as_deref()
3748 .unwrap()
3749 .contains(DAEMON_LEXICAL_TIMEOUT_MARKER));
3750 assert_eq!(serde_json::to_vec(&sent).unwrap().len(), internal_len);
3751
3752 let untouched = " {\"results\":[],\"summary\":{}} ".to_string();
3753 assert_eq!(
3754 take_daemon_lexical_timeout_marker(untouched.clone()),
3755 (untouched, None)
3756 );
3757 }
3758
3759 #[tokio::test]
3760 async fn incomplete_initial_frames_release_the_connection_deadline() {
3761 for prefix in [&[][..], &[0, 0][..], &[0, 0, 0, 5][..]] {
3762 let (mut peer, mut server) = tokio::io::duplex(64);
3763 peer.write_all(prefix).await.expect("send partial frame");
3764 let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(10);
3765 let error = read_initial_frame(&mut server, deadline)
3766 .await
3767 .expect_err("an idle peer cannot hold a daemon connection indefinitely");
3768 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
3769 }
3770
3771 let (mut peer, mut server) = tokio::io::duplex(64);
3772 write_frame(&mut peer, b"{}")
3773 .await
3774 .expect("send full frame");
3775 assert_eq!(
3776 read_initial_frame(
3777 &mut server,
3778 tokio::time::Instant::now() + std::time::Duration::from_secs(1),
3779 )
3780 .await
3781 .expect("complete frame remains readable"),
3782 b"{}"
3783 );
3784 }
3785
3786 #[test]
3787 fn repeated_accept_failures_back_off_and_cap_at_one_second() {
3788 let mut previous = None;
3789 for expected_ms in [10, 20, 40, 80, 160, 320, 640, 1000, 1000] {
3790 let next = next_accept_error_backoff(previous);
3791 assert_eq!(next.as_millis(), expected_ms);
3792 previous = Some(next);
3793 }
3794 assert_eq!(next_accept_error_backoff(None).as_millis(), 10);
3795 }
3796
3797 #[derive(Debug)]
3798 struct DrainBlockingBlobStore {
3799 started: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
3800 release: Arc<tokio::sync::Semaphore>,
3801 }
3802
3803 #[async_trait]
3804 impl khive_storage::BlobStore for DrainBlockingBlobStore {
3805 async fn put(
3806 &self,
3807 _bytes: Vec<u8>,
3808 ) -> khive_storage::StorageResult<khive_storage::ContentRef> {
3809 panic!("put is not used by the hydration drain test")
3810 }
3811
3812 async fn get_bounded_verified(
3813 &self,
3814 _content_ref: &khive_storage::ContentRef,
3815 _max_bytes: u64,
3816 ) -> khive_storage::StorageResult<Vec<u8>> {
3817 if let Some(started) = self
3818 .started
3819 .lock()
3820 .unwrap_or_else(std::sync::PoisonError::into_inner)
3821 .take()
3822 {
3823 let _ = started.send(());
3824 }
3825 self.release
3826 .clone()
3827 .acquire_owned()
3828 .await
3829 .expect("test release semaphore remains open")
3830 .forget();
3831 Ok(b"late result".to_vec())
3832 }
3833
3834 async fn exists(
3835 &self,
3836 _content_ref: &khive_storage::ContentRef,
3837 ) -> khive_storage::StorageResult<bool> {
3838 panic!("exists is not used by the hydration drain test")
3839 }
3840
3841 async fn size(
3842 &self,
3843 _content_ref: &khive_storage::ContentRef,
3844 ) -> khive_storage::StorageResult<Option<u64>> {
3845 panic!("size is not used by the hydration drain test")
3846 }
3847
3848 async fn delete(
3849 &self,
3850 _content_ref: &khive_storage::ContentRef,
3851 ) -> khive_storage::StorageResult<bool> {
3852 panic!("delete is not used by the hydration drain test")
3853 }
3854 }
3855
3856 struct AppendCompletionEventStore {
3857 inner: Arc<dyn khive_storage::EventStore>,
3858 first_append: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
3859 }
3860
3861 #[async_trait]
3862 impl khive_storage::EventStore for AppendCompletionEventStore {
3863 async fn append_event(
3864 &self,
3865 event: khive_storage::Event,
3866 ) -> khive_storage::StorageResult<()> {
3867 self.inner.append_event(event).await?;
3868 if let Some(completed) = self
3869 .first_append
3870 .lock()
3871 .unwrap_or_else(std::sync::PoisonError::into_inner)
3872 .take()
3873 {
3874 let _ = completed.send(());
3875 }
3876 Ok(())
3877 }
3878
3879 async fn append_events(
3880 &self,
3881 events: Vec<khive_storage::Event>,
3882 ) -> khive_storage::StorageResult<khive_storage::BatchWriteSummary> {
3883 self.inner.append_events(events).await
3884 }
3885
3886 async fn get_event(
3887 &self,
3888 id: uuid::Uuid,
3889 ) -> khive_storage::StorageResult<Option<khive_storage::Event>> {
3890 self.inner.get_event(id).await
3891 }
3892
3893 async fn query_events(
3894 &self,
3895 filter: khive_storage::EventFilter,
3896 page: khive_storage::PageRequest,
3897 ) -> khive_storage::StorageResult<khive_storage::Page<khive_storage::Event>> {
3898 self.inner.query_events(filter, page).await
3899 }
3900
3901 async fn count_events(
3902 &self,
3903 filter: khive_storage::EventFilter,
3904 ) -> khive_storage::StorageResult<u64> {
3905 self.inner.count_events(filter).await
3906 }
3907 }
3908
3909 #[tokio::test]
3910 #[serial(checkpoint_skip_metrics)]
3911 async fn secondary_only_checkpoint_topology_emits_lifecycle_outcome() {
3912 let main_backend = khive_db::StorageBackend::memory().expect("in-memory main backend");
3913 let inner_event_store = main_backend.events().expect("main event store");
3914 let (first_append_tx, first_append_rx) = tokio::sync::oneshot::channel();
3915 let event_store: Arc<dyn khive_storage::EventStore> =
3916 Arc::new(AppendCompletionEventStore {
3917 inner: inner_event_store,
3918 first_append: std::sync::Mutex::new(Some(first_append_tx)),
3919 });
3920 let secondary_dir = tempfile::tempdir().expect("secondary tempdir");
3921 let secondary_backend =
3922 khive_db::StorageBackend::sqlite_for_test(secondary_dir.path().join("secondary.db"))
3923 .expect("file-backed secondary backend");
3924
3925 let mut tasks = checkpoint_task_specs(
3926 None,
3927 vec![secondary_backend.pool_arc()],
3928 Some(Arc::clone(&event_store)),
3929 "local".to_string(),
3930 );
3931 assert_eq!(tasks.len(), 1);
3932 let task = tasks.pop().expect("one secondary checkpoint task");
3933 assert!(!task.is_main, "the only checkpoint task must be secondary");
3934 assert!(
3935 task.lifecycle_owner.is_some(),
3936 "the secondary task must own lifecycle emission when no main task exists"
3937 );
3938
3939 let config = CheckpointConfig {
3940 interval: std::time::Duration::from_millis(10),
3941 warn_pages: 0,
3942 ..CheckpointConfig::default()
3943 };
3944 let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(());
3945 let handle = tokio::spawn(run_checkpoint_task(
3946 task.pool,
3947 config,
3948 task.lifecycle_owner,
3949 shutdown_rx,
3950 task.is_main,
3951 ));
3952
3953 tokio::time::timeout(std::time::Duration::from_secs(10), first_append_rx)
3957 .await
3958 .expect("secondary checkpoint owner did not complete an append within 10s")
3959 .expect("checkpoint lifecycle append completion sender dropped");
3960
3961 let events = event_store
3962 .query_events(
3963 khive_storage::EventFilter::default(),
3964 khive_storage::PageRequest {
3965 limit: 100,
3966 offset: 0,
3967 },
3968 )
3969 .await
3970 .expect("query lifecycle events");
3971
3972 shutdown_tx.send(()).expect("send checkpoint shutdown");
3973 tokio::time::timeout(std::time::Duration::from_secs(1), handle)
3974 .await
3975 .expect("checkpoint task should exit within 1s")
3976 .expect("checkpoint task panicked");
3977 assert!(
3978 !events.items.is_empty()
3979 && events
3980 .items
3981 .iter()
3982 .all(|event| event.kind == khive_types::EventKind::CheckpointOutcomeRecorded),
3983 "the designated secondary owner must emit CheckpointOutcomeRecorded"
3984 );
3985
3986 let file_main_dir = tempfile::tempdir().expect("file-backed main tempdir");
3987 let file_main =
3988 khive_db::StorageBackend::sqlite_for_test(file_main_dir.path().join("main.db"))
3989 .expect("file-backed main backend");
3990 let tasks = checkpoint_task_specs(
3991 Some(file_main.pool_arc()),
3992 vec![secondary_backend.pool_arc()],
3993 Some(event_store),
3994 "local".to_string(),
3995 );
3996 assert!(tasks[0].is_main && tasks[0].lifecycle_owner.is_some());
3997 assert!(!tasks[1].is_main && tasks[1].lifecycle_owner.is_none());
3998 }
3999
4000 #[test]
4004 fn current_process_is_running() {
4005 let pid = std::process::id();
4007 assert!(
4008 is_process_running(pid),
4009 "current process {pid} should be detected as running"
4010 );
4011 }
4012
4013 #[test]
4014 fn pid_zero_is_not_running() {
4015 assert!(
4018 !is_process_running(0),
4019 "pid 0 must be rejected by the guard before the unsafe call"
4020 );
4021 }
4022
4023 #[test]
4024 fn very_large_pid_is_not_running() {
4025 assert!(
4027 !is_process_running(u32::MAX),
4028 "u32::MAX should fail i32 conversion and return false"
4029 );
4030 }
4031
4032 #[test]
4036 fn classify_kill_result_zero_is_alive() {
4037 assert_eq!(classify_kill_result(0, 0), PidLiveness::Alive);
4038 assert!(classify_kill_result(0, 0).is_running());
4039 }
4040
4041 #[test]
4042 fn classify_kill_result_esrch_is_dead() {
4043 assert_eq!(classify_kill_result(-1, libc::ESRCH), PidLiveness::Dead);
4044 assert!(!classify_kill_result(-1, libc::ESRCH).is_running());
4045 }
4046
4047 #[test]
4048 fn classify_kill_result_eperm_is_permission_denied_and_counts_as_running() {
4049 assert_eq!(
4050 classify_kill_result(-1, libc::EPERM),
4051 PidLiveness::PermissionDenied
4052 );
4053 assert!(
4054 classify_kill_result(-1, libc::EPERM).is_running(),
4055 "EPERM must be unknown-safe: treated as running, never as a basis \
4056 for stale cleanup to unlink a live daemon's rendezvous files"
4057 );
4058 }
4059
4060 #[test]
4061 fn same_process_pid_requires_explicit_in_process_harness_opt_in() {
4062 let current = std::process::id();
4063 assert!(
4064 !pid_can_name_incumbent(current, current, false),
4065 "production startup must not trust a same-PID stale rendezvous"
4066 );
4067 assert!(
4068 pid_can_name_incumbent(current, current, true),
4069 "the in-process harness must let a live same-PID owner win"
4070 );
4071 let distinct_probe_pid = current.wrapping_add(2);
4075 assert_ne!(
4076 distinct_probe_pid, current,
4077 "probe PID must differ from this process's PID"
4078 );
4079 assert!(
4080 pid_can_name_incumbent(distinct_probe_pid, current, false),
4081 "a distinct PID remains eligible under ordinary production rules"
4082 );
4083 }
4084
4085 #[test]
4086 fn pid_1_probe_is_running_regardless_of_permission_outcome() {
4087 assert!(
4095 is_process_running(1),
4096 "PID 1 always exists; EPERM must not read as dead"
4097 );
4098 }
4099
4100 #[tokio::test]
4101 async fn stale_cleanup_preserves_live_incumbent_without_reachable_socket() {
4102 if crate::test_process::run_in_child() {
4106 return;
4107 }
4108 assert_eq!(
4109 std::env::var("KHIVE_RUNTIME_ISOLATED_TEST").ok().as_deref(),
4110 Some("daemon::tests::stale_cleanup_preserves_live_incumbent_without_reachable_socket"),
4111 "the stale-listener fixture must run alone in its child process"
4112 );
4113 for socket_exists in [false, true] {
4114 let dir = tempfile::tempdir().expect("tempdir");
4115 let sock = dir.path().join("khived.sock");
4116 let pid_file = dir.path().join("khived.pid");
4117 if socket_exists {
4118 let listener = std::os::unix::net::UnixListener::bind(&sock)
4119 .expect("bind socket before closing listener");
4120 drop(listener);
4121 }
4122 let identity = socket_identity(&sock);
4123 assert_eq!(identity.is_some(), socket_exists);
4124 let error = UnixStream::connect(&sock)
4125 .await
4126 .expect_err("incumbent must have no reachable listener");
4127 assert_eq!(
4128 error.kind(),
4129 if socket_exists {
4130 std::io::ErrorKind::ConnectionRefused
4131 } else {
4132 std::io::ErrorKind::NotFound
4133 }
4134 );
4135 let live_pid = std::process::id().to_string();
4136 let _pid_file_guard = write_pid_file_exclusive(&pid_file)
4137 .expect("claim and lock the live incumbent PID file");
4138
4139 assert!(
4142 matches!(
4143 cleanup_stale_daemon(&sock, &pid_file, true, "probe-test").await,
4144 Incumbent::Live(_) | Incumbent::Serving(_)
4145 ),
4146 "live incumbent must retain ownership with socket_exists={socket_exists}"
4147 );
4148 assert_eq!(
4149 std::fs::read_to_string(&pid_file).expect("live incumbent PID must survive"),
4150 live_pid
4151 );
4152 assert!(socket_identity(&sock) == identity);
4153 }
4154 }
4155
4156 #[tokio::test]
4157 #[serial]
4158 async fn live_foreign_pid_does_not_block_daemon_startup() {
4159 if crate::test_process::run_in_child() {
4160 return;
4161 }
4162
4163 let dir = tempfile::tempdir().expect("tempdir");
4164 let sock = dir.path().join("khived.sock");
4165 let pid_file = dir.path().join("khived.pid");
4166 std::env::set_var("KHIVE_SOCKET", &sock);
4167 std::env::set_var("KHIVE_PID", &pid_file);
4168 std::env::set_var("KHIVE_LOCK", dir.path().join("khived.recovery.lock"));
4169
4170 let stale_listener =
4171 std::os::unix::net::UnixListener::bind(&sock).expect("create stale socket path");
4172 drop(stale_listener);
4173
4174 let mut foreign = std::process::Command::new("/bin/sleep")
4175 .arg("30")
4176 .spawn()
4177 .expect("spawn live unrelated process");
4178 std::fs::write(&pid_file, foreign.id().to_string()).expect("write unrelated PID");
4179
4180 let dispatcher = MockDispatch {
4181 namespace: "local".to_string(),
4182 config_id: "foreign-pid-start-test".to_string(),
4183 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4184 pool: None,
4185 dispatch_err: None,
4186 };
4187 let daemon = tokio::spawn(run_daemon_in_process_test(dispatcher));
4188 let connected = tokio::time::timeout(std::time::Duration::from_secs(5), async {
4189 loop {
4190 if let Ok(stream) = UnixStream::connect(&sock).await {
4191 break Some(stream);
4192 }
4193 if daemon.is_finished() {
4194 break None;
4195 }
4196 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4197 }
4198 })
4199 .await;
4200
4201 let response = if let Ok(Some(mut stream)) = connected {
4202 let mut request = base_request_frame("foreign-pid-start-test");
4203 request.probe_only = true;
4204 let payload = serde_json::to_vec(&request).expect("encode probe request");
4205 tokio::time::timeout(std::time::Duration::from_secs(1), async {
4206 write_frame(&mut stream, &payload).await.ok()?;
4207 let raw = read_frame(&mut stream).await.ok()?;
4208 serde_json::from_slice::<DaemonResponseFrame>(&raw).ok()
4209 })
4210 .await
4211 .ok()
4212 .flatten()
4213 } else {
4214 None
4215 };
4216 let foreign_survived_start = foreign
4217 .try_wait()
4218 .expect("query unrelated process state")
4219 .is_none();
4220
4221 daemon.abort();
4222 let _ = daemon.await;
4223 let _ = foreign.kill();
4224 let _ = foreign.wait();
4225 std::env::remove_var("KHIVE_SOCKET");
4226 std::env::remove_var("KHIVE_PID");
4227 std::env::remove_var("KHIVE_LOCK");
4228
4229 assert!(
4230 response.is_some_and(|response| {
4231 response.ok
4232 && response.served_config_id.as_deref() == Some("foreign-pid-start-test")
4233 }),
4234 "daemon must start and answer its identity probe"
4235 );
4236 assert!(
4237 foreign_survived_start,
4238 "starting khived must leave the unrelated live process running"
4239 );
4240 }
4241
4242 #[tokio::test]
4243 #[serial]
4244 async fn second_start_refuses_while_pid_file_is_locked_before_bind() {
4245 if crate::test_process::run_in_child() {
4246 return;
4247 }
4248
4249 let dir = tempfile::tempdir().expect("tempdir");
4250 let sock = dir.path().join("khived.sock");
4251 let pid_file = dir.path().join("khived.pid");
4252 std::env::set_var("KHIVE_SOCKET", &sock);
4253 std::env::set_var("KHIVE_PID", &pid_file);
4254 std::env::set_var("KHIVE_LOCK", dir.path().join("khived.recovery.lock"));
4255
4256 let _incumbent_startup_guard = write_pid_file_exclusive(&pid_file)
4257 .expect("incumbent claims and locks its PID file before binding");
4258 let dispatcher = MockDispatch {
4259 namespace: "local".to_string(),
4260 config_id: "startup-lock-test".to_string(),
4261 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4262 pool: None,
4263 dispatch_err: None,
4264 };
4265 let second = tokio::time::timeout(
4266 std::time::Duration::from_secs(1),
4267 run_daemon_in_process_test(dispatcher),
4268 )
4269 .await;
4270 let refused = matches!(second, Ok(Err(_)));
4271 let pid_file_survived = pid_file.exists();
4272 let socket_was_not_bound = !sock.exists();
4273
4274 std::env::remove_var("KHIVE_SOCKET");
4275 std::env::remove_var("KHIVE_PID");
4276 std::env::remove_var("KHIVE_LOCK");
4277
4278 assert!(
4279 refused,
4280 "a second start must refuse while an incumbent holds its pre-bind PID lock"
4281 );
4282 assert!(
4283 pid_file_survived,
4284 "the incumbent PID file must remain in place"
4285 );
4286 assert!(
4287 socket_was_not_bound,
4288 "the second start must not bind the socket"
4289 );
4290 }
4291
4292 #[test]
4293 fn env_truthy_recognises_set_values() {
4294 assert!(!env_truthy("__KHIVE_TEST_ABSENT_VAR_XYZ__"));
4295
4296 let key = "__KHIVE_TEST_TRUTHY_ABC__";
4300 std::env::set_var(key, "1");
4301 assert!(env_truthy(key));
4302 std::env::set_var(key, "false");
4303 assert!(!env_truthy(key));
4304 std::env::set_var(key, "0");
4305 assert!(!env_truthy(key));
4306 std::env::remove_var(key);
4307 }
4308
4309 #[tokio::test(flavor = "current_thread", start_paused = true)]
4310 #[serial(background_tasks)]
4311 async fn accepted_connection_is_counted_before_first_poll_and_drain_waits() {
4312 use std::sync::atomic::Ordering;
4313
4314 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4315 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
4316 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
4317 let started_in_task = Arc::clone(&started);
4318
4319 let handle = spawn_connection_task(Arc::clone(&active), async move {
4320 started_in_task.store(true, Ordering::Relaxed);
4321 let _ = release_rx.await;
4322 });
4323
4324 assert_eq!(active.load(Ordering::Relaxed), 1);
4325 assert!(
4326 !started.load(Ordering::Relaxed),
4327 "the current-thread runtime must leave the spawned handler unpolled"
4328 );
4329
4330 let drain_fut = drain(active.as_ref());
4331 tokio::pin!(drain_fut);
4332 let too_early =
4333 tokio::time::timeout(std::time::Duration::from_millis(150), &mut drain_fut).await;
4334 assert!(
4335 too_early.is_err(),
4336 "drain must wait for a connection claimed before its task's first poll"
4337 );
4338 assert!(started.load(Ordering::Relaxed));
4339
4340 release_tx.send(()).expect("handler still waiting");
4341 tokio::time::timeout(std::time::Duration::from_secs(1), handle)
4342 .await
4343 .expect("handler should finish promptly")
4344 .expect("handler should not panic");
4345 assert_eq!(active.load(Ordering::Relaxed), 0);
4346 tokio::time::timeout(std::time::Duration::from_secs(1), drain_fut)
4347 .await
4348 .expect("drain should finish once the handler releases its claim");
4349 }
4350
4351 #[tokio::test(flavor = "current_thread")]
4352 async fn cancelled_connection_releases_count_before_first_poll() {
4353 use std::sync::atomic::Ordering;
4354
4355 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4356 let started = Arc::new(std::sync::atomic::AtomicBool::new(false));
4357 let started_in_task = Arc::clone(&started);
4358 let handle = spawn_connection_task(Arc::clone(&active), async move {
4359 started_in_task.store(true, Ordering::Relaxed);
4360 std::future::pending::<()>().await;
4361 });
4362
4363 assert_eq!(active.load(Ordering::Relaxed), 1);
4364 handle.abort();
4365 let error = handle.await.expect_err("aborted handler must be cancelled");
4366 assert!(error.is_cancelled());
4367 assert!(!started.load(Ordering::Relaxed));
4368 assert_eq!(active.load(Ordering::Relaxed), 0);
4369 }
4370
4371 #[tokio::test(flavor = "current_thread")]
4372 async fn panicked_connection_releases_count() {
4373 use std::sync::atomic::Ordering;
4374
4375 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4376 let handle = spawn_connection_task(Arc::clone(&active), async move {
4377 panic!("intentional connection-handler panic");
4378 });
4379
4380 assert_eq!(active.load(Ordering::Relaxed), 1);
4381 let error = handle
4382 .await
4383 .expect_err("panicked handler must fail its join");
4384 assert!(error.is_panic());
4385 assert_eq!(active.load(Ordering::Relaxed), 0);
4386 }
4387
4388 #[test]
4389 fn connection_claim_releases_if_spawn_panics() {
4390 use std::sync::atomic::Ordering;
4391
4392 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4393 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
4394 drop(spawn_connection_task(Arc::clone(&active), async {}));
4395 }));
4396
4397 assert!(result.is_err(), "tokio::spawn outside a runtime must panic");
4398 assert_eq!(active.load(Ordering::Relaxed), 0);
4399 }
4400
4401 #[tokio::test]
4402 #[serial(background_tasks)]
4403 async fn drain_returns_promptly_with_no_accepted_connection() {
4404 let active = std::sync::atomic::AtomicUsize::new(0);
4405 tokio::time::timeout(std::time::Duration::from_secs(1), drain(&active))
4406 .await
4407 .expect("empty drain should return immediately");
4408 }
4409
4410 #[test]
4411 fn stopped_listener_is_closed_before_drain() {
4412 use std::process::{Command, Stdio};
4413
4414 let dir = tempfile::Builder::new()
4415 .prefix("kh-drain-")
4416 .tempdir_in("/tmp")
4417 .expect("short isolated socket directory");
4418 let child_home = dir.path().join("home");
4419 std::fs::create_dir(&child_home).expect("empty daemon child HOME");
4420 let mut child = Command::new(std::env::current_exe().expect("test executable"))
4421 .args([
4422 "--exact",
4423 "daemon::tests::stopped_listener_is_closed_before_drain_child",
4424 "--ignored",
4425 "--nocapture",
4426 "--test-threads=1",
4427 ])
4428 .env_clear()
4429 .envs(
4430 std::env::vars_os().filter(|(key, _)| !key.to_string_lossy().starts_with("KHIVE_")),
4431 )
4432 .env("HOME", &child_home)
4433 .env("KHIVE_VOLUME_LOCK_DIR", dir.path().join("volume-locks"))
4434 .env_remove("LATTICE_MODEL_CACHE")
4435 .env("KHIVE_TEST_HARNESS", "1")
4436 .env("KHIVE_DRAIN_TEST_CHILD", "1")
4437 .env("KHIVE_SOCKET", dir.path().join("s"))
4438 .env("KHIVE_PID", dir.path().join("p"))
4439 .env("KHIVE_LOCK", dir.path().join("l"))
4440 .env("KHIVE_RECOVERER_LOCK", dir.path().join("r"))
4441 .env("KHIVE_DRAIN_TIMEOUT_SECS", "10")
4442 .current_dir(dir.path())
4443 .stdin(Stdio::null())
4444 .stdout(Stdio::piped())
4445 .stderr(Stdio::piped())
4446 .spawn()
4447 .expect("spawn isolated daemon test");
4448 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
4449 let completed = loop {
4450 match child.try_wait() {
4451 Ok(Some(_)) => break true,
4452 Ok(None) if std::time::Instant::now() < deadline => {
4453 std::thread::sleep(std::time::Duration::from_millis(10));
4454 }
4455 _ => {
4456 let _ = child.kill();
4457 break false;
4458 }
4459 }
4460 };
4461 let output = child.wait_with_output().expect("reap daemon test child");
4462 assert!(completed, "daemon test child did not finish: {output:?}");
4463 assert!(output.status.success(), "daemon test failed: {output:?}");
4464 assert!(
4465 String::from_utf8_lossy(&output.stdout).contains("STOPPED_LISTENER_DRAIN_VERIFIED"),
4466 "child must run the listener witness: {output:?}"
4467 );
4468 assert!(
4469 std::fs::read_dir(child_home).unwrap().next().is_none(),
4470 "daemon drain child must leave its private HOME empty"
4471 );
4472 }
4473
4474 #[tokio::test]
4475 #[ignore = "subprocess helper, invoked by stopped_listener_is_closed_before_drain"]
4476 async fn stopped_listener_is_closed_before_drain_child() {
4477 assert_eq!(
4478 std::env::var("KHIVE_DRAIN_TEST_CHILD").expect("isolated child environment"),
4479 "1"
4480 );
4481 let _sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
4482 .expect("install child SIGTERM handler");
4483 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
4484 let background = spawn_tracked_task(async move {
4485 release_rx.await.expect("release held drain task");
4486 });
4487 let dispatcher = MockDispatch {
4488 namespace: "local".to_string(),
4489 config_id: "drain-test".to_string(),
4490 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4491 pool: None,
4492 dispatch_err: None,
4493 };
4494 let starts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4495 let stopped = Arc::new(std::sync::atomic::AtomicBool::new(false));
4496 let callback_starts = Arc::clone(&starts);
4497 let callback_stopped = Arc::clone(&stopped);
4498 let boot_guard = Some(acquire_daemon_boot_guard().expect("boot guard"));
4499 let daemon = tokio::spawn(run_daemon_with_boot_guard_and_start(
4500 dispatcher,
4501 boot_guard,
4502 move |_| {
4503 use std::os::unix::fs::FileTypeExt;
4504 assert!(std::fs::metadata(socket_path())
4505 .unwrap()
4506 .file_type()
4507 .is_socket());
4508 assert_eq!(
4509 std::fs::read_to_string(pid_path()).unwrap(),
4510 std::process::id().to_string()
4511 );
4512 assert_eq!(
4513 callback_starts.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
4514 0
4515 );
4516 track_named_background_task("startup-lifecycle-test", async move {
4517 daemon_shutdown_token().cancelled().await;
4518 callback_stopped.store(true, std::sync::atomic::Ordering::SeqCst);
4519 });
4520 },
4521 ));
4522 let sock = socket_path();
4523 let mut stream = tokio::time::timeout(std::time::Duration::from_secs(5), async {
4524 loop {
4525 if let Ok(stream) = UnixStream::connect(&sock).await {
4526 break stream;
4527 }
4528 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4529 }
4530 })
4531 .await
4532 .expect("daemon must bind");
4533 let payload = serde_json::to_vec(&base_request_frame("drain-test"))
4534 .expect("encode readiness request");
4535 write_frame(&mut stream, &payload)
4536 .await
4537 .expect("write readiness request");
4538 let response =
4539 tokio::time::timeout(std::time::Duration::from_secs(2), read_frame(&mut stream))
4540 .await
4541 .expect("daemon must serve readiness request")
4542 .expect("read readiness response");
4543 let response: DaemonResponseFrame =
4544 serde_json::from_slice(&response).expect("decode readiness response");
4545 assert!(response.ok, "daemon readiness failed: {response:?}");
4546 assert_eq!(starts.load(std::sync::atomic::Ordering::SeqCst), 1);
4547 drop(stream);
4548
4549 let rc = unsafe { libc::kill(std::process::id() as i32, libc::SIGTERM) };
4551 assert_eq!(rc, 0, "signal isolated daemon child");
4552 tokio::time::timeout(
4553 std::time::Duration::from_secs(2),
4554 daemon_shutdown_token().cancelled(),
4555 )
4556 .await
4557 .expect("daemon must begin shutdown");
4558 assert!(
4559 !daemon.is_finished(),
4560 "held background task must retain drain"
4561 );
4562 assert!(
4563 sock.exists(),
4564 "cleanup must not have removed the socket yet"
4565 );
4566 assert_eq!(
4567 std::fs::read_to_string(pid_path()).expect("draining daemon PID"),
4568 std::process::id().to_string()
4569 );
4570
4571 let late_connect = tokio::time::timeout(
4573 std::time::Duration::from_secs(1),
4574 UnixStream::connect(&sock),
4575 )
4576 .await
4577 .expect("late connect must finish promptly");
4578 release_tx.send(()).expect("release daemon drain");
4579 background.await.expect("held background task must finish");
4580 tokio::time::timeout(std::time::Duration::from_secs(2), daemon)
4581 .await
4582 .expect("released daemon must finish shutdown")
4583 .expect("daemon task must not panic")
4584 .expect("daemon shutdown must succeed");
4585 let error = late_connect.expect_err("stopped listener must not queue new connections");
4586 assert_eq!(error.kind(), std::io::ErrorKind::ConnectionRefused);
4587 assert!(!sock.exists(), "owned socket must be removed after drain");
4588 assert!(
4589 !pid_path().exists(),
4590 "owned PID must be removed after drain"
4591 );
4592 assert!(
4593 stopped.load(std::sync::atomic::Ordering::SeqCst),
4594 "work started after ownership must finish inside daemon drain"
4595 );
4596 println!("STOPPED_LISTENER_DRAIN_VERIFIED");
4597 }
4598
4599 #[tokio::test(start_paused = true)]
4600 #[serial(background_tasks)]
4601 async fn graceful_drain_has_a_hard_upper_bound_with_stuck_work() {
4602 use std::sync::atomic::Ordering;
4603
4604 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4605 let task = spawn_connection_task(Arc::clone(&active), async {
4606 std::future::pending::<()>().await;
4607 });
4608 assert_eq!(active.load(Ordering::Relaxed), 1);
4609 let started = tokio::time::Instant::now();
4610
4611 let drained = drain_with_timeout(&active, std::time::Duration::from_millis(250)).await;
4612
4613 assert!(!drained, "stuck work must exhaust the drain bound");
4614 assert!(
4615 started.elapsed() >= std::time::Duration::from_millis(250)
4616 && started.elapsed() < std::time::Duration::from_millis(350),
4617 "graceful shutdown exceeded its configured bound: {:?}",
4618 started.elapsed()
4619 );
4620 finish_connection_tasks(vec![task], drained).await;
4621 assert_eq!(
4622 active.load(Ordering::Relaxed),
4623 0,
4624 "hard-bound escalation must abort, await, and release the handler"
4625 );
4626 }
4627
4628 #[tokio::test(start_paused = true)]
4629 #[serial(background_tasks)]
4630 async fn admitted_work_finishes_inside_drain_window_without_abort() {
4631 use std::sync::atomic::Ordering;
4632
4633 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4634 let committed = Arc::new(std::sync::atomic::AtomicBool::new(false));
4635 let committed_in_task = Arc::clone(&committed);
4636 let task = spawn_connection_task(Arc::clone(&active), async move {
4637 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4638 committed_in_task.store(true, Ordering::SeqCst);
4639 });
4640
4641 let drained = drain_with_timeout(&active, std::time::Duration::from_millis(250)).await;
4642 assert!(
4643 drained,
4644 "admitted work should finish inside the drain window"
4645 );
4646 finish_connection_tasks(vec![task], drained).await;
4647 assert!(committed.load(Ordering::SeqCst));
4648 assert_eq!(active.load(Ordering::Relaxed), 0);
4649 }
4650
4651 #[tokio::test]
4664 #[serial(background_tasks)]
4665 async fn drain_waits_for_tracked_background_tasks_before_returning() {
4666 let active = std::sync::atomic::AtomicUsize::new(0);
4667 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
4668
4669 track_background_task(async move {
4670 let _ = rx.await;
4671 });
4672 assert!(
4673 background_task_count() >= 1,
4674 "track_background_task must make the in-flight task visible immediately"
4675 );
4676
4677 let drain_fut = drain(&active);
4678 tokio::pin!(drain_fut);
4679
4680 let too_early =
4682 tokio::time::timeout(std::time::Duration::from_millis(150), &mut drain_fut).await;
4683 assert!(
4684 too_early.is_err(),
4685 "drain() must not return while a tracked background task is still running"
4686 );
4687
4688 tx.send(())
4690 .expect("tracked task still awaiting the oneshot");
4691 let done = tokio::time::timeout(std::time::Duration::from_secs(5), drain_fut).await;
4692 assert!(
4693 done.is_ok(),
4694 "drain() must return once the tracked background task finishes"
4695 );
4696 }
4697
4698 #[tokio::test]
4699 #[serial(background_tasks)]
4700 async fn drain_waits_for_hydration_after_its_last_request_waiter_is_cancelled() {
4701 let before = background_task_count();
4702 let active = std::sync::atomic::AtomicUsize::new(0);
4703 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
4704 let release = Arc::new(tokio::sync::Semaphore::new(0));
4705 let store = Arc::new(DrainBlockingBlobStore {
4706 started: std::sync::Mutex::new(Some(started_tx)),
4707 release: Arc::clone(&release),
4708 });
4709 let hydrator = Arc::new(
4710 crate::BlobHydrator::new(
4711 store as Arc<dyn khive_storage::BlobStore>,
4712 khive_storage::MAX_BLOB_WHOLE_BYTES,
4713 )
4714 .expect("minimum hydration budget is valid"),
4715 );
4716 let content_ref =
4717 khive_storage::ContentRef::from_hex("a".repeat(64)).expect("fixture content ref");
4718
4719 let request_hydrator = Arc::clone(&hydrator);
4720 let request = tokio::spawn(async move {
4721 request_hydrator
4722 .hydrate_verified(&content_ref, khive_storage::MAX_BLOB_WHOLE_BYTES)
4723 .await
4724 });
4725 started_rx.await.expect("backend work must begin");
4726 request.abort();
4727 assert!(request.await.unwrap_err().is_cancelled());
4728 assert_eq!(background_task_count(), before + 1);
4729
4730 let draining = drain_with_timeout(&active, std::time::Duration::from_secs(5));
4731 tokio::pin!(draining);
4732 assert!(
4733 tokio::time::timeout(std::time::Duration::from_millis(150), &mut draining)
4734 .await
4735 .is_err(),
4736 "drain must remain pending while cancelled-request hydration still runs"
4737 );
4738
4739 release.add_permits(1);
4740 assert!(
4741 tokio::time::timeout(std::time::Duration::from_secs(5), draining)
4742 .await
4743 .expect("drain should finish after native hydration ends"),
4744 "hydration should finish inside the drain window"
4745 );
4746 assert_eq!(background_task_count(), before);
4747 }
4748
4749 #[tokio::test]
4754 #[serial(background_tasks)]
4755 async fn track_background_task_count_returns_to_zero_after_completion() {
4756 let before = background_task_count();
4758 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
4759 track_background_task(async move {
4760 let _ = rx.await;
4761 });
4762 assert_eq!(background_task_count(), before + 1);
4763 tx.send(()).expect("still awaiting");
4764 for _ in 0..100 {
4766 if background_task_count() == before {
4767 break;
4768 }
4769 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4770 }
4771 assert_eq!(background_task_count(), before);
4772 }
4773
4774 #[tokio::test]
4777 #[serial(background_tasks)]
4778 async fn track_background_task_count_returns_to_baseline_after_panic() {
4779 let before = background_task_count();
4789
4790 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
4791 track_background_task(async move {
4792 let _ = rx.await;
4793 panic!("intentional panic to exercise the Drop-guard decrement path");
4794 });
4795 assert_eq!(background_task_count(), before + 1);
4796
4797 tx.send(()).expect("still awaiting");
4798 for _ in 0..100 {
4799 if background_task_count() == before {
4800 break;
4801 }
4802 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4803 }
4804 assert_eq!(
4805 background_task_count(),
4806 before,
4807 "background task counter must return to baseline after the tracked future panics"
4808 );
4809 }
4810
4811 #[test]
4819 #[serial(active_phases)]
4820 fn register_active_phase_appears_and_disappears_with_the_guard() {
4821 assert!(
4822 !active_phase_names().contains(&"adr103_test_phase".to_string()),
4823 "must start absent (leaked from a prior failed run would poison this test)"
4824 );
4825
4826 let guard = register_active_phase("adr103_test_phase");
4827 assert!(active_phase_names().contains(&"adr103_test_phase".to_string()));
4828
4829 drop(guard);
4830 assert!(
4831 !active_phase_names().contains(&"adr103_test_phase".to_string()),
4832 "the phase name must drop out of the gauge once its guard is dropped"
4833 );
4834 }
4835
4836 #[test]
4837 #[serial(active_phases)]
4838 fn register_active_phase_counts_concurrent_occurrences_of_the_same_name() {
4839 let first = register_active_phase("adr103_concurrent_phase");
4840 let second = register_active_phase("adr103_concurrent_phase");
4841 assert!(active_phase_names().contains(&"adr103_concurrent_phase".to_string()));
4842
4843 drop(first);
4844 assert!(
4845 active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
4846 "one of two concurrent occurrences ending must not remove the name early"
4847 );
4848
4849 drop(second);
4850 assert!(
4851 !active_phase_names().contains(&"adr103_concurrent_phase".to_string()),
4852 "the name must be removed only once every concurrent occurrence has ended"
4853 );
4854 }
4855
4856 #[derive(Clone)]
4863 struct MockDispatch {
4864 namespace: String,
4865 config_id: String,
4866 dispatch_calls: Arc<std::sync::atomic::AtomicUsize>,
4867 pool: Option<Arc<ConnectionPool>>,
4868 dispatch_err: Option<String>,
4872 }
4873
4874 #[derive(Clone)]
4875 struct CancellationAwareDispatch {
4876 started: Arc<tokio::sync::Notify>,
4877 cancellation_observed: Arc<std::sync::atomic::AtomicBool>,
4878 count_sql: Option<Arc<dyn khive_storage::SqlAccess>>,
4879 }
4880
4881 #[async_trait]
4882 impl DaemonDispatch for CancellationAwareDispatch {
4883 fn plan(&self, ops: &str) -> String {
4884 khive_request::plan_request(ops, &Default::default()).to_string()
4885 }
4886
4887 async fn dispatch(
4888 &self,
4889 _ops: String,
4890 _presentation: Option<String>,
4891 _presentation_per_op: Option<Vec<Option<String>>>,
4892 _format: Option<String>,
4893 _format_per_op: Option<Vec<Option<String>>>,
4894 _from_wire: bool,
4895 _identity: Option<RequestIdentity>,
4896 ) -> Result<String, String> {
4897 self.started.notify_one();
4898 if let Some(sql) = &self.count_sql {
4899 let mut reader = sql.reader().await.map_err(|error| error.to_string())?;
4900 let result = reader.query_scalar(khive_storage::SqlStatement {
4901 sql: "SELECT COUNT(*) FROM events WHERE namespace = ?1 AND verb LIKE 'knowledge.%'".into(),
4902 params: vec![khive_storage::SqlValue::Text("local".into())],
4903 label: Some("knowledge.stats.event_count".into()),
4904 }).await;
4905 self.cancellation_observed.store(
4906 matches!(
4907 result,
4908 Err(khive_storage::error::StorageError::Timeout { .. })
4909 ),
4910 std::sync::atomic::Ordering::SeqCst,
4911 );
4912 return result
4913 .map(|value| format!("{value:?}"))
4914 .map_err(|error| error.to_string());
4915 }
4916 khive_storage::wait_for_request_read_cancellation().await;
4917 self.cancellation_observed
4918 .store(true, std::sync::atomic::Ordering::SeqCst);
4919 Ok("{}".to_string())
4920 }
4921
4922 async fn warm_all(&self) {}
4923
4924 fn namespace(&self) -> &str {
4925 "local"
4926 }
4927
4928 fn config_id(&self) -> &str {
4929 "disconnect-test"
4930 }
4931 }
4932
4933 mod demand_retirement_tests {
4934 use super::*;
4935 use khive_storage::SqlAccess;
4936
4937 pub(super) fn dispatcher(pool: Option<Arc<ConnectionPool>>) -> MockDispatch {
4938 MockDispatch {
4939 namespace: "local".to_owned(),
4940 config_id: "idle-test".to_owned(),
4941 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
4942 pool,
4943 dispatch_err: None,
4944 }
4945 }
4946
4947 pub(super) fn lifecycle(mode: DaemonLifetime) -> Arc<DaemonLifecycle> {
4948 Arc::new(DaemonLifecycle::new(
4949 DaemonOptions {
4950 lifetime: mode,
4951 idle_interval: std::time::Duration::from_secs(1),
4952 },
4953 DaemonStartupReport::default(),
4954 ))
4955 }
4956
4957 #[tokio::test(start_paused = true)]
4958 async fn ordinary_cleanup_resets_idle_and_admission_is_one_way() {
4959 let state = lifecycle(DaemonLifetime::Demand);
4960 tokio::time::advance(std::time::Duration::from_secs(3)).await;
4961 assert!(
4962 !state.try_idle(Vec::new),
4963 "readiness must precede the idle clock"
4964 );
4965 state.ready();
4966 let request = state.admit().unwrap();
4967 tokio::time::advance(std::time::Duration::from_secs(3)).await;
4968 assert!(
4969 !state.try_idle(Vec::new),
4970 "admitted work must prevent retirement"
4971 );
4972 drop(request);
4973 assert!(
4974 !state.try_idle(Vec::new),
4975 "cleanup starts a fresh idle interval"
4976 );
4977 tokio::time::advance(std::time::Duration::from_secs(1)).await;
4978 assert!(state.try_idle(Vec::new));
4979 assert!(
4980 state.admit().is_none(),
4981 "draining must refuse before dispatch"
4982 );
4983 assert!(!state.try_idle(Vec::new), "retirement cannot be repeated");
4984 assert_eq!(
4985 state.snapshot().shutdown_reason,
4986 Some(DaemonShutdownReason::Idle)
4987 );
4988 state.stopped();
4989 assert!(state.admit().is_none());
4990 }
4991
4992 #[test]
4993 fn concurrent_admission_and_idle_decision_choose_one_winner() {
4994 for _ in 0..16 {
4995 let state = Arc::new(DaemonLifecycle::new(
4996 DaemonOptions {
4997 lifetime: DaemonLifetime::Demand,
4998 idle_interval: std::time::Duration::from_nanos(1),
4999 },
5000 DaemonStartupReport::default(),
5001 ));
5002 state.ready();
5003 let barrier = Arc::new(std::sync::Barrier::new(2));
5004 let admitting_state = Arc::clone(&state);
5005 let admitting_barrier = Arc::clone(&barrier);
5006 let admission = std::thread::spawn(move || {
5007 admitting_barrier.wait();
5008 admitting_state.admit()
5009 });
5010 let retiring_state = Arc::clone(&state);
5011 let retirement = std::thread::spawn(move || {
5012 barrier.wait();
5013 retiring_state.try_idle(Vec::new)
5014 });
5015 let admitted = admission.join().unwrap();
5016 let retired = retirement.join().unwrap();
5017 assert_ne!(
5018 admitted.is_some(),
5019 retired,
5020 "request admission and voluntary retirement cannot both win"
5021 );
5022 if retired {
5023 assert!(state.admit().is_none());
5024 }
5025 drop(admitted);
5026 }
5027 }
5028
5029 #[tokio::test(start_paused = true)]
5030 async fn named_service_obligations_and_unknown_resources_are_ineligible() {
5031 let state = Arc::new(DaemonLifecycle::new(
5032 DaemonOptions {
5033 lifetime: DaemonLifetime::Demand,
5034 idle_interval: std::time::Duration::from_secs(1),
5035 },
5036 DaemonStartupReport {
5037 skipped_components: vec!["schedule-tick".to_owned()],
5038 idle_ineligible_reasons: vec![
5039 "unclassified_component:external-service".to_owned()
5040 ],
5041 },
5042 ));
5043 state.ready();
5044 tokio::time::advance(std::time::Duration::from_secs(3)).await;
5045 assert!(!state.try_idle(Vec::new));
5046 assert_eq!(
5047 state.snapshot().idle_ineligible_reasons,
5048 vec!["unclassified_component:external-service"]
5049 );
5050 let unknown = CancellationAwareDispatch {
5051 started: Arc::new(tokio::sync::Notify::new()),
5052 cancellation_observed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
5053 count_sql: None,
5054 };
5055 let clean = lifecycle(DaemonLifetime::Demand);
5056 clean.ready();
5057 tokio::time::advance(std::time::Duration::from_secs(3)).await;
5058 assert!(!clean.try_idle(|| unknown.idle_retirement_blockers()));
5059 assert_eq!(
5060 clean.snapshot().idle_blockers,
5061 vec!["dispatcher_resource_inventory_unknown"]
5062 );
5063 }
5064
5065 #[tokio::test(start_paused = true)]
5066 #[serial(background_tasks, tx_registry)]
5067 async fn retained_raw_sql_writer_blocks_actual_idle_wait_and_persistent_stays() {
5068 let dir = tempfile::tempdir().unwrap();
5069 let pool = Arc::new(
5070 ConnectionPool::new(khive_db::PoolConfig {
5071 path: Some(dir.path().join("retained.db")),
5072 write_queue_enabled: Some(false),
5073 write_routing_strict: false,
5074 ..Default::default()
5075 })
5076 .unwrap(),
5077 );
5078 let bridge = khive_db::SqlBridge::new(Arc::clone(&pool), true);
5079 let writer = bridge.writer().await.unwrap();
5080 assert!(
5081 khive_storage::tx_registry::snapshot().is_empty(),
5082 "this hold must be autocommit, not an open transaction"
5083 );
5084 let d = dispatcher(Some(Arc::clone(&pool)));
5085 let demand = lifecycle(DaemonLifetime::Demand);
5086 let persistent = lifecycle(DaemonLifetime::Persistent);
5087 demand.ready();
5088 persistent.ready();
5089 tokio::time::advance(std::time::Duration::from_secs(3)).await;
5090 let idle = wait_for_idle(&d, &demand);
5091 tokio::pin!(idle);
5092 assert!(
5093 tokio::time::timeout(std::time::Duration::from_millis(150), &mut idle)
5094 .await
5095 .is_err(),
5096 "a genuine retained writer handle must prevent the actual idle arm"
5097 );
5098 assert!(tokio::time::timeout(
5099 std::time::Duration::from_millis(150),
5100 wait_for_idle(&d, &persistent)
5101 )
5102 .await
5103 .is_err());
5104 drop(writer);
5105 assert_eq!(pool.retirement_writer_holds(), 0);
5106 tokio::time::timeout(std::time::Duration::from_secs(2), idle)
5107 .await
5108 .unwrap();
5109 assert_eq!(demand.snapshot().phase, DaemonLifecyclePhase::Draining);
5110 assert_eq!(persistent.snapshot().phase, DaemonLifecyclePhase::Serving);
5111 let pooled = lifecycle(DaemonLifetime::Demand);
5112 pooled.ready();
5113 tokio::time::advance(std::time::Duration::from_secs(2)).await;
5114 let pooled_guard = pool.writer().unwrap();
5115 assert!(
5116 !pooled.try_idle(|| idle_retirement_blockers(&d)),
5117 "pooled writer hold must block retirement"
5118 );
5119 drop(pooled_guard);
5120 assert!(pooled.try_idle(|| idle_retirement_blockers(&d)));
5121 }
5122
5123 #[tokio::test(start_paused = true)]
5124 #[serial(background_tasks, tx_registry)]
5125 async fn persistent_idle_wait_never_retires_after_writer_release() {
5126 let dir = tempfile::tempdir().unwrap();
5127 let pool = Arc::new(
5128 ConnectionPool::new(khive_db::PoolConfig {
5129 path: Some(dir.path().join("persistent.db")),
5130 write_queue_enabled: Some(false),
5131 write_routing_strict: false,
5132 ..Default::default()
5133 })
5134 .unwrap(),
5135 );
5136 let bridge = khive_db::SqlBridge::new(Arc::clone(&pool), true);
5137 let held = bridge.writer().await.unwrap();
5138 let d = dispatcher(Some(pool));
5139 let state = lifecycle(DaemonLifetime::Persistent);
5140 state.ready();
5141 tokio::time::advance(std::time::Duration::from_secs(3)).await;
5142 assert!(tokio::time::timeout(
5143 std::time::Duration::from_millis(100),
5144 wait_for_idle(&d, &state)
5145 )
5146 .await
5147 .is_err());
5148 drop(held);
5149 assert!(tokio::time::timeout(
5150 std::time::Duration::from_secs(2),
5151 wait_for_idle(&d, &state)
5152 )
5153 .await
5154 .is_err());
5155 assert_eq!(state.snapshot().phase, DaemonLifecyclePhase::Serving);
5156 }
5157
5158 #[tokio::test(start_paused = true)]
5159 #[serial(background_tasks, tx_registry)]
5160 async fn explicit_sql_reader_transaction_blocks_retirement_without_writer_hold() {
5161 let dir = tempfile::tempdir().unwrap();
5162 let pool = Arc::new(
5163 ConnectionPool::new(khive_db::PoolConfig {
5164 path: Some(dir.path().join("reader.db")),
5165 write_queue_enabled: Some(false),
5166 write_routing_strict: false,
5167 ..Default::default()
5168 })
5169 .unwrap(),
5170 );
5171 let bridge = khive_db::SqlBridge::new(Arc::clone(&pool), true);
5172 let mut reader = bridge.reader().await.unwrap();
5173 reader
5174 .query_all(khive_storage::SqlStatement {
5175 sql: "BEGIN DEFERRED".to_owned(),
5176 params: vec![],
5177 label: Some("idle-reader".to_owned()),
5178 })
5179 .await
5180 .unwrap();
5181 assert_eq!(pool.retirement_writer_holds(), 0);
5182 let d = dispatcher(Some(pool));
5183 let state = lifecycle(DaemonLifetime::Demand);
5184 state.ready();
5185 tokio::time::advance(std::time::Duration::from_secs(3)).await;
5186 assert!(!state.try_idle(|| idle_retirement_blockers(&d)));
5187 assert!(state
5188 .snapshot()
5189 .idle_blockers
5190 .contains(&"open_sql_transaction".to_owned()));
5191 drop(reader);
5192 assert!(state.try_idle(|| idle_retirement_blockers(&d)));
5193 }
5194
5195 #[tokio::test(start_paused = true)]
5196 #[serial(background_tasks, tx_registry)]
5197 async fn unsettled_named_worker_blocks_idle_without_resetting_clock() {
5198 let (release, pending) = tokio::sync::oneshot::channel::<()>();
5199 let task = spawn_named_tracked_task("idle-test-worker", async move {
5200 pending.await.unwrap();
5201 });
5202 let state = lifecycle(DaemonLifetime::Demand);
5203 state.ready();
5204 let d = dispatcher(None);
5205 tokio::time::advance(std::time::Duration::from_secs(2)).await;
5206 assert!(!state.try_idle(|| idle_retirement_blockers(&d)));
5207 assert!(state
5208 .snapshot()
5209 .idle_blockers
5210 .contains(&"unsettled_worker:idle-test-worker".to_owned()));
5211 release.send(()).unwrap();
5212 task.await.unwrap();
5213 assert!(
5214 state.try_idle(|| idle_retirement_blockers(&d)),
5215 "maintenance completion must not reset ordinary activity"
5216 );
5217 }
5218
5219 #[tokio::test(start_paused = true)]
5220 #[serial(background_tasks)]
5221 async fn voluntary_drain_retains_pending_work_past_deadline() {
5222 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5223 let (release, pending) = tokio::sync::oneshot::channel::<()>();
5224 let task = spawn_connection_task(Arc::clone(&active), async move {
5225 pending.await.unwrap();
5226 });
5227 let drain = drain_for_idle(&active, std::time::Duration::from_millis(10));
5228 tokio::pin!(drain);
5229 assert!(
5230 tokio::time::timeout(std::time::Duration::from_secs(1), &mut drain)
5231 .await
5232 .is_err(),
5233 "voluntary timeout must retain admitted work"
5234 );
5235 assert!(!task.is_finished());
5236 release.send(()).unwrap();
5237 task.await.unwrap();
5238 tokio::time::timeout(std::time::Duration::from_secs(1), drain)
5239 .await
5240 .unwrap();
5241 }
5242
5243 #[tokio::test(start_paused = true)]
5244 async fn stalled_response_transport_is_bounded() {
5245 let (mut writer, _held_reader) = tokio::io::duplex(1);
5246 let error = tokio::time::timeout(
5247 std::time::Duration::from_secs(35),
5248 write_response_frame(&mut writer, b"bounded response"),
5249 )
5250 .await
5251 .expect("the production response bound must fire before the fixture ceiling")
5252 .unwrap_err();
5253 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
5254 }
5255
5256 #[tokio::test(start_paused = true)]
5257 async fn draining_handler_refuses_before_dispatch() {
5258 let d = dispatcher(None);
5259 let calls = Arc::clone(&d.dispatch_calls);
5260 let state = lifecycle(DaemonLifetime::Demand);
5261 state.ready();
5262 tokio::time::advance(std::time::Duration::from_secs(2)).await;
5263 assert!(state.try_idle(Vec::new));
5264 tokio::time::resume();
5267 let (mut client, server) = UnixStream::pair().unwrap();
5268 let handle = tokio::spawn(handle_conn_with_lifecycle(
5269 server,
5270 d,
5271 None,
5272 tokio::time::Instant::now() + INITIAL_FRAME_READ_TIMEOUT,
5273 Some(state),
5274 ));
5275 let mut frame = base_request_frame("idle-test");
5276 frame.ops = "stats()".to_owned();
5277 write_frame(&mut client, &serde_json::to_vec(&frame).unwrap())
5278 .await
5279 .unwrap();
5280 let refusal: DaemonResponseFrame =
5281 serde_json::from_slice(&read_frame(&mut client).await.unwrap()).unwrap();
5282 assert!(!refusal.ok);
5283 assert_eq!(refusal.error_detail.unwrap()["code"], "daemon_draining");
5284 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
5285 handle.await.unwrap();
5286 }
5287
5288 #[test]
5289 fn lifecycle_metrics_are_additive_and_generation_is_stable() {
5290 let state = lifecycle(DaemonLifetime::Demand);
5291 let generation = state.snapshot().instance_generation;
5292 let metrics = MetricsSnapshot {
5293 lifecycle: Some(state.snapshot()),
5294 ..Default::default()
5295 };
5296 let decoded: MetricsSnapshot =
5297 serde_json::from_value(serde_json::to_value(metrics).unwrap()).unwrap();
5298 assert_eq!(decoded.lifecycle.unwrap().instance_generation, generation);
5299 let old = serde_json::to_value(MetricsSnapshot::default()).unwrap();
5300 assert!(old.get("lifecycle").is_none());
5301 assert!(serde_json::from_value::<MetricsSnapshot>(old)
5302 .unwrap()
5303 .lifecycle
5304 .is_none());
5305 }
5306 }
5307
5308 #[async_trait]
5309 impl DaemonDispatch for MockDispatch {
5310 fn idle_retirement_blockers(&self) -> Vec<String> {
5311 self.pool
5312 .as_ref()
5313 .filter(|pool| pool.retirement_writer_holds() != 0)
5314 .map(|_| vec!["test_backend:held_writer".to_owned()])
5315 .unwrap_or_default()
5316 }
5317
5318 fn plan(&self, ops: &str) -> String {
5319 khive_request::plan_request(ops, &Default::default()).to_string()
5320 }
5321
5322 async fn dispatch(
5323 &self,
5324 _ops: String,
5325 _presentation: Option<String>,
5326 _presentation_per_op: Option<Vec<Option<String>>>,
5327 _format: Option<String>,
5328 _format_per_op: Option<Vec<Option<String>>>,
5329 _from_wire: bool,
5330 _identity: Option<RequestIdentity>,
5331 ) -> Result<String, String> {
5332 self.dispatch_calls
5333 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5334 match &self.dispatch_err {
5335 Some(msg) => Err(msg.clone()),
5336 None => Ok("{}".to_string()),
5337 }
5338 }
5339
5340 async fn warm_all(&self) {}
5341
5342 fn namespace(&self) -> &str {
5343 &self.namespace
5344 }
5345
5346 fn config_id(&self) -> &str {
5347 &self.config_id
5348 }
5349
5350 fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
5351 self.pool.clone()
5352 }
5353 }
5354
5355 fn base_request_frame(config_id: &str) -> DaemonRequestFrame {
5356 DaemonRequestFrame {
5357 plan: false,
5358 ops: String::new(),
5359 presentation: None,
5360 presentation_per_op: None,
5361 namespace: "local".to_string(),
5362 actor_id: None,
5363 process_ref: None,
5364 visible_namespaces: Vec::new(),
5365 config_id: config_id.to_string(),
5366 protocol_version: PROTOCOL_VERSION,
5367 probe_only: false,
5368 metrics_only: false,
5369 format: None,
5370 format_per_op: None,
5371 from_wire: false,
5372 request_id: None,
5373 }
5374 }
5375
5376 async fn round_trip<D: DaemonDispatch>(
5379 dispatcher: D,
5380 req: &DaemonRequestFrame,
5381 ) -> DaemonResponseFrame {
5382 let (mut client, server) = UnixStream::pair().expect("unix stream pair");
5383 let payload = serde_json::to_vec(req).expect("encode request frame");
5384 let handle = tokio::spawn(async move {
5385 handle_conn(server, dispatcher).await;
5386 });
5387 write_frame(&mut client, &payload)
5388 .await
5389 .expect("write request frame");
5390 let raw = read_frame(&mut client).await.expect("read response frame");
5391 handle.await.expect("handle_conn task panicked");
5392 serde_json::from_slice(&raw).expect("decode response frame")
5393 }
5394
5395 #[tokio::test]
5396 async fn expired_accepted_deadline_refuses_even_buffered_complete_frame() {
5397 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5398 let dispatcher = MockDispatch {
5399 namespace: "local".into(),
5400 config_id: "expired-accept-test".into(),
5401 dispatch_calls: Arc::clone(&calls),
5402 pool: None,
5403 dispatch_err: None,
5404 };
5405 let (mut client, server) = UnixStream::pair().expect("unix stream pair");
5406 let frame = base_request_frame("expired-accept-test");
5407 write_frame(&mut client, &serde_json::to_vec(&frame).unwrap())
5408 .await
5409 .expect("buffer complete frame before handler starts");
5410 let accepted_deadline = tokio::time::Instant::now() - std::time::Duration::from_secs(1);
5414 tokio::time::timeout(
5415 std::time::Duration::from_secs(1),
5416 handle_conn_with_shutdown(server, dispatcher, None, accepted_deadline),
5417 )
5418 .await
5419 .expect("expired accepted deadline must not start a fresh read window");
5420 let mut byte = [0u8; 1];
5421 match client.read(&mut byte).await {
5422 Ok(0) => {}
5423 Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => {}
5424 other => panic!("expected closed socket, got {other:?}"),
5425 }
5426 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
5427 }
5428
5429 struct ReadyFrameReader {
5433 frame: Vec<u8>,
5434 offset: usize,
5435 polls: usize,
5436 }
5437
5438 impl tokio::io::AsyncRead for ReadyFrameReader {
5439 fn poll_read(
5440 self: std::pin::Pin<&mut Self>,
5441 _cx: &mut std::task::Context<'_>,
5442 buf: &mut tokio::io::ReadBuf<'_>,
5443 ) -> std::task::Poll<std::io::Result<()>> {
5444 let reader = self.get_mut();
5445 reader.polls += 1;
5446 let remaining = &reader.frame[reader.offset..];
5447 let count = remaining.len().min(buf.remaining());
5448 buf.put_slice(&remaining[..count]);
5449 reader.offset += count;
5450 std::task::Poll::Ready(Ok(()))
5451 }
5452 }
5453
5454 #[tokio::test]
5455 async fn expired_accepted_deadline_refuses_a_frame_ready_on_first_poll() {
5456 let mut reader = ReadyFrameReader {
5457 frame: [2_u32.to_be_bytes().as_slice(), b"{}"].concat(),
5458 offset: 0,
5459 polls: 0,
5460 };
5461 let accepted_deadline = tokio::time::Instant::now() - std::time::Duration::from_secs(1);
5462 let error = read_initial_frame(&mut reader, accepted_deadline)
5463 .await
5464 .expect_err("a fully ready frame must not outlive its acceptance deadline");
5465 assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
5466 assert_eq!(reader.polls, 0, "an expired frame must not be polled");
5467 }
5468
5469 #[tokio::test]
5473 async fn socket_speaks_khived_protocol_accepts_a_real_khived() {
5474 let dir = tempfile::tempdir().expect("tempdir");
5475 let sock_path = dir.path().join("real.sock");
5476 let listener = UnixListener::bind(&sock_path).expect("bind real listener");
5477 let dispatcher = MockDispatch {
5478 namespace: "local".to_string(),
5479 config_id: "probe-test".to_string(),
5480 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
5481 pool: None,
5482 dispatch_err: None,
5483 };
5484 let accept_task = tokio::spawn(async move {
5485 if let Ok((stream, _)) = listener.accept().await {
5486 handle_conn(stream, dispatcher).await;
5487 }
5488 });
5489
5490 assert!(
5491 socket_speaks_khived_protocol(&sock_path, "probe-test").await,
5492 "a real khived answering the probe_only frame with a matching config_id must be recognized"
5493 );
5494
5495 let _ = tokio::time::timeout(std::time::Duration::from_secs(2), accept_task).await;
5496 }
5497
5498 include!("daemon/probe_listener_tests.rs");
5499
5500 #[tokio::test]
5508 async fn socket_speaks_khived_protocol_rejects_a_non_ack_or_mismatched_response() {
5509 let dir = tempfile::tempdir().expect("tempdir");
5510 let sock_path = dir.path().join("mismatched.sock");
5511 let listener = UnixListener::bind(&sock_path).expect("bind fake listener");
5512 let accept_task = tokio::spawn(async move {
5513 if let Ok((mut stream, _)) = listener.accept().await {
5514 let _raw = read_frame(&mut stream).await.expect("read probe frame");
5515 let resp = DaemonResponseFrame {
5516 ok: false,
5517 result: None,
5518 error: None,
5519 namespace_mismatch: false,
5520 config_mismatch: true,
5521 served_config_id: Some("someone-elses-config".to_string()),
5522 version_mismatch: false,
5523 daemon_protocol_version: PROTOCOL_VERSION,
5524 error_detail: None,
5525 metrics: None,
5526 request_id: None,
5527 };
5528 let payload = serde_json::to_vec(&resp).expect("encode response");
5529 write_frame(&mut stream, &payload)
5530 .await
5531 .expect("write response");
5532 }
5533 });
5534
5535 let speaks = socket_speaks_khived_protocol(&sock_path, "expected-config").await;
5536 assert!(
5537 !speaks,
5538 "a well-formed but non-ack / identity-mismatched response must not be treated as \
5539 the same live khived"
5540 );
5541
5542 let _ = tokio::time::timeout(std::time::Duration::from_secs(2), accept_task).await;
5543 }
5544
5545 #[tokio::test]
5557 async fn socket_speaks_khived_protocol_rejects_a_metrics_only_response() {
5558 let dir = tempfile::tempdir().expect("tempdir");
5559 let sock_path = dir.path().join("metrics-only.sock");
5560 let listener = UnixListener::bind(&sock_path).expect("bind fake listener");
5561 let accept_task = tokio::spawn(async move {
5562 if let Ok((mut stream, _)) = listener.accept().await {
5563 let _raw = read_frame(&mut stream).await.expect("read probe frame");
5564 let resp = DaemonResponseFrame {
5565 ok: true,
5566 result: None,
5567 error: None,
5568 namespace_mismatch: false,
5569 config_mismatch: false,
5570 served_config_id: Some("expected-config".to_string()),
5571 version_mismatch: false,
5572 daemon_protocol_version: PROTOCOL_VERSION,
5573 error_detail: None,
5574 metrics: Some(MetricsSnapshot::default()),
5575 request_id: None,
5576 };
5577 let payload = serde_json::to_vec(&resp).expect("encode response");
5578 write_frame(&mut stream, &payload)
5579 .await
5580 .expect("write response");
5581 }
5582 });
5583
5584 let speaks = socket_speaks_khived_protocol(&sock_path, "expected-config").await;
5585 assert!(
5586 !speaks,
5587 "an otherwise-matching response carrying a metrics snapshot must not be treated as \
5588 a probe acknowledgement"
5589 );
5590
5591 tokio::time::timeout(std::time::Duration::from_secs(2), accept_task)
5592 .await
5593 .expect("fake listener accept task timed out")
5594 .expect("fake listener accept task panicked");
5595 }
5596
5597 #[tokio::test]
5604 async fn socket_speaks_khived_protocol_rejects_a_response_with_request_id() {
5605 let dir = tempfile::tempdir().expect("tempdir");
5606 let sock_path = dir.path().join("request-id.sock");
5607 let listener = UnixListener::bind(&sock_path).expect("bind fake listener");
5608 let accept_task = tokio::spawn(async move {
5609 if let Ok((mut stream, _)) = listener.accept().await {
5610 let _raw = read_frame(&mut stream).await.expect("read probe frame");
5611 let resp = DaemonResponseFrame {
5612 ok: true,
5613 result: None,
5614 error: None,
5615 namespace_mismatch: false,
5616 config_mismatch: false,
5617 served_config_id: Some("expected-config".to_string()),
5618 version_mismatch: false,
5619 daemon_protocol_version: PROTOCOL_VERSION,
5620 error_detail: None,
5621 metrics: None,
5622 request_id: Some(42),
5623 };
5624 let payload = serde_json::to_vec(&resp).expect("encode response");
5625 write_frame(&mut stream, &payload)
5626 .await
5627 .expect("write response");
5628 }
5629 });
5630
5631 let speaks = socket_speaks_khived_protocol(&sock_path, "expected-config").await;
5632 assert!(
5633 !speaks,
5634 "an otherwise-matching response carrying an echoed request_id must not be treated \
5635 as a probe acknowledgement"
5636 );
5637
5638 tokio::time::timeout(std::time::Duration::from_secs(2), accept_task)
5639 .await
5640 .expect("fake listener accept task timed out")
5641 .expect("fake listener accept task panicked");
5642 }
5643
5644 #[derive(Clone)]
5645 struct DetailedDispatch {
5646 calls: Arc<std::sync::atomic::AtomicUsize>,
5647 detail: serde_json::Value,
5648 }
5649
5650 #[async_trait]
5651 impl DaemonDispatch for DetailedDispatch {
5652 fn plan(&self, ops: &str) -> String {
5653 khive_request::plan_request(ops, &Default::default()).to_string()
5654 }
5655
5656 async fn dispatch(
5657 &self,
5658 _ops: String,
5659 _presentation: Option<String>,
5660 _presentation_per_op: Option<Vec<Option<String>>>,
5661 _format: Option<String>,
5662 _format_per_op: Option<Vec<Option<String>>>,
5663 _from_wire: bool,
5664 _identity: Option<RequestIdentity>,
5665 ) -> Result<String, String> {
5666 panic!("the daemon must use the detailed dispatch seam");
5667 }
5668
5669 async fn dispatch_with_error_detail(
5670 &self,
5671 _ops: String,
5672 _presentation: Option<String>,
5673 _presentation_per_op: Option<Vec<Option<String>>>,
5674 _format: Option<String>,
5675 _format_per_op: Option<Vec<Option<String>>>,
5676 _from_wire: bool,
5677 _identity: Option<RequestIdentity>,
5678 ) -> Result<String, DaemonDispatchError> {
5679 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5680 Err(DaemonDispatchError::new(
5681 "audit failed",
5682 Some(self.detail.clone()),
5683 ))
5684 }
5685
5686 async fn warm_all(&self) {}
5687
5688 fn namespace(&self) -> &str {
5689 "local"
5690 }
5691
5692 fn config_id(&self) -> &str {
5693 "disposition-test"
5694 }
5695 }
5696
5697 #[tokio::test]
5698 async fn disposition_detail_survives_daemon_framing_and_legacy_v4_decoder() {
5699 #[allow(dead_code)]
5700 #[derive(serde::Deserialize)]
5701 struct LegacyV4Response {
5702 ok: bool,
5703 result: Option<String>,
5704 error: Option<String>,
5705 namespace_mismatch: bool,
5706 #[serde(default)]
5707 config_mismatch: bool,
5708 #[serde(default)]
5709 served_config_id: Option<String>,
5710 #[serde(default)]
5711 version_mismatch: bool,
5712 #[serde(default)]
5713 daemon_protocol_version: u32,
5714 #[serde(default)]
5715 metrics: Option<MetricsSnapshot>,
5716 #[serde(default)]
5717 request_id: Option<u64>,
5718 }
5719
5720 let detail = serde_json::json!({
5721 "kind": "obligation",
5722 "code": "store_failure",
5723 "message": "audit failed",
5724 "domain_disposition": "committed",
5725 "domain_result": { "id": "persisted-row" },
5726 });
5727 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5728 let response = round_trip(
5729 DetailedDispatch {
5730 calls: Arc::clone(&calls),
5731 detail: detail.clone(),
5732 },
5733 &base_request_frame("disposition-test"),
5734 )
5735 .await;
5736 assert_eq!(response.error_detail.as_ref(), Some(&detail));
5737 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
5738 let encoded = serde_json::to_vec(&response).expect("serialize detailed response");
5739 let legacy: LegacyV4Response = serde_json::from_slice(&encoded).expect("legacy v4 decode");
5740 assert!(!legacy.ok);
5741 assert_eq!(legacy.error.as_deref(), Some("audit failed"));
5742 assert_eq!(legacy.daemon_protocol_version, PROTOCOL_VERSION);
5743 }
5744
5745 #[tokio::test]
5746 async fn disposition_legacy_dispatch_error_is_unknown_and_success_has_no_detail() {
5747 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5748 let dispatcher = MockDispatch {
5749 namespace: "local".to_string(),
5750 config_id: "disposition-test".to_string(),
5751 dispatch_calls: Arc::clone(&calls),
5752 pool: None,
5753 dispatch_err: Some("legacy failure".to_string()),
5754 };
5755 let request = base_request_frame("disposition-test");
5756 let failure = round_trip(dispatcher.clone(), &request).await;
5757 assert_eq!(
5758 failure.error_detail.as_ref().unwrap()["domain_disposition"],
5759 "unknown"
5760 );
5761 assert_eq!(failure.error.as_deref(), Some("legacy failure"));
5762 let success = round_trip(
5763 MockDispatch {
5764 dispatch_err: None,
5765 ..dispatcher
5766 },
5767 &request,
5768 )
5769 .await;
5770 assert!(success.ok);
5771 assert!(serde_json::to_value(success)
5772 .unwrap()
5773 .get("error_detail")
5774 .is_none());
5775 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
5776 }
5777
5778 #[test]
5779 fn disposition_new_decoder_accepts_legacy_v4_error_without_detail() {
5780 let response: DaemonResponseFrame = serde_json::from_str(
5781 r#"{
5782 "ok":false,"result":null,"error":"legacy failure",
5783 "namespace_mismatch":false,"config_mismatch":false,
5784 "served_config_id":"cfg","version_mismatch":false,
5785 "daemon_protocol_version":4,"request_id":null
5786 }"#,
5787 )
5788 .expect("decode legacy v4 error frame");
5789 assert!(response.error_detail.is_none());
5790 assert_eq!(response.error.as_deref(), Some("legacy failure"));
5791 }
5792
5793 #[test]
5794 fn disposition_normalization_omits_unconfirmed_domain_results() {
5795 for disposition in ["not_committed", "unknown", "unrecognized"] {
5796 let error = DaemonDispatchError::new(
5797 "failure",
5798 Some(serde_json::json!({
5799 "message": "failure",
5800 "domain_disposition": disposition,
5801 "domain_result": { "id": "unconfirmed" },
5802 })),
5803 );
5804 assert!(error.error_detail.get("domain_result").is_none());
5805 assert_eq!(
5806 error.error_detail["domain_disposition"],
5807 if disposition == "not_committed" {
5808 "not_committed"
5809 } else {
5810 "unknown"
5811 }
5812 );
5813 }
5814 }
5815
5816 #[test]
5817 fn disposition_normalization_iteratively_discards_deep_owned_values() {
5818 for disposition in ["committed", "not_committed", "unknown"] {
5819 let mut value = serde_json::Value::Null;
5820 for _ in 0..4096 {
5821 value = serde_json::Value::Array(vec![value]);
5822 }
5823 let fields = serde_json::Map::from_iter([
5824 ("domain_disposition".into(), serde_json::json!(disposition)),
5825 ("domain_result".into(), value),
5826 ]);
5827 let error =
5828 DaemonDispatchError::new("failure", Some(serde_json::Value::Object(fields)));
5829 assert!(error.error_detail.get("domain_result").is_none());
5830 assert_eq!(error.error_detail["domain_disposition"], disposition);
5831 if disposition == "committed" {
5832 assert_eq!(error.error_detail["code"], "result_too_deep");
5833 }
5834 serde_json::to_vec(&error.error_detail).expect("bounded error detail serializes");
5835 }
5836 let mut value = serde_json::Value::Null;
5837 for _ in 0..4096 {
5838 value = serde_json::Value::Array(vec![value]);
5839 }
5840 let error = DaemonDispatchError::new("failure", Some(value));
5841 assert_eq!(error.error_detail["code"], "error_detail_too_deep");
5842 assert_eq!(error.error_detail["domain_disposition"], "unknown");
5843 assert!(error.error_detail.get("data").is_none());
5844 }
5845
5846 #[tokio::test]
5847 async fn daemon_peer_disconnect_signals_request_read_cancellation() {
5848 let started = Arc::new(tokio::sync::Notify::new());
5849 let cancellation_observed = Arc::new(std::sync::atomic::AtomicBool::new(false));
5850 let dispatcher = CancellationAwareDispatch {
5851 started: Arc::clone(&started),
5852 cancellation_observed: Arc::clone(&cancellation_observed),
5853 count_sql: None,
5854 };
5855 let (mut client, server) = UnixStream::pair().expect("unix stream pair");
5856 let request = base_request_frame("disconnect-test");
5857 let payload = serde_json::to_vec(&request).expect("encode request frame");
5858 let handler = tokio::spawn(async move { handle_conn(server, dispatcher).await });
5859 write_frame(&mut client, &payload)
5860 .await
5861 .expect("write request frame");
5862 started.notified().await;
5863
5864 drop(client);
5865 tokio::time::timeout(std::time::Duration::from_millis(500), handler)
5866 .await
5867 .expect("daemon handler ignored peer disconnect")
5868 .expect("daemon handler panicked");
5869 assert!(
5870 cancellation_observed.load(std::sync::atomic::Ordering::SeqCst),
5871 "peer loss did not reach the request-scoped read cancellation signal"
5872 );
5873 }
5874
5875 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5876 async fn daemon_disconnect_interrupts_pooled_stats_count() {
5877 use khive_storage::{SqlAccess, SqlStatement, SqlValue};
5878 let dir = tempfile::tempdir().unwrap();
5879 let pool = Arc::new(
5880 ConnectionPool::new(khive_db::PoolConfig {
5881 path: Some(dir.path().join("disconnect-count.db")),
5882 max_readers: 1,
5883 ..Default::default()
5884 })
5885 .unwrap(),
5886 );
5887 pool.writer()
5888 .unwrap()
5889 .conn()
5890 .execute_batch(
5891 "CREATE TABLE count_fixture(n INTEGER PRIMARY KEY); \
5892 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1000) \
5893 INSERT INTO count_fixture SELECT x FROM n; \
5894 CREATE VIEW events AS SELECT 'local' AS namespace, 'knowledge.learn' AS verb \
5895 FROM count_fixture a CROSS JOIN count_fixture b CROSS JOIN count_fixture c;",
5896 )
5897 .unwrap();
5898 let sql = Arc::new(khive_db::SqlBridge::new(Arc::clone(&pool), true));
5899 let cancellation_observed = Arc::new(std::sync::atomic::AtomicBool::new(false));
5900 let dispatcher = CancellationAwareDispatch {
5901 started: Arc::new(tokio::sync::Notify::new()),
5902 cancellation_observed: Arc::clone(&cancellation_observed),
5903 count_sql: Some(sql.clone()),
5904 };
5905 let (mut client, server) = UnixStream::pair().unwrap();
5906 let request = base_request_frame("disconnect-test");
5907 let progress = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5908 let handler = tokio::spawn(khive_db::scope_test_read_progress(
5909 Arc::clone(&progress),
5910 async move { handle_conn(server, dispatcher).await },
5911 ));
5912 write_frame(&mut client, &serde_json::to_vec(&request).unwrap())
5913 .await
5914 .unwrap();
5915 tokio::time::timeout(std::time::Duration::from_secs(2), async {
5916 while progress.load(std::sync::atomic::Ordering::SeqCst) == 0 {
5917 assert!(
5918 !handler.is_finished(),
5919 "COUNT returned before its first SQLite progress callback"
5920 );
5921 tokio::task::yield_now().await;
5922 }
5923 })
5924 .await
5925 .unwrap();
5926 assert!(
5927 !handler.is_finished(),
5928 "COUNT must be outstanding at disconnect"
5929 );
5930 let started = std::time::Instant::now();
5931 let grace = khive_db::sqlite_interrupt_grace_from_env();
5932 drop(client);
5933 tokio::time::timeout(grace, handler)
5934 .await
5935 .expect("disconnected COUNT did not settle within interrupt grace")
5936 .unwrap();
5937 assert!(cancellation_observed.load(std::sync::atomic::Ordering::SeqCst));
5938 let snapshot = pool.reader_acquisition_snapshot();
5939 assert_eq!(snapshot.active_pooled_checkouts, 0);
5940 assert_eq!(snapshot.available_reader_admission_slots, 1);
5941 eprintln!(
5942 "daemon_stats_count_disconnect_ms={} grace_ms={}",
5943 started.elapsed().as_secs_f64() * 1000.0,
5944 grace.as_millis()
5945 );
5946 let count = sql
5947 .reader()
5948 .await
5949 .unwrap()
5950 .query_scalar(SqlStatement {
5951 sql: "SELECT COUNT(*) FROM count_fixture".into(),
5952 params: vec![],
5953 label: None,
5954 })
5955 .await
5956 .unwrap();
5957 assert!(matches!(count, Some(SqlValue::Integer(1000))));
5958 }
5959
5960 #[tokio::test]
5965 async fn protocol_v3_frame_is_rejected_before_process_ref_dispatch() {
5966 const {
5967 assert!(
5968 PROTOCOL_VERSION >= 4,
5969 "process_ref requires protocol v4 or later"
5970 )
5971 };
5972 let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5973 let dispatcher = MockDispatch {
5974 namespace: "local".to_string(),
5975 config_id: "cfg-v4".to_string(),
5976 dispatch_calls: Arc::clone(&dispatch_calls),
5977 pool: None,
5978 dispatch_err: None,
5979 };
5980 let mut request = base_request_frame("cfg-v4");
5981 request.protocol_version = 3;
5982 request.process_ref = Some("worker/legacy-rollout".to_string());
5983
5984 let response = round_trip(dispatcher, &request).await;
5985 assert!(!response.ok);
5986 assert!(
5987 !response.version_mismatch,
5988 "a client below this protocol is answered in the implicit shape its bridge re-execs on"
5989 );
5990 assert_eq!(
5991 response.error_detail.as_ref().unwrap()["code"],
5992 "version_mismatch"
5993 );
5994 assert_eq!(
5995 response.error_detail.as_ref().unwrap()["domain_disposition"],
5996 "unknown"
5997 );
5998 assert_eq!(response.daemon_protocol_version, PROTOCOL_VERSION);
5999 assert_eq!(
6000 dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6001 0,
6002 "a v3 frame must be rejected before a provenance-bearing mutation dispatches"
6003 );
6004 let error = response.error.expect("mismatch explains both versions");
6005 assert!(
6006 error.contains("client=3") && error.contains(&format!("daemon={PROTOCOL_VERSION}")),
6007 "mismatch must identify the exact rollout boundary; got {error:?}"
6008 );
6009 }
6010
6011 #[tokio::test]
6015 async fn protocol_v7_frame_is_rejected_before_compatible_superset_dispatch() {
6016 let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6017 let client_id = config_id("p", "");
6018 let daemon_id = config_id("p", "m");
6019 assert!(super::config_ids_compatible(&client_id, &daemon_id));
6020 let dispatcher = MockDispatch {
6021 namespace: "local".to_string(),
6022 config_id: daemon_id,
6023 dispatch_calls: Arc::clone(&dispatch_calls),
6024 pool: None,
6025 dispatch_err: None,
6026 };
6027 let mut request = base_request_frame(&client_id);
6028 request.protocol_version = 7;
6029
6030 let response = round_trip(dispatcher, &request).await;
6031 assert!(!response.ok);
6032 assert!(!response.version_mismatch);
6033 assert_eq!(
6034 response.error_detail.as_ref().unwrap()["code"],
6035 "version_mismatch"
6036 );
6037 assert_eq!(response.daemon_protocol_version, PROTOCOL_VERSION);
6038 assert_eq!(dispatch_calls.load(std::sync::atomic::Ordering::SeqCst), 0);
6039 }
6040
6041 #[tokio::test]
6047 async fn newer_client_frame_is_refused_with_the_explicit_flag() {
6048 let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6049 let dispatcher = MockDispatch {
6050 namespace: "local".to_string(),
6051 config_id: "cfg-v4".to_string(),
6052 dispatch_calls: Arc::clone(&dispatch_calls),
6053 pool: None,
6054 dispatch_err: None,
6055 };
6056 let mut request = base_request_frame("cfg-v4");
6057 request.protocol_version = PROTOCOL_VERSION + 1;
6058
6059 let response = round_trip(dispatcher, &request).await;
6060 assert!(!response.ok);
6061 assert!(
6062 response.version_mismatch,
6063 "a client above this protocol keeps the explicit flag"
6064 );
6065 assert_eq!(response.daemon_protocol_version, PROTOCOL_VERSION);
6066 assert_eq!(
6067 response.error_detail.as_ref().unwrap()["code"],
6068 "version_mismatch"
6069 );
6070 assert_eq!(
6071 dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6072 0,
6073 "a newer client's frame must not dispatch"
6074 );
6075 }
6076
6077 #[tokio::test]
6084 async fn metrics_only_frame_returns_snapshot_without_dispatching() {
6085 let dispatch_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
6086 let dispatcher = MockDispatch {
6087 namespace: "local".to_string(),
6088 config_id: "cfg-a".to_string(),
6089 dispatch_calls: Arc::clone(&dispatch_calls),
6090 pool: None,
6091 dispatch_err: None,
6092 };
6093
6094 let mut metrics_req = base_request_frame("cfg-a");
6095 metrics_req.metrics_only = true;
6096 let metrics_resp = round_trip(dispatcher.clone(), &metrics_req).await;
6097
6098 assert!(metrics_resp.ok, "metrics_only response must be ok=true");
6099 assert!(
6100 metrics_resp.metrics.is_some(),
6101 "metrics_only=true must return Some(snapshot)"
6102 );
6103 assert_eq!(
6104 dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6105 0,
6106 "metrics_only must never reach the ops-dispatch path"
6107 );
6108
6109 let mut mismatched_req = base_request_frame("some-other-config");
6111 mismatched_req.metrics_only = true;
6112 let mismatched_resp = round_trip(dispatcher.clone(), &mismatched_req).await;
6113 assert!(mismatched_resp.ok);
6114 assert!(mismatched_resp.metrics.is_some());
6115 assert!(!mismatched_resp.config_mismatch);
6116 assert_eq!(
6117 dispatch_calls.load(std::sync::atomic::Ordering::SeqCst),
6118 0,
6119 "a mismatched-config metrics_only request must still skip dispatch"
6120 );
6121
6122 let normal_req = base_request_frame("cfg-a");
6125 let normal_resp = round_trip(dispatcher, &normal_req).await;
6126 assert!(normal_resp.ok);
6127 assert!(normal_resp.metrics.is_none());
6128 assert_eq!(dispatch_calls.load(std::sync::atomic::Ordering::SeqCst), 1);
6129 }
6130
6131 #[test]
6144 fn shared_writable_socket_dirs_are_refused_without_being_modified() {
6145 let dir = tempfile::tempdir().expect("tempdir");
6146 for (name, mode) in [("open", 0o777u32), ("sticky-tmp", 0o1777u32)] {
6147 let shared = dir.path().join(name);
6148 std::fs::create_dir(&shared).expect("create");
6149 std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))
6150 .expect("chmod");
6151
6152 let err = ensure_socket_dir_is_trusted(&shared)
6153 .expect_err("group/other-writable must be refused, sticky or not");
6154 assert!(
6155 err.to_string().contains(&format!("{:04o}", mode & 0o7777)),
6156 "the refusal should name the mode it saw, got: {err}"
6157 );
6158 let after = std::fs::metadata(&shared)
6159 .expect("stat")
6160 .permissions()
6161 .mode()
6162 & 0o7777;
6163 assert_eq!(
6164 after, mode,
6165 "refusing must not re-permission a directory khive does not own"
6166 );
6167 }
6168 }
6169
6170 #[test]
6178 fn writable_non_sticky_ancestor_is_refused() {
6179 let dir = tempfile::tempdir().expect("tempdir");
6180 let open_mid = dir.path().join("open-mid");
6181 std::fs::create_dir(&open_mid).expect("create mid");
6182 let inner = open_mid.join("private");
6183 std::fs::create_dir(&inner).expect("create inner");
6184 std::fs::set_permissions(&inner, std::fs::Permissions::from_mode(0o700)).expect("chmod");
6185 std::fs::set_permissions(&open_mid, std::fs::Permissions::from_mode(0o777))
6186 .expect("chmod mid");
6187
6188 let err = ensure_socket_dir_is_trusted(&inner)
6189 .expect_err("a 0777 non-sticky ancestor must be refused");
6190 assert!(
6191 err.to_string().contains("ancestor"),
6192 "the refusal should say it was an ancestor that failed, got: {err}"
6193 );
6194 assert!(
6195 err.to_string().contains("open-mid"),
6196 "the refusal should name the failing ancestor, got: {err}"
6197 );
6198 }
6199
6200 #[test]
6207 fn symlink_component_to_untrusted_directory_is_refused() {
6208 let dir = tempfile::tempdir().expect("tempdir");
6209 let open = dir.path().join("open-target");
6210 std::fs::create_dir(&open).expect("create target");
6211 std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).expect("chmod");
6212 let link = dir.path().join("link");
6213 std::os::unix::fs::symlink(&open, &link).expect("symlink");
6214
6215 let euid = unsafe { libc::geteuid() } as u32;
6217 let err = ensure_socket_path_is_swap_resistant(&link, euid)
6218 .expect_err("a link into a 0777 non-sticky directory must be refused");
6219 assert!(
6220 err.to_string().contains("open-target"),
6221 "the refusal should name the untrusted target directory, got: {err}"
6222 );
6223 }
6224
6225 include!("daemon/socket_path_tests.rs");
6226
6227 #[test]
6236 fn trusted_socket_dirs_are_accepted_unmodified() {
6237 let dir = tempfile::tempdir().expect("tempdir");
6238 for (name, mode) in [("private", 0o700), ("listable", 0o755)] {
6239 let d = dir.path().join(name);
6240 std::fs::create_dir(&d).expect("create");
6241 std::fs::set_permissions(&d, std::fs::Permissions::from_mode(mode)).expect("chmod");
6242
6243 ensure_socket_dir_is_trusted(&d)
6244 .unwrap_or_else(|e| panic!("mode {mode:04o} must be accepted, got: {e}"));
6245
6246 let after = std::fs::metadata(&d).expect("stat").permissions().mode() & 0o7777;
6247 assert_eq!(
6248 after, mode,
6249 "acceptance must not re-permission the directory either"
6250 );
6251 }
6252 }
6253
6254 #[test]
6255 fn pid_directory_owned_by_another_uid_is_refused() {
6256 let dir = tempfile::Builder::new()
6257 .prefix("khive-pid-owner-")
6258 .tempdir()
6259 .expect("tempdir");
6260 let parent = dir.path().join("private");
6261 std::fs::create_dir(&parent).expect("create private directory");
6262 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700))
6263 .expect("set private mode");
6264
6265 let daemon_euid = (unsafe { libc::geteuid() } as u32).wrapping_add(1);
6269 let error = ensure_rendezvous_dir_is_trusted(
6270 &parent,
6271 RendezvousPathRole::PidFile,
6272 daemon_euid,
6273 false,
6274 )
6275 .expect_err("a PID parent owned by another uid must be refused");
6276 let message = format!("{error:#}");
6277
6278 assert!(
6279 message.contains("KHIVE_PID"),
6280 "wrong variable in refusal: {message}"
6281 );
6282 assert!(
6283 message.contains("PID-file directory") && message.contains("owned by uid"),
6284 "refusal must identify foreign ownership of the PID parent: {message}"
6285 );
6286 }
6287
6288 #[tokio::test]
6293 async fn metrics_snapshot_wal_pages_reflects_recent_write() {
6294 let dir = tempfile::tempdir().expect("tempdir");
6295 let path = dir.path().join("metrics_wal_test.db");
6296 let pool = Arc::new(
6297 ConnectionPool::new(khive_db::PoolConfig {
6298 path: Some(path),
6299 ..khive_db::PoolConfig::for_test()
6300 })
6301 .expect("pool open"),
6302 );
6303
6304 {
6305 let writer = pool.try_writer().expect("writer");
6306 writer
6307 .conn()
6308 .execute_batch(
6309 "CREATE TABLE t (x INTEGER); \
6310 INSERT INTO t VALUES (1); \
6311 INSERT INTO t VALUES (2);",
6312 )
6313 .expect("seed writes");
6314 }
6315
6316 let dedicated_conn = pool
6317 .open_standalone_writer()
6318 .expect("open dedicated checkpoint connection");
6319 khive_db::checkpoint_once(
6320 &pool,
6321 &dedicated_conn,
6322 &CheckpointConfig::default(),
6323 &mut khive_db::checkpoint::TruncateState::default(),
6324 )
6325 .expect("checkpoint_once must observe on a healthy dedicated connection");
6326
6327 let dispatcher = MockDispatch {
6328 namespace: "local".to_string(),
6329 config_id: "cfg-wal".to_string(),
6330 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6331 pool: Some(pool),
6332 dispatch_err: None,
6333 };
6334
6335 let snapshot = build_metrics_snapshot(&dispatcher);
6336 assert!(
6337 snapshot.wal_pages.is_some(),
6338 "wal_pages must be observed after a real checkpoint tick, got {snapshot:?}"
6339 );
6340 assert_eq!(snapshot.wal_log_frames, snapshot.wal_pages);
6341 assert!(snapshot.wal_checkpointed_frames.is_some());
6342 assert!(snapshot.wal_pending_frames.is_some());
6343 assert!(snapshot.wal_physical_bytes.is_some());
6344 assert!(snapshot.wal_observed_at_unix_ms.is_some());
6345 assert_eq!(snapshot.wal_checkpoint_stores.len(), 1);
6346 assert_eq!(snapshot.wal_checkpoint_stores[0].store_id, "main");
6347 assert_eq!(snapshot.wal_checkpoint_stores[0].timing.ticks, 1);
6348 assert_eq!(
6352 snapshot.wal_checkpoint_consecutive_skips, 0,
6353 "an observed (non-skipped) tick must report zero consecutive skips, got {snapshot:?}"
6354 );
6355 }
6356
6357 #[derive(Clone)]
6358 struct CheckpointMetricsDispatch {
6359 main: Option<Arc<ConnectionPool>>,
6360 secondaries: Vec<Arc<ConnectionPool>>,
6361 }
6362
6363 #[async_trait]
6364 impl DaemonDispatch for CheckpointMetricsDispatch {
6365 fn plan(&self, _ops: &str) -> String {
6366 panic!("metrics must not plan")
6367 }
6368 async fn dispatch(
6369 &self,
6370 _ops: String,
6371 _presentation: Option<String>,
6372 _presentation_per_op: Option<Vec<Option<String>>>,
6373 _format: Option<String>,
6374 _format_per_op: Option<Vec<Option<String>>>,
6375 _from_wire: bool,
6376 _identity: Option<RequestIdentity>,
6377 ) -> Result<String, String> {
6378 panic!("metrics must not dispatch")
6379 }
6380 async fn warm_all(&self) {}
6381 fn namespace(&self) -> &str {
6382 "local"
6383 }
6384 fn config_id(&self) -> &str {
6385 "checkpoint-metrics"
6386 }
6387 fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
6388 self.main.clone()
6389 }
6390 fn secondary_pools_for_checkpoint(&self) -> Vec<Arc<ConnectionPool>> {
6391 self.secondaries.clone()
6392 }
6393 }
6394
6395 #[tokio::test]
6396 #[serial(checkpoint_skip_metrics)]
6397 async fn metrics_checkpoint_timing_keeps_stores_separate_and_scrapes_read_only() {
6398 let dir = tempfile::tempdir().unwrap();
6399 let mut pools = Vec::new();
6400 for label in ["primary", "secondary"] {
6401 let directory = dir.path().join(label);
6402 std::fs::create_dir(&directory).unwrap();
6403 let pool = Arc::new(
6404 ConnectionPool::new(khive_db::PoolConfig {
6405 path: Some(directory.join("same.db")),
6406 ..khive_db::PoolConfig::for_test()
6407 })
6408 .unwrap(),
6409 );
6410 pool.try_writer()
6411 .unwrap()
6412 .conn()
6413 .execute_batch("CREATE TABLE t (x INTEGER); INSERT INTO t VALUES (1);")
6414 .unwrap();
6415 pools.push(pool);
6416 }
6417 let dispatcher = CheckpointMetricsDispatch {
6418 main: Some(Arc::clone(&pools[0])),
6419 secondaries: vec![Arc::clone(&pools[1])],
6420 };
6421 let before = build_metrics_snapshot(&dispatcher);
6422 assert_eq!(before.wal_checkpoint_stores.len(), 2);
6423 assert!(before
6424 .wal_checkpoint_stores
6425 .iter()
6426 .all(|store| store.timing.ticks == 0));
6427 for (index, pool) in pools.iter().enumerate() {
6428 let conn = pool.open_standalone_writer().unwrap();
6429 for _ in 0..=index {
6430 khive_db::checkpoint_once(
6431 pool,
6432 &conn,
6433 &CheckpointConfig {
6434 truncate_high_water_pages: u64::MAX,
6435 ..CheckpointConfig::default()
6436 },
6437 &mut khive_db::checkpoint::TruncateState::default(),
6438 )
6439 .unwrap();
6440 }
6441 }
6442 let mut request = base_request_frame("checkpoint-metrics");
6443 request.metrics_only = true;
6444 let snapshot = round_trip(dispatcher.clone(), &request)
6445 .await
6446 .metrics
6447 .unwrap();
6448 let stores = &snapshot.wal_checkpoint_stores;
6449 assert_eq!(stores.len(), 2);
6450 assert_eq!(stores[0].store_id, "main");
6451 assert_eq!(stores[0].role, "main");
6452 assert_eq!(stores[1].store_id, "secondary:0");
6453 assert_eq!(stores[1].role, "secondary");
6454 for store in stores {
6455 assert_eq!(
6456 store.database.as_deref(),
6457 Some("same.db"),
6458 "wire label must omit directories"
6459 );
6460 assert!(store.timing.elapsed_us_max <= store.timing.elapsed_us_sum);
6461 }
6462 assert_eq!(stores[0].timing.ticks, 1);
6463 assert_eq!(stores[1].timing.ticks, 2);
6464 let again = build_metrics_snapshot(&dispatcher);
6465 assert_eq!(
6466 again.wal_checkpoint_stores, *stores,
6467 "scraping must not checkpoint"
6468 );
6469 let secondary_only = build_metrics_snapshot(&CheckpointMetricsDispatch {
6470 main: None,
6471 secondaries: vec![Arc::clone(&pools[1])],
6472 });
6473 assert_eq!(secondary_only.wal_checkpoint_stores.len(), 1);
6474 assert_eq!(
6475 secondary_only.wal_checkpoint_stores[0].store_id,
6476 "secondary:0"
6477 );
6478 assert_eq!(secondary_only.wal_checkpoint_stores[0].timing.ticks, 2);
6479 assert!(build_metrics_snapshot(&CheckpointMetricsDispatch {
6480 main: None,
6481 secondaries: vec![]
6482 })
6483 .wal_checkpoint_stores
6484 .is_empty());
6485 }
6486
6487 #[test]
6488 fn metrics_checkpoint_timing_serde_is_additive_and_round_trips() {
6489 let snapshot = MetricsSnapshot {
6490 wal_checkpoint_stores: vec![CheckpointStoreMetrics {
6491 store_id: "secondary:0".into(),
6492 role: "secondary".into(),
6493 database: Some("memory.db".into()),
6494 timing: khive_db::checkpoint::CheckpointTiming {
6495 ticks: 7,
6496 elapsed_us_sum: 123,
6497 elapsed_us_max: 50,
6498 busy_ticks: 2,
6499 error_ticks: 1,
6500 },
6501 }],
6502 ..MetricsSnapshot::default()
6503 };
6504 let wire = serde_json::to_value(&snapshot).unwrap();
6505 let store = &wire["wal_checkpoint_stores"][0];
6506 assert_eq!(
6507 store,
6508 &serde_json::json!({
6509 "store_id": "secondary:0", "role": "secondary", "database": "memory.db",
6510 "ticks": 7, "elapsed_us_sum": 123, "elapsed_us_max": 50, "busy_ticks": 2, "error_ticks": 1,
6511 })
6512 );
6513 assert_eq!(
6514 serde_json::from_value::<MetricsSnapshot>(wire.clone()).unwrap(),
6515 snapshot
6516 );
6517 let mut old_wire = wire.clone();
6518 old_wire
6519 .as_object_mut()
6520 .unwrap()
6521 .remove("wal_checkpoint_stores");
6522 let old = serde_json::from_value::<MetricsSnapshot>(old_wire).unwrap();
6523 assert!(
6524 old.wal_checkpoint_stores.is_empty(),
6525 "old snapshot must default the new vector"
6526 );
6527 let partial = serde_json::from_value::<CheckpointStoreMetrics>(serde_json::json!({
6528 "store_id": "main", "role": "main"
6529 }))
6530 .unwrap();
6531 assert_eq!(
6532 partial.timing,
6533 khive_db::checkpoint::CheckpointTiming::default()
6534 );
6535 assert_eq!(partial.database, None);
6536 #[derive(serde::Deserialize)]
6537 struct LegacyMetrics {
6538 wal_pages: Option<u64>,
6539 open_tx_count: usize,
6540 }
6541 let legacy: LegacyMetrics = serde_json::from_value(wire).unwrap();
6542 assert_eq!(legacy.wal_pages, None);
6543 assert_eq!(legacy.open_tx_count, 0);
6544 }
6545
6546 #[tokio::test]
6547 async fn metrics_snapshot_exposes_decomposed_writer_stages() {
6548 let dir = tempfile::tempdir().expect("tempdir");
6549 let path = dir.path().join("metrics_writer_stage_test.db");
6550 let pool = Arc::new(
6551 ConnectionPool::new(khive_db::PoolConfig {
6552 path: Some(path),
6553 ..khive_db::PoolConfig::for_test()
6554 })
6555 .expect("pool open"),
6556 );
6557 {
6558 let writer = pool.try_writer().unwrap();
6559 writer
6560 .conn()
6561 .execute_batch("CREATE TABLE t (id INTEGER PRIMARY KEY)")
6562 .unwrap();
6563 }
6564 let handle = pool
6565 .writer_task_handle()
6566 .unwrap()
6567 .expect("file-backed default writer task");
6568 handle
6569 .send(|conn| {
6570 std::thread::sleep(std::time::Duration::from_millis(30));
6571 conn.execute("INSERT INTO t VALUES (1)", [])
6572 .map_err(|error| khive_storage::error::StorageError::Pool {
6573 operation: "metrics_writer_stage_test".into(),
6574 message: error.to_string(),
6575 })
6576 })
6577 .await
6578 .unwrap();
6579
6580 let dispatcher = MockDispatch {
6581 namespace: "local".to_string(),
6582 config_id: "cfg-writer-stages".to_string(),
6583 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6584 pool: Some(pool),
6585 dispatch_err: None,
6586 };
6587 let snapshot = build_metrics_snapshot(&dispatcher);
6588 assert!(snapshot.write_last_queue_wait_micros.is_some());
6589 assert!(snapshot.write_last_transaction_acquire_micros.is_some());
6590 assert!(snapshot.write_last_commit_micros.is_some());
6591 assert!(
6592 snapshot.write_last_body_micros >= Some(25_000),
6593 "synthetic delay must be attributed to the body: {snapshot:?}"
6594 );
6595 assert!(snapshot.write_last_total_micros >= snapshot.write_last_body_micros);
6596 assert!(snapshot.write_last_observed_at_unix_ms.is_some());
6597 }
6598
6599 #[test]
6604 #[serial(tx_registry)]
6605 fn metrics_snapshot_reflects_open_transaction_registry() {
6606 let dispatcher = MockDispatch {
6607 namespace: "local".to_string(),
6608 config_id: "cfg-tx".to_string(),
6609 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6610 pool: None,
6611 dispatch_err: None,
6612 };
6613
6614 let departing_handle = khive_storage::tx_registry::register(Some(
6615 "daemon_metrics_snapshot_departing_test_tx".to_string(),
6616 ));
6617 let before = build_metrics_snapshot(&dispatcher).open_tx_count;
6618 assert!(before >= 1);
6619
6620 let handle = khive_storage::tx_registry::register(Some(
6621 "daemon_metrics_snapshot_owned_test_tx".to_string(),
6622 ));
6623 drop(departing_handle);
6624
6625 let during = build_metrics_snapshot(&dispatcher);
6626 assert!(
6627 during.open_tx_count >= 1,
6628 "open_tx_count must reflect the live owned transaction despite registry churn: \
6629 churn_baseline={before} during={}",
6630 during.open_tx_count
6631 );
6632 assert!(
6633 during.oldest_pinned_tx_micros.is_some(),
6634 "oldest_pinned_tx_micros must be Some while a transaction is open"
6635 );
6636
6637 drop(handle);
6638 assert!(
6639 !khive_storage::tx_registry::snapshot()
6640 .iter()
6641 .any(|(_, label)| label.as_deref()
6642 == Some("daemon_metrics_snapshot_owned_test_tx")),
6643 "the owned registry entry must disappear when its handle is dropped"
6644 );
6645 }
6646
6647 #[tokio::test]
6651 async fn metrics_snapshot_write_queue_depth_flag_gated() {
6652 let dir = tempfile::tempdir().expect("tempdir");
6653
6654 let enabled_pool = Arc::new(
6655 ConnectionPool::new(khive_db::PoolConfig {
6656 path: Some(dir.path().join("wq_enabled.db")),
6657 write_queue_enabled: Some(true),
6658 ..khive_db::PoolConfig::for_test()
6659 })
6660 .expect("pool open"),
6661 );
6662 let enabled_dispatcher = MockDispatch {
6663 namespace: "local".to_string(),
6664 config_id: "cfg-wq-on".to_string(),
6665 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6666 pool: Some(enabled_pool),
6667 dispatch_err: None,
6668 };
6669 let snapshot_on = build_metrics_snapshot(&enabled_dispatcher);
6670 assert!(
6671 snapshot_on.write_queue_depth.is_some(),
6672 "write_queue_depth must be Some when write_queue_enabled=true, got {snapshot_on:?}"
6673 );
6674 assert!(snapshot_on.write_queue_capacity.is_some());
6675
6676 let disabled_pool = Arc::new(
6677 ConnectionPool::new(khive_db::PoolConfig {
6678 path: Some(dir.path().join("wq_disabled.db")),
6679 write_queue_enabled: Some(false),
6680 ..khive_db::PoolConfig::for_test()
6681 })
6682 .expect("pool open"),
6683 );
6684 let disabled_dispatcher = MockDispatch {
6685 namespace: "local".to_string(),
6686 config_id: "cfg-wq-off".to_string(),
6687 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6688 pool: Some(disabled_pool),
6689 dispatch_err: None,
6690 };
6691 let snapshot_off = build_metrics_snapshot(&disabled_dispatcher);
6692 assert!(
6693 snapshot_off.write_queue_depth.is_none(),
6694 "write_queue_depth must be None when write_queue_enabled=false, got {snapshot_off:?}"
6695 );
6696 assert!(snapshot_off.write_queue_capacity.is_none());
6697
6698 let no_pool_dispatcher = MockDispatch {
6700 namespace: "local".to_string(),
6701 config_id: "cfg-no-pool".to_string(),
6702 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6703 pool: None,
6704 dispatch_err: None,
6705 };
6706 let snapshot_no_pool = build_metrics_snapshot(&no_pool_dispatcher);
6707 assert!(snapshot_no_pool.write_queue_depth.is_none());
6708 assert!(snapshot_no_pool.write_queue_capacity.is_none());
6709 }
6710
6711 #[test]
6716 fn frame_serde_defaults_additive_fields_when_absent() {
6717 let req_json = serde_json::json!({
6718 "ops": "",
6719 "presentation": null,
6720 "presentation_per_op": null,
6721 "namespace": "local",
6722 "actor_id": null,
6723 "visible_namespaces": [],
6724 "config_id": "cfg",
6725 "protocol_version": PROTOCOL_VERSION,
6726 "probe_only": false,
6727 "format": null,
6728 "format_per_op": null,
6729 "from_wire": false
6730 });
6731 let frame: DaemonRequestFrame =
6732 serde_json::from_value(req_json).expect("decode a metrics_only-absent request frame");
6733 assert!(
6734 !frame.metrics_only,
6735 "metrics_only must default to false when absent from the wire payload"
6736 );
6737 assert_eq!(
6738 frame.request_id, None,
6739 "request_id must default to None when absent from the wire payload (khive#948)"
6740 );
6741 assert_eq!(
6742 frame.process_ref, None,
6743 "process_ref must default to None when absent from the wire payload (khive#1428)"
6744 );
6745 let encoded_frame = serde_json::to_value(&frame).expect("encode request frame");
6746 assert!(
6747 encoded_frame.get("process_ref").is_none(),
6748 "absent provenance must not change the serialized request wire shape"
6749 );
6750
6751 let resp_json = serde_json::json!({
6752 "ok": true,
6753 "result": null,
6754 "error": null,
6755 "namespace_mismatch": false,
6756 "config_mismatch": false,
6757 "served_config_id": "cfg",
6758 "version_mismatch": false,
6759 "daemon_protocol_version": PROTOCOL_VERSION
6760 });
6761 let resp: DaemonResponseFrame =
6762 serde_json::from_value(resp_json).expect("decode a metrics-absent response frame");
6763 assert!(
6764 resp.metrics.is_none(),
6765 "metrics must default to None when absent from the wire payload"
6766 );
6767 assert_eq!(
6768 resp.request_id, None,
6769 "request_id must default to None when absent from the wire payload (khive#948)"
6770 );
6771 }
6772
6773 #[tokio::test]
6778 async fn request_id_echoed_on_success_and_error_arms() {
6779 let dispatcher = MockDispatch {
6780 namespace: "local".to_string(),
6781 config_id: "cfg-a".to_string(),
6782 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6783 pool: None,
6784 dispatch_err: None,
6785 };
6786 let mut ok_req = base_request_frame("cfg-a");
6787 ok_req.request_id = Some(42);
6788 let ok_resp = round_trip(dispatcher, &ok_req).await;
6789 assert!(ok_resp.ok, "expected successful dispatch: {ok_resp:?}");
6790 assert_eq!(
6791 ok_resp.request_id,
6792 Some(42),
6793 "request_id must be echoed back on a successful dispatch response"
6794 );
6795
6796 let mismatched_dispatcher = MockDispatch {
6799 namespace: "local".to_string(),
6800 config_id: "cfg-a".to_string(),
6801 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6802 pool: None,
6803 dispatch_err: None,
6804 };
6805 let mut mismatch_req = base_request_frame("cfg-WRONG");
6806 mismatch_req.request_id = Some(99);
6807 let mismatch_resp = round_trip(mismatched_dispatcher, &mismatch_req).await;
6808 assert!(mismatch_resp.config_mismatch);
6809 assert_eq!(
6810 mismatch_resp.request_id,
6811 Some(99),
6812 "request_id must be echoed on the config_mismatch rejection arm too"
6813 );
6814
6815 let erroring_dispatcher = MockDispatch {
6818 namespace: "local".to_string(),
6819 config_id: "cfg-a".to_string(),
6820 dispatch_calls: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
6821 pool: None,
6822 dispatch_err: Some("simulated dispatch error".to_string()),
6823 };
6824 let mut err_req = base_request_frame("cfg-a");
6825 err_req.request_id = Some(7);
6826 let err_resp = round_trip(erroring_dispatcher, &err_req).await;
6827 assert!(!err_resp.ok, "expected a dispatch error: {err_resp:?}");
6828 assert_eq!(
6829 err_resp.request_id,
6830 Some(7),
6831 "request_id must be echoed on the real ops-dispatch error arm"
6832 );
6833 }
6834
6835 #[test]
6844 fn shutdown_cleanup_removes_paths_it_still_owns() {
6845 let dir = tempfile::tempdir().expect("tempdir");
6846 let sock = dir.path().join("khived.sock");
6847 let pid_file = dir.path().join("khived.pid");
6848
6849 let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
6850 std::fs::write(&pid_file, std::process::id().to_string()).expect("write pid file");
6851 let identity = socket_identity(&sock);
6852 assert!(
6853 identity.is_some(),
6854 "must read identity of a freshly bound socket"
6855 );
6856
6857 let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
6858
6859 assert!(
6860 cleaned,
6861 "cleanup must proceed when PID and socket still match"
6862 );
6863 assert!(!sock.exists(), "owned socket must be removed");
6864 assert!(!pid_file.exists(), "owned pid file must be removed");
6865 }
6866
6867 #[test]
6868 fn shutdown_cleanup_skips_when_pid_file_names_a_different_process() {
6869 let dir = tempfile::tempdir().expect("tempdir");
6870 let sock = dir.path().join("khived.sock");
6871 let pid_file = dir.path().join("khived.pid");
6872
6873 let _listener = std::os::unix::net::UnixListener::bind(&sock).expect("bind socket");
6874 let identity = socket_identity(&sock);
6875 std::fs::write(&pid_file, "1").expect("write foreign pid file");
6879
6880 let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, identity);
6881
6882 assert!(
6883 !cleaned,
6884 "cleanup must be skipped when the PID file no longer names this process"
6885 );
6886 assert!(sock.exists(), "replacement daemon's socket must survive");
6887 assert!(
6888 pid_file.exists(),
6889 "replacement daemon's pid file must survive"
6890 );
6891 }
6892
6893 #[test]
6894 fn shutdown_cleanup_skips_when_socket_was_rebound_by_a_replacement() {
6895 let dir = tempfile::tempdir().expect("tempdir");
6896 let sock = dir.path().join("khived.sock");
6897 let original_sock = dir.path().join("original.sock");
6898 let pid_file = dir.path().join("khived.pid");
6899
6900 let _original_listener =
6910 std::os::unix::net::UnixListener::bind(&original_sock).expect("bind original socket");
6911 let _replacement_listener =
6912 std::os::unix::net::UnixListener::bind(&sock).expect("bind replacement socket");
6913
6914 let original_identity = socket_identity(&original_sock);
6915 let replacement_identity = socket_identity(&sock);
6916 assert!(
6917 original_identity.is_some(),
6918 "must read identity of the original socket"
6919 );
6920 assert!(
6921 replacement_identity.is_some(),
6922 "must read identity of the replacement socket"
6923 );
6924 assert!(
6925 original_identity != replacement_identity,
6926 "two concurrently bound sockets must have distinct identities"
6927 );
6928
6929 std::fs::write(&pid_file, std::process::id().to_string())
6930 .expect("write pid file matching this process");
6931
6932 let cleaned = shutdown_cleanup_if_owned(&sock, &pid_file, original_identity);
6937
6938 assert!(
6939 !cleaned,
6940 "cleanup must be skipped when the socket at this path is a different \
6941 inode than the one this daemon originally bound"
6942 );
6943 assert!(sock.exists(), "replacement daemon's socket must survive");
6944 assert!(
6945 pid_file.exists(),
6946 "replacement daemon's pid file must survive"
6947 );
6948 }
6949
6950 #[test]
6951 fn shutdown_cleanup_preserves_atomically_renamed_successor() {
6952 let dir = tempfile::tempdir().expect("tempdir");
6953 let sock = dir.path().join("khived.sock");
6954 let staged_sock = dir.path().join("next.sock");
6955 let pid_file = dir.path().join("khived.pid");
6956 let _original_listener =
6957 std::os::unix::net::UnixListener::bind(&sock).expect("bind original socket");
6958 let successor =
6959 std::os::unix::net::UnixListener::bind(&staged_sock).expect("bind staged successor");
6960 let original_identity = socket_identity(&sock).expect("original socket identity");
6961 let successor_identity = socket_identity(&staged_sock).expect("successor socket identity");
6962 assert!(original_identity != successor_identity);
6963 let original_pid = std::process::id().to_string();
6964 std::fs::write(&pid_file, &original_pid).expect("write original PID");
6965
6966 std::fs::rename(&staged_sock, &sock).expect("publish successor over original socket");
6967 assert!(!staged_sock.exists());
6968 assert!(socket_identity(&sock) == Some(successor_identity));
6969 assert!(!shutdown_cleanup_if_owned(
6971 &sock,
6972 &pid_file,
6973 Some(original_identity)
6974 ));
6975 assert!(socket_identity(&sock) == Some(successor_identity));
6976 assert_eq!(
6977 std::fs::read_to_string(&pid_file).expect("PID must survive stale cleanup"),
6978 original_pid
6979 );
6980 successor
6981 .set_nonblocking(true)
6982 .expect("bound successor must support nonblocking accept");
6983 let _client = std::os::unix::net::UnixStream::connect(&sock)
6984 .expect("published successor must remain reachable");
6985 let _accepted = successor
6986 .accept()
6987 .expect("successor must receive connection");
6988 }
6989
6990 #[test]
6991 fn isolated_daemon_locks_use_private_fixture_paths() {
6992 if crate::test_process::run_in_child() {
6993 return;
6994 }
6995 let home = PathBuf::from(std::env::var_os("HOME").expect("child HOME"));
6996 for path in [lock_path(), recoverer_lock_path()] {
6997 assert_eq!(
6998 path.parent(),
6999 home.parent(),
7000 "runtime daemon locks must use private fixture paths outside HOME"
7001 );
7002 }
7003 let _boot = acquire_daemon_boot_guard().expect("private boot lock");
7004 let _recoverer = try_acquire_recoverer_lock_until(
7005 std::time::Instant::now() + std::time::Duration::from_secs(1),
7006 )
7007 .expect("private recoverer lock")
7008 .expect("private recoverer lock must be available");
7009 assert!(lock_path().is_file());
7010 assert!(recoverer_lock_path().is_file());
7011 assert!(
7012 std::fs::read_dir(home).unwrap().next().is_none(),
7013 "both daemon lock producers must leave the child HOME empty"
7014 );
7015 }
7016
7017 include!("daemon/store_guard_tests.rs");
7018
7019 #[test]
7031 #[serial]
7032 fn recovery_lock_serializes_two_concurrent_boot_sequences() {
7033 if crate::test_process::run_in_child() {
7034 return;
7035 }
7036
7037 let dir = tempfile::tempdir().expect("tempdir");
7038 let lock_file = dir.path().join("khived.recovery.lock");
7039 std::env::set_var("KHIVE_LOCK", &lock_file);
7040
7041 let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
7042 let overlap_detected = Arc::new(std::sync::atomic::AtomicBool::new(false));
7043
7044 let run_one_boot =
7045 |active: Arc<std::sync::atomic::AtomicUsize>,
7046 overlap: Arc<std::sync::atomic::AtomicBool>| {
7047 move || {
7048 let _guard = acquire_recovery_lock().expect("acquire recovery lock");
7049 if active.fetch_add(1, std::sync::atomic::Ordering::SeqCst) != 0 {
7051 overlap.store(true, std::sync::atomic::Ordering::SeqCst);
7052 }
7053 std::thread::sleep(std::time::Duration::from_millis(50));
7054 active.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
7055 }
7057 };
7058
7059 let t1 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
7060 let t2 = std::thread::spawn(run_one_boot(active.clone(), overlap_detected.clone()));
7061 t1.join().expect("boot thread 1 must not panic");
7062 t2.join().expect("boot thread 2 must not panic");
7063
7064 assert!(
7065 !overlap_detected.load(std::sync::atomic::Ordering::SeqCst),
7066 "two concurrent boot sequences must never hold the schema-init \
7067 critical section at the same time (#667)"
7068 );
7069
7070 std::env::remove_var("KHIVE_LOCK");
7071 }
7072
7073 #[test]
7078 #[serial]
7079 fn acquire_daemon_boot_guard_returns_guard_when_lock_available() {
7080 if crate::test_process::run_in_child() {
7081 return;
7082 }
7083
7084 let dir = tempfile::tempdir().expect("tempdir");
7085 let lock_file = dir.path().join("khived.recovery.lock");
7086 std::env::set_var("KHIVE_LOCK", &lock_file);
7087
7088 let guard = acquire_daemon_boot_guard();
7089 assert!(
7090 guard.is_ok(),
7091 "daemon boot guard must succeed when the lock file can be opened and flocked"
7092 );
7093 drop(guard);
7094
7095 std::env::remove_var("KHIVE_LOCK");
7096 }
7097
7098 #[test]
7099 #[serial]
7100 fn acquire_daemon_boot_guard_fails_loudly_when_lock_file_cannot_be_opened() {
7101 if crate::test_process::run_in_child() {
7102 return;
7103 }
7104
7105 let dir = tempfile::tempdir().expect("tempdir");
7106 std::env::set_var("KHIVE_LOCK", dir.path());
7112
7113 let result = acquire_daemon_boot_guard();
7114 assert!(
7115 result.is_err(),
7116 "daemon boot guard must fail loudly, never silently proceed unguarded, \
7117 when the underlying recovery lock cannot be acquired"
7118 );
7119
7120 std::env::remove_var("KHIVE_LOCK");
7121 }
7122
7123 #[test]
7126 fn write_pid_file_exclusive_creates_new_file_with_own_pid() {
7127 let dir = tempfile::tempdir().expect("tempdir");
7128 let pid_file = dir.path().join("khived.pid");
7129 write_pid_file_exclusive(&pid_file).expect("first writer must win");
7130 let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
7131 assert_eq!(contents, std::process::id().to_string());
7132 }
7133
7134 #[test]
7135 fn write_pid_file_exclusive_refuses_to_overwrite_an_existing_file() {
7136 let dir = tempfile::tempdir().expect("tempdir");
7137 let pid_file = dir.path().join("khived.pid");
7138 std::fs::write(&pid_file, "999999").expect("seed an existing pid file");
7139
7140 let err = write_pid_file_exclusive(&pid_file)
7141 .expect_err("must not silently overwrite an existing pid file");
7142 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
7143
7144 let contents = std::fs::read_to_string(&pid_file).expect("read pid file");
7147 assert_eq!(
7148 contents, "999999",
7149 "an existing pid file must never be truncated by a losing writer"
7150 );
7151 }
7152
7153 #[test]
7162 fn two_concurrent_writers_converge_on_exactly_one_pid_file_owner() {
7163 let dir = tempfile::tempdir().expect("tempdir");
7164 let pid_file = std::sync::Arc::new(dir.path().join("khived.pid"));
7165 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
7166
7167 let spawn_writer =
7168 |pid_file: std::sync::Arc<std::path::PathBuf>,
7169 barrier: std::sync::Arc<std::sync::Barrier>| {
7170 std::thread::spawn(move || {
7171 barrier.wait();
7172 write_pid_file_exclusive(&pid_file)
7173 })
7174 };
7175
7176 let t1 = spawn_writer(pid_file.clone(), barrier.clone());
7177 let t2 = spawn_writer(pid_file.clone(), barrier.clone());
7178 let r1 = t1.join().expect("writer 1 must not panic");
7179 let r2 = t2.join().expect("writer 2 must not panic");
7180
7181 let results = [&r1, &r2];
7182 let ok_count = results.iter().filter(|r| r.is_ok()).count();
7183 let already_exists_count = results
7184 .iter()
7185 .filter(|r| matches!(r, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists))
7186 .count();
7187 assert_eq!(
7188 ok_count, 1,
7189 "exactly one of two concurrent writers must win the pid file"
7190 );
7191 assert_eq!(
7192 already_exists_count, 1,
7193 "the other writer must observe AlreadyExists, never a silent overwrite"
7194 );
7195 assert!(pid_file.exists(), "the winner's pid file must exist");
7196 let contents = std::fs::read_to_string(&*pid_file).expect("read pid file");
7197 assert_eq!(
7198 contents,
7199 std::process::id().to_string(),
7200 "the surviving pid file must contain the winner's pid — both threads \
7201 share this process's pid, so an unexpected value would also prove a \
7202 lost/garbled write raced through"
7203 );
7204 }
7205
7206 #[tokio::test]
7217 async fn peer_uid_reports_the_connecting_process_uid() {
7218 let dir = tempfile::tempdir().expect("tempdir");
7219 let sock = dir.path().join("peer.sock");
7220 let listener = UnixListener::bind(&sock).expect("bind");
7221
7222 let connect_path = sock.clone();
7223 let client = tokio::spawn(async move { UnixStream::connect(&connect_path).await });
7224
7225 let (server_side, _) = listener.accept().await.expect("accept");
7226 let client_side = client.await.expect("join").expect("connect");
7227
7228 let expected = unsafe { libc::geteuid() } as u32;
7230
7231 assert_eq!(
7232 peer_uid(&server_side).expect("peer_uid must succeed on a live connection"),
7233 expected,
7234 "the uid read from the kernel for a same-process connection must be \
7235 this process's euid"
7236 );
7237 assert_eq!(
7239 peer_uid(&client_side).expect("peer_uid must succeed on the client end"),
7240 expected
7241 );
7242 }
7243
7244 #[test]
7254 fn only_a_foreign_uid_is_refused() {
7255 let euid = unsafe { libc::geteuid() } as u32;
7257
7258 assert!(
7259 uid_is_permitted(euid, euid),
7260 "a connection from the daemon's own uid must be served — this is \
7261 every seat on the host, and ADR-096 accepted exactly this shape"
7262 );
7263 assert!(
7264 !uid_is_permitted(euid.wrapping_add(1), euid),
7265 "a connection from any other uid must be refused"
7266 );
7267 assert!(
7268 !uid_is_permitted(0, euid.wrapping_add(1)),
7269 "root is not special-cased: the rule is equality with the daemon's \
7270 euid, not a privilege comparison"
7271 );
7272 }
7273
7274 struct CapturedFields(Arc<std::sync::Mutex<Vec<String>>>);
7278
7279 impl tracing::Subscriber for CapturedFields {
7280 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
7281 true
7282 }
7283 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
7284 tracing::span::Id::from_u64(1)
7285 }
7286 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
7287 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
7288 fn event(&self, event: &tracing::Event<'_>) {
7289 struct Visitor(String);
7290 impl tracing::field::Visit for Visitor {
7291 fn record_debug(
7292 &mut self,
7293 field: &tracing::field::Field,
7294 value: &dyn std::fmt::Debug,
7295 ) {
7296 self.0.push_str(&format!("{}={:?} ", field.name(), value));
7297 }
7298 }
7299 let mut visitor = Visitor(String::new());
7300 event.record(&mut visitor);
7301 self.0.lock().unwrap().push(visitor.0);
7302 }
7303 fn enter(&self, _: &tracing::span::Id) {}
7304 fn exit(&self, _: &tracing::span::Id) {}
7305 }
7306
7307 #[cfg(unix)]
7310 #[tokio::test]
7311 #[serial(background_tasks)]
7312 async fn drain_timeout_warning_names_the_outstanding_tasks() {
7313 let lines = Arc::new(std::sync::Mutex::new(Vec::new()));
7314 let subscriber = CapturedFields(lines.clone());
7315 let _dispatch = tracing::dispatcher::set_default(&tracing::Dispatch::new(subscriber));
7316
7317 let active = std::sync::atomic::AtomicUsize::new(0);
7318 let (stop_tx, stop_rx) = tokio::sync::broadcast::channel::<()>(1);
7319 for name in ["test_task_alpha", "test_task_beta"] {
7320 let mut rx = stop_rx.resubscribe();
7321 track_named_background_task(name, async move {
7322 let _ = rx.recv().await;
7323 });
7324 }
7325 drop(stop_rx);
7326
7327 let drained = drain_with_timeout(&active, std::time::Duration::from_millis(150)).await;
7328 assert!(
7329 !drained,
7330 "two unfinished tasks must make the drain time out"
7331 );
7332
7333 let warned = lines
7334 .lock()
7335 .unwrap()
7336 .iter()
7337 .find(|line| line.contains("drain timeout reached"))
7338 .cloned()
7339 .expect("the drain timeout must emit its warning through the test subscriber");
7340 assert!(
7341 warned.contains("test_task_alpha") && warned.contains("test_task_beta"),
7342 "the drain-timeout warning must name every outstanding task; got {warned}"
7343 );
7344
7345 let _ = stop_tx.send(());
7346 for _ in 0..100 {
7347 if background_task_names().is_empty() {
7348 break;
7349 }
7350 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7351 }
7352 }
7353
7354 #[tokio::test]
7357 #[serial(background_tasks)]
7358 async fn a_named_task_drops_its_name_when_it_finishes() {
7359 let before = background_task_count();
7360 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
7361 track_named_background_task("test_task_finishes", async move {
7362 let _ = rx.await;
7363 });
7364 assert!(
7365 background_task_names().contains(&"test_task_finishes".to_string()),
7366 "a live named task must be listed while the counter holds it"
7367 );
7368 tx.send(()).expect("still awaiting");
7369 for _ in 0..100 {
7370 if background_task_count() == before {
7371 break;
7372 }
7373 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7374 }
7375 assert_eq!(background_task_count(), before);
7376 assert!(
7377 !background_task_names().contains(&"test_task_finishes".to_string()),
7378 "a finished task's name must be released, not left to accumulate"
7379 );
7380 }
7381
7382 #[tokio::test]
7385 #[serial(background_tasks)]
7386 async fn the_unnamed_entry_point_still_registers_and_releases() {
7387 let before = background_task_count();
7388 let (tx, rx) = tokio::sync::oneshot::channel::<()>();
7389 track_background_task(async move {
7390 let _ = rx.await;
7391 });
7392 assert_eq!(background_task_count(), before + 1);
7393 assert!(
7394 background_task_names().contains(&UNNAMED_BACKGROUND_TASK.to_string()),
7395 "the unchanged public entry point must still register, under the placeholder name"
7396 );
7397 tx.send(()).expect("still awaiting");
7398 for _ in 0..100 {
7399 if background_task_count() == before {
7400 break;
7401 }
7402 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
7403 }
7404 assert_eq!(background_task_count(), before);
7405 }
7406
7407 include!("daemon_config_id_tests.rs");
7408}