Skip to main content

khive_runtime/
daemon.rs

1//! khived daemon server — persistent warm runtime over a Unix socket.
2//!
3//! The daemon binds `~/.khive/khived.sock`, accepts length-prefixed request
4//! frames, dispatches them through a [`DaemonDispatch`] implementor, and serves
5//! results back. It is transport-agnostic: the MCP crate provides the dispatch
6//! impl, but any future client (CLI, HTTP gateway) can reuse this server.
7//!
8//! The client side (forwarding, auto-spawn) lives in the transport crate
9//! (e.g. `khive-mcp`), not here.
10
11use std::io::Write as _;
12use std::path::PathBuf;
13use std::sync::Arc;
14
15#[cfg(unix)]
16use std::os::unix::fs::PermissionsExt;
17#[cfg(unix)]
18use std::os::unix::io::AsRawFd;
19
20use async_trait::async_trait;
21#[cfg(unix)]
22use libc;
23use serde::{Deserialize, Serialize};
24use tokio::io::{AsyncReadExt, AsyncWriteExt};
25use tokio::net::{UnixListener, UnixStream};
26
27use khive_db::{run_checkpoint_task, CheckpointConfig, ConnectionPool};
28
29/// Maximum frame size accepted in either direction.
30pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
31
32/// Wire protocol version for the daemon IPC framing.
33///
34/// Increment this constant whenever the request or response frame shape
35/// changes in a backward-incompatible way. The client sends its version
36/// in every request; the daemon rejects mismatches with an explicit error
37/// that names both sides so the operator knows exactly what to do
38/// (`make local` rebuilds the client binary).
39///
40/// Version history:
41///   1 — initial versioned framing (added `protocol_version` + `version_mismatch`);
42///       added `probe_only` request field + probe-ack sentinel shape in response
43pub const PROTOCOL_VERSION: u32 = 2;
44
45const DEFAULT_DRAIN_TIMEOUT_SECS: u64 = 10;
46
47// ── paths ─────────────────────────────────────────────────────────────────────
48
49fn khive_dir() -> PathBuf {
50    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
51    PathBuf::from(home).join(".khive")
52}
53
54/// Unix socket path the daemon binds and clients connect to.
55///
56/// Overridable via the `KHIVE_SOCKET` env var (for tests and ops).
57pub fn socket_path() -> PathBuf {
58    if let Ok(p) = std::env::var("KHIVE_SOCKET") {
59        if !p.is_empty() {
60            return PathBuf::from(p);
61        }
62    }
63    khive_dir().join("khived.sock")
64}
65
66/// PID file path written by the daemon.
67///
68/// Overridable via the `KHIVE_PID` env var.
69pub fn pid_path() -> PathBuf {
70    if let Ok(p) = std::env::var("KHIVE_PID") {
71        if !p.is_empty() {
72            return PathBuf::from(p);
73        }
74    }
75    khive_dir().join("khived.pid")
76}
77
78/// Advisory lock file used to serialize stale-daemon recovery across concurrent
79/// clients (flock on the file; released when the lock file handle is dropped).
80///
81/// Overridable via the `KHIVE_LOCK` env var (for tests).
82pub fn lock_path() -> PathBuf {
83    if let Ok(p) = std::env::var("KHIVE_LOCK") {
84        if !p.is_empty() {
85            return PathBuf::from(p);
86        }
87    }
88    khive_dir().join("khived.recovery.lock")
89}
90
91/// Acquire an exclusive advisory flock on the recovery/startup lock file.
92///
93/// The returned `File` holds the lock for its lifetime; dropping it releases
94/// it.  Used by both the client (serializing kill+spawn) and the daemon server
95/// (serializing cleanup+bind+pid-write) so the two critical sections are
96/// mutually exclusive across processes.
97#[cfg(unix)]
98pub fn acquire_recovery_lock() -> Option<std::fs::File> {
99    let path = lock_path();
100    if let Some(parent) = path.parent() {
101        let _ = std::fs::create_dir_all(parent);
102    }
103    let file = match std::fs::OpenOptions::new()
104        .create(true)
105        .truncate(false)
106        .write(true)
107        .open(&path)
108    {
109        Ok(f) => f,
110        Err(e) => {
111            tracing::warn!(error = %e, path = ?path, "cannot open recovery lock file");
112            return None;
113        }
114    };
115    // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
116    let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
117    if rc != 0 {
118        tracing::warn!("flock LOCK_EX failed on recovery lock");
119        return None;
120    }
121    Some(file)
122}
123
124// ── wire types ────────────────────────────────────────────────────────────────
125
126/// Request frame sent from a client to the daemon.
127#[derive(Serialize, Deserialize, Default)]
128pub struct DaemonRequestFrame {
129    pub ops: String,
130    pub presentation: Option<String>,
131    pub presentation_per_op: Option<Vec<Option<String>>>,
132    pub namespace: String,
133    /// Fingerprint of the client's resolved runtime config (packs, db target,
134    /// embedders). The daemon rejects a request whose `config_id` differs from
135    /// its own so a restricted client (e.g. `--pack kg`, `--db :memory:`) never
136    /// dispatches through the broader default daemon. See ADR-027 / ADR-049.
137    #[serde(default)]
138    pub config_id: String,
139    /// IPC protocol version sent by the client. Pre-versioning clients omit
140    /// this field (deserializes to 0). The daemon compares against
141    /// [`PROTOCOL_VERSION`] and rejects mismatches with an explicit error.
142    #[serde(default)]
143    pub protocol_version: u32,
144    /// When `true`, the daemon returns an identity frame (ok=true, result=None)
145    /// immediately after identity validation — without calling the dispatcher.
146    /// Used by the client's under-lock recovery probe to confirm a daemon is
147    /// alive and identity-matching without dispatching any mutating verb.
148    /// Pre-probe clients omit this field (deserializes to false → normal dispatch).
149    #[serde(default)]
150    pub probe_only: bool,
151    /// Output format for this request (ADR-078). Forwarded to the daemon's
152    /// serialization seam. `None` means use the daemon's resolved default.
153    #[serde(default)]
154    pub format: Option<String>,
155    /// Per-operation output format overrides (ADR-078).
156    #[serde(default)]
157    pub format_per_op: Option<Vec<Option<String>>>,
158    /// Whether this request originated from the agent-facing MCP `request`
159    /// tool (the wire surface). When `true`, the daemon enforces verb
160    /// visibility — `Visibility::Subhandler` verbs are rejected because agents
161    /// must not invoke internal subhandlers. When `false` (the default, and the
162    /// only value any operator path sends), subhandlers are allowed: `kkernel
163    /// exec` and other in-process callers are trusted operator surfaces.
164    ///
165    /// This is the origin discriminator, not a daemon-vs-local one: operator
166    /// requests flow through the daemon by default too, so the gate cannot key
167    /// on transport. Pre-versioning clients omit this field (deserializes to
168    /// `false` → ungated), which is safe: the only way to reach the gated wire
169    /// surface is the MCP `request` tool, which always sets it explicitly.
170    #[serde(default)]
171    pub from_wire: bool,
172}
173
174/// Response frame sent from the daemon back to a client.
175#[derive(Serialize, Deserialize)]
176pub struct DaemonResponseFrame {
177    pub ok: bool,
178    pub result: Option<String>,
179    pub error: Option<String>,
180    pub namespace_mismatch: bool,
181    /// Set when the request's `config_id` does not match the daemon's. Like
182    /// `namespace_mismatch`, this signals the client to fall back to local
183    /// dispatch rather than execute under a different runtime/config.
184    #[serde(default)]
185    pub config_mismatch: bool,
186    /// The `config_id` the daemon dispatched under, echoed back so the client
187    /// can positively confirm the result came from a matching runtime. A
188    /// pre-`config_id` daemon omits this field (deserializes to `None`), which
189    /// the client treats as a mismatch and falls back to local dispatch — this
190    /// closes the upgrade window where a new restricted client could otherwise
191    /// trust a still-warm legacy daemon's broader registry.
192    #[serde(default)]
193    pub served_config_id: Option<String>,
194    /// Set when the client's `protocol_version` does not match the daemon's
195    /// [`PROTOCOL_VERSION`]. The client must treat this as a hard error and
196    /// surface the human-readable `error` field rather than falling back to
197    /// local dispatch (which would hide the version skew).
198    #[serde(default)]
199    pub version_mismatch: bool,
200    /// The daemon's [`PROTOCOL_VERSION`], echoed in error responses so the
201    /// client can include both sides in the diagnostic message. Pre-versioning
202    /// daemons omit this field (deserializes to 0).
203    #[serde(default)]
204    pub daemon_protocol_version: u32,
205}
206
207// ── framing ───────────────────────────────────────────────────────────────────
208
209/// Read one length-prefixed frame (4-byte BE u32 length + JSON bytes).
210pub async fn read_frame(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
211    let mut len_buf = [0u8; 4];
212    stream.read_exact(&mut len_buf).await?;
213    let len = u32::from_be_bytes(len_buf) as usize;
214    if len > MAX_FRAME_BYTES {
215        return Err(std::io::Error::new(
216            std::io::ErrorKind::InvalidData,
217            format!("daemon frame of {len} bytes exceeds {MAX_FRAME_BYTES} cap"),
218        ));
219    }
220    let mut buf = vec![0u8; len];
221    stream.read_exact(&mut buf).await?;
222    Ok(buf)
223}
224
225/// Write one length-prefixed frame.
226pub async fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> {
227    if payload.len() > MAX_FRAME_BYTES {
228        return Err(std::io::Error::new(
229            std::io::ErrorKind::InvalidData,
230            format!(
231                "daemon frame of {} bytes exceeds {MAX_FRAME_BYTES} cap",
232                payload.len()
233            ),
234        ));
235    }
236    let len = (payload.len() as u32).to_be_bytes();
237    stream.write_all(&len).await?;
238    stream.write_all(payload).await?;
239    stream.flush().await?;
240    Ok(())
241}
242
243// ── dispatch trait ────────────────────────────────────────────────────────────
244
245/// Transport-agnostic dispatch interface for the daemon server.
246///
247/// The MCP crate implements this by dispatching through the shared request body
248/// while honoring [`DaemonRequestFrame::from_wire`] (so subhandler visibility is
249/// gated by request origin, not by transport); any future transport can do the
250/// same.
251#[async_trait]
252pub trait DaemonDispatch: Clone + Send + Sync + 'static {
253    /// Dispatch a verb-DSL request string and return the rendered result.
254    ///
255    /// `from_wire` carries the origin discriminator from
256    /// [`DaemonRequestFrame::from_wire`]: when `true`, the implementor enforces
257    /// verb visibility (rejects `Visibility::Subhandler` verbs); when `false`,
258    /// the request is from a trusted operator surface and subhandlers pass.
259    async fn dispatch(
260        &self,
261        ops: String,
262        presentation: Option<String>,
263        presentation_per_op: Option<Vec<Option<String>>>,
264        format: Option<String>,
265        format_per_op: Option<Vec<Option<String>>>,
266        from_wire: bool,
267    ) -> Result<String, String>;
268
269    /// Warm every pack's in-memory state (ANN indexes, etc.).
270    async fn warm_all(&self);
271
272    /// The namespace this dispatcher was configured for.
273    fn namespace(&self) -> &str;
274
275    /// Fingerprint of this dispatcher's resolved runtime config (packs, db
276    /// target, embedders). Used to reject forwarded requests from clients whose
277    /// config differs, so a restricted client cannot dispatch through a broader
278    /// daemon.
279    fn config_id(&self) -> &str;
280
281    /// Return the pool to use for background WAL checkpointing, if available.
282    ///
283    /// Implementors backed by a file-based SQLite database should return
284    /// `Some(pool_arc)`. In-memory or test dispatchers that have no pool
285    /// return `None` and the checkpoint task is not spawned.
286    ///
287    /// The default implementation returns `None`.
288    fn pool_for_checkpoint(&self) -> Option<Arc<ConnectionPool>> {
289        None
290    }
291}
292
293// ── server ────────────────────────────────────────────────────────────────────
294
295async fn handle_conn<D: DaemonDispatch>(mut stream: UnixStream, dispatcher: D) {
296    let raw = match read_frame(&mut stream).await {
297        Ok(r) => r,
298        Err(e) => {
299            tracing::debug!(error = %e, "failed to read daemon request frame");
300            return;
301        }
302    };
303    let frame: DaemonRequestFrame = match serde_json::from_slice(&raw) {
304        Ok(f) => f,
305        Err(e) => {
306            tracing::debug!(error = %e, "failed to decode daemon request frame");
307            return;
308        }
309    };
310
311    let served_config_id = Some(dispatcher.config_id().to_string());
312    let resp = if frame.protocol_version != PROTOCOL_VERSION {
313        let msg = format!(
314            "daemon protocol mismatch: client={} daemon={} — \
315             rebuild/update the client binary (make local)",
316            frame.protocol_version, PROTOCOL_VERSION,
317        );
318        tracing::warn!(
319            client_version = frame.protocol_version,
320            daemon_version = PROTOCOL_VERSION,
321            "daemon protocol version mismatch"
322        );
323        DaemonResponseFrame {
324            ok: false,
325            result: None,
326            error: Some(msg),
327            namespace_mismatch: false,
328            config_mismatch: false,
329            served_config_id,
330            version_mismatch: true,
331            daemon_protocol_version: PROTOCOL_VERSION,
332        }
333    } else if frame.namespace != dispatcher.namespace() {
334        DaemonResponseFrame {
335            ok: false,
336            result: None,
337            error: None,
338            namespace_mismatch: true,
339            config_mismatch: false,
340            served_config_id,
341            version_mismatch: false,
342            daemon_protocol_version: PROTOCOL_VERSION,
343        }
344    } else if frame.config_id != dispatcher.config_id() {
345        DaemonResponseFrame {
346            ok: false,
347            result: None,
348            error: None,
349            namespace_mismatch: false,
350            config_mismatch: true,
351            served_config_id,
352            version_mismatch: false,
353            daemon_protocol_version: PROTOCOL_VERSION,
354        }
355    } else if frame.probe_only {
356        // Probe-only request: identity checks passed; return immediately without
357        // dispatching any verb. The client uses this to confirm the daemon is
358        // alive and identity-matching without triggering any mutation.
359        DaemonResponseFrame {
360            ok: true,
361            result: None,
362            error: None,
363            namespace_mismatch: false,
364            config_mismatch: false,
365            served_config_id,
366            version_mismatch: false,
367            daemon_protocol_version: PROTOCOL_VERSION,
368        }
369    } else {
370        match dispatcher
371            .dispatch(
372                frame.ops,
373                frame.presentation,
374                frame.presentation_per_op,
375                frame.format,
376                frame.format_per_op,
377                frame.from_wire,
378            )
379            .await
380        {
381            Ok(result) => DaemonResponseFrame {
382                ok: true,
383                result: Some(result),
384                error: None,
385                namespace_mismatch: false,
386                config_mismatch: false,
387                served_config_id,
388                version_mismatch: false,
389                daemon_protocol_version: PROTOCOL_VERSION,
390            },
391            Err(e) => DaemonResponseFrame {
392                ok: false,
393                result: None,
394                error: Some(e),
395                namespace_mismatch: false,
396                config_mismatch: false,
397                served_config_id,
398                version_mismatch: false,
399                daemon_protocol_version: PROTOCOL_VERSION,
400            },
401        }
402    };
403
404    match serde_json::to_vec(&resp) {
405        Ok(payload) => {
406            if payload.len() > MAX_FRAME_BYTES {
407                // The serialized response exceeds the IPC frame cap.  Send a
408                // small explicit error frame so the client can distinguish a
409                // per-request payload-size failure from a daemon crash.  A
410                // client that receives this error frame will NOT trigger
411                // stale-daemon kill/respawn (ParseFailure requires a read_frame
412                // error, not an ok=false result).
413                tracing::warn!(
414                    bytes = payload.len(),
415                    limit = MAX_FRAME_BYTES,
416                    "daemon response exceeds MAX_FRAME_BYTES; sending explicit error frame"
417                );
418                let err_resp = DaemonResponseFrame {
419                    ok: false,
420                    result: None,
421                    error: Some(format!(
422                        "response too large: {} bytes exceeds {} byte IPC cap",
423                        payload.len(),
424                        MAX_FRAME_BYTES,
425                    )),
426                    namespace_mismatch: false,
427                    config_mismatch: false,
428                    served_config_id: resp.served_config_id,
429                    version_mismatch: false,
430                    daemon_protocol_version: PROTOCOL_VERSION,
431                };
432                if let Ok(err_payload) = serde_json::to_vec(&err_resp) {
433                    if let Err(e) = write_frame(&mut stream, &err_payload).await {
434                        tracing::debug!(error = %e, "failed to write oversized-response error frame");
435                    }
436                }
437            } else if let Err(e) = write_frame(&mut stream, &payload).await {
438                tracing::debug!(error = %e, "failed to write daemon response frame");
439            }
440        }
441        Err(e) => tracing::warn!(error = %e, "failed to serialize daemon response frame"),
442    }
443}
444
445/// Run the daemon: bind the socket, warm in the background, serve request
446/// frames until SIGTERM/SIGINT.
447pub async fn run_daemon<D: DaemonDispatch>(dispatcher: D) -> anyhow::Result<()> {
448    let sock = socket_path();
449    let pid_file = pid_path();
450
451    if let Some(parent) = sock.parent() {
452        std::fs::create_dir_all(parent)?;
453        #[cfg(unix)]
454        if let Err(e) = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)) {
455            tracing::warn!(error = %e, path = ?parent, "failed to chmod 0700 khive dir");
456        }
457    }
458
459    // Hold the startup lock across cleanup → bind → pid-write so a concurrent
460    // client's kill_and_respawn (which also holds this lock) cannot unlink the
461    // socket between our bind and our pid-write.  The lock is released once the
462    // listener is bound and the PID file is written — at that point any racing
463    // client will find a live socket+pid and skip the stale-cleanup path.
464    //
465    // Deadlock note: the client holds this lock only during kill+spawn and
466    // releases it before the spawned daemon process starts (the lock guard is
467    // dropped when kill_and_respawn returns, before the readiness probe loop).
468    // The daemon then acquires the lock here without any client holding it.
469    #[cfg(unix)]
470    let _startup_lock = acquire_recovery_lock();
471
472    if !cleanup_stale_daemon(&sock, &pid_file).await {
473        tracing::info!("a responsive khived is already running; exiting");
474        return Ok(());
475    }
476
477    let listener = UnixListener::bind(&sock)?;
478    #[cfg(unix)]
479    if let Err(e) = std::fs::set_permissions(&sock, std::fs::Permissions::from_mode(0o600)) {
480        tracing::warn!(error = %e, path = ?sock, "failed to chmod 0600 socket");
481    }
482
483    write_pid_file(&pid_file)?;
484    // Release the startup lock now: the listener is bound and the PID file is
485    // written.  Any concurrent client or daemon startup will observe a live
486    // socket+pid and take the non-recovery path.
487    #[cfg(unix)]
488    drop(_startup_lock);
489    tracing::info!(socket = ?sock, pid = std::process::id(), "khived listening");
490
491    {
492        let warm = dispatcher.clone();
493        tokio::spawn(async move {
494            warm.warm_all().await;
495        });
496    }
497
498    if let Some(pool) = dispatcher.pool_for_checkpoint() {
499        let cfg = CheckpointConfig::from_env();
500        tokio::spawn(run_checkpoint_task(pool, cfg));
501        tracing::info!("WAL checkpoint task started");
502    }
503
504    let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
505
506    let shutdown = async {
507        // REASON: signal handler registration can only fail if the global Tokio runtime
508        // is not running or the OS rejects the signal number — both are unrecoverable
509        // at this point in startup, so panic is the correct response.
510        let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
511            .expect("install SIGTERM handler");
512        let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
513            .expect("install SIGINT handler");
514        tokio::select! {
515            _ = sigterm.recv() => tracing::info!("received SIGTERM"),
516            _ = sigint.recv() => tracing::info!("received SIGINT"),
517        }
518    };
519
520    tokio::select! {
521        _ = async {
522            loop {
523                match listener.accept().await {
524                    Ok((stream, _)) => {
525                        let d = dispatcher.clone();
526                        let active = Arc::clone(&active);
527                        tokio::spawn(async move {
528                            active.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
529                            handle_conn(stream, d).await;
530                            active.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
531                        });
532                    }
533                    Err(e) => tracing::error!(error = %e, "accept failed"),
534                }
535            }
536        } => {}
537        _ = shutdown => {}
538    }
539
540    drain(&active).await;
541
542    let _ = std::fs::remove_file(&sock);
543    let _ = std::fs::remove_file(&pid_file);
544    tracing::info!("khived stopped");
545    Ok(())
546}
547
548// ── helpers ───────────────────────────────────────────────────────────────────
549
550fn is_process_running(pid: u32) -> bool {
551    let Ok(pid) = i32::try_from(pid) else {
552        return false;
553    };
554    if pid <= 0 {
555        return false;
556    }
557    // SAFETY: signal 0 is an existence/permission probe with no side effects.
558    unsafe { libc::kill(pid, 0) == 0 }
559}
560
561async fn cleanup_stale_daemon(sock: &std::path::Path, pid_file: &std::path::Path) -> bool {
562    if let Ok(pid_str) = std::fs::read_to_string(pid_file) {
563        if let Ok(pid) = pid_str.trim().parse::<u32>() {
564            if pid != std::process::id()
565                && is_process_running(pid)
566                && sock.exists()
567                && UnixStream::connect(sock).await.is_ok()
568            {
569                return false;
570            }
571        }
572    }
573    if sock.exists() {
574        if let Err(e) = std::fs::remove_file(sock) {
575            tracing::warn!(error = %e, path = ?sock, "failed to remove stale socket");
576        }
577    }
578    if pid_file.exists() {
579        if let Err(e) = std::fs::remove_file(pid_file) {
580            tracing::warn!(error = %e, path = ?pid_file, "failed to remove stale PID file");
581        }
582    }
583    true
584}
585
586fn write_pid_file(pid_file: &std::path::Path) -> std::io::Result<()> {
587    let mut opts = std::fs::OpenOptions::new();
588    opts.write(true).create(true).truncate(true);
589    #[cfg(unix)]
590    {
591        use std::os::unix::fs::OpenOptionsExt;
592        opts.mode(0o600);
593    }
594    let mut f = opts.open(pid_file)?;
595    f.write_all(std::process::id().to_string().as_bytes())?;
596    Ok(())
597}
598
599async fn drain(active: &std::sync::atomic::AtomicUsize) {
600    use std::sync::atomic::Ordering;
601    if active.load(Ordering::Relaxed) == 0 {
602        return;
603    }
604    let deadline = tokio::time::Instant::now() + drain_timeout();
605    while active.load(Ordering::Relaxed) > 0 {
606        if tokio::time::Instant::now() >= deadline {
607            tracing::warn!(
608                remaining = active.load(Ordering::Relaxed),
609                "drain timeout reached; forcing shutdown"
610            );
611            break;
612        }
613        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
614    }
615}
616
617fn drain_timeout() -> std::time::Duration {
618    let secs = std::env::var("KHIVE_DRAIN_TIMEOUT_SECS")
619        .ok()
620        .and_then(|v| v.parse::<u64>().ok())
621        .unwrap_or(DEFAULT_DRAIN_TIMEOUT_SECS);
622    std::time::Duration::from_secs(secs)
623}
624
625/// Returns `true` for non-empty env values that are not `"0"` or `"false"`.
626pub fn env_truthy(key: &str) -> bool {
627    std::env::var(key)
628        .map(|v| {
629            let v = v.trim();
630            !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
631        })
632        .unwrap_or(false)
633}
634
635#[cfg(test)]
636mod tests {
637    use super::*;
638
639    // Focused regression tests for the unsafe process probe (SAFETY: signal 0
640    // is an existence check with no side effects; see is_process_running).
641
642    #[test]
643    fn current_process_is_running() {
644        // The current PID is always alive.
645        let pid = std::process::id();
646        assert!(
647            is_process_running(pid),
648            "current process {pid} should be detected as running"
649        );
650    }
651
652    #[test]
653    fn pid_zero_is_not_running() {
654        // PID 0 is the process group; kill(0, 0) sends to the group,
655        // which we treat as invalid — the guard `pid <= 0` must block it.
656        assert!(
657            !is_process_running(0),
658            "pid 0 must be rejected by the guard before the unsafe call"
659        );
660    }
661
662    #[test]
663    fn very_large_pid_is_not_running() {
664        // u32::MAX overflows i32 — try_from returns Err, guard returns false.
665        assert!(
666            !is_process_running(u32::MAX),
667            "u32::MAX should fail i32 conversion and return false"
668        );
669    }
670
671    #[test]
672    fn env_truthy_recognises_set_values() {
673        assert!(!env_truthy("__KHIVE_TEST_ABSENT_VAR_XYZ__"));
674
675        // env_truthy with a live value — set and unset atomically to avoid
676        // cross-test pollution (not parallel-safe without serial_test, but these
677        // are fast unit tests and the variable name is unique).
678        let key = "__KHIVE_TEST_TRUTHY_ABC__";
679        std::env::set_var(key, "1");
680        assert!(env_truthy(key));
681        std::env::set_var(key, "false");
682        assert!(!env_truthy(key));
683        std::env::set_var(key, "0");
684        assert!(!env_truthy(key));
685        std::env::remove_var(key);
686    }
687}