Skip to main content

khive_runtime/daemon/
process_files.rs

1use super::*;
2
3// ── helpers ───────────────────────────────────────────────────────────────────
4
5/// Liveness verdict for a `kill(pid, 0)` probe.
6#[cfg(unix)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub(super) enum PidLiveness {
9    /// errno 0 — signal delivery succeeded, the process exists and this
10    /// caller may signal it.
11    Alive,
12    /// ESRCH (or any other non-EPERM errno) — no such process.
13    Dead,
14    /// EPERM — the process exists but this caller lacks permission to
15    /// signal it. Unknown-safe: treated as running so stale-daemon cleanup
16    /// never unlinks a live daemon's socket/PID file just because it is
17    /// owned by a different user/uid.
18    PermissionDenied,
19}
20
21#[cfg(unix)]
22impl PidLiveness {
23    pub(super) fn is_running(self) -> bool {
24        !matches!(self, PidLiveness::Dead)
25    }
26}
27
28/// Maps a `kill(pid, 0)` outcome (return code + errno) to a [`PidLiveness`].
29/// Pure and side-effect-free so the errno mapping can be unit tested without
30/// a real process probe.
31#[cfg(unix)]
32pub(super) fn classify_kill_result(rc: i32, errno: i32) -> PidLiveness {
33    if rc == 0 {
34        return PidLiveness::Alive;
35    }
36    match errno {
37        libc::EPERM => PidLiveness::PermissionDenied,
38        _ => PidLiveness::Dead,
39    }
40}
41
42#[cfg(unix)]
43pub(super) fn is_process_running(pid: u32) -> bool {
44    let Ok(pid) = i32::try_from(pid) else {
45        return false;
46    };
47    if pid <= 0 {
48        return false;
49    }
50    // SAFETY: signal 0 is an existence/permission probe with no side effects.
51    let rc = unsafe { libc::kill(pid, 0) };
52    let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
53    classify_kill_result(rc, errno).is_running()
54}
55
56/// Whether a PID may identify an incumbent from this candidate's point of view.
57///
58/// Production rejects the current PID so a stale rendezvous left by a prior
59/// process whose PID was reused cannot protect an unrelated socket. The
60/// in-process daemon harness opts in to the same-PID case because all of its
61/// otherwise independent boot candidates necessarily share one OS process.
62#[cfg(unix)]
63pub(super) fn pid_can_name_incumbent(
64    pid: u32,
65    current_pid: u32,
66    allow_same_process_incumbent: bool,
67) -> bool {
68    allow_same_process_incumbent || pid != current_pid
69}
70
71/// Bounded timeout for the protocol-identity probe used by duplicate-daemon
72/// detection. Short enough that a hung or foreign listener does not stall
73/// startup; long enough for a live khived under normal load to answer a
74/// `probe_only` frame.
75#[cfg(unix)]
76pub(super) const DUPLICATE_PROBE_TIMEOUT: std::time::Duration =
77    std::time::Duration::from_millis(500);
78
79/// Whether the listener at `sock` actually speaks the khived wire protocol
80/// **as a khived this process can defer to** — identified by a configuration
81/// compatible with `expected_config_id`.
82///
83/// A live PID plus an accepting Unix socket is not proof of khived: any
84/// unrelated process that happens to have bound the same path also answers
85/// `connect()`. Nor is any well-formed [`DaemonResponseFrame`] proof: a
86/// `config_mismatch`/`version_mismatch` response, a `metrics_only` snapshot
87/// response, or a legacy pre-probe daemon that falls through to normal
88/// dispatch on the empty `ops` string, all deserialize cleanly without being
89/// the unambiguous "yes, alive and identity-matching" answer this check
90/// needs — the daemon's `metrics_only` arm in particular echoes the same
91/// `ok=true, result=None, error=None`, all-mismatch-flags-false, matching
92/// protocol version and `served_config_id` shape as the probe-ack arm, and
93/// is distinguished only by carrying `metrics: Some(...)`. This sends a
94/// bounded `probe_only` frame (the same identity probe the client-side
95/// recovery path uses, `crates/khive-mcp/src/daemon.rs::probe_daemon_identity`)
96/// carrying this process's own `config_id`, and requires a compatible
97/// probe-branch shape back: `ok=true`, `result=None`, `error=None`,
98/// `metrics=None`, `request_id=None` (this probe frame never sets one), no
99/// mismatch flags, matching protocol version, and matching
100/// `served_config_id` — mirroring the client probe's `is_probe_ack` check so
101/// both sides of the protocol agree on what "alive" means. Connect, write,
102/// and read are all inside the one bounded timeout: `UnixStream::connect`
103/// itself awaits write readiness, so a listener with a saturated accept
104/// backlog could otherwise hold this call open past the advertised bound.
105/// A connect that succeeds but never answers, times out, or answers with
106/// non-protocol bytes, a mismatched identity, a `metrics_only` snapshot, or
107/// any other non-probe-shaped response is not treated as the same khived
108/// and falls through to the stale-socket recovery path instead.
109#[cfg(unix)]
110pub(super) async fn socket_speaks_khived_protocol(
111    sock: &std::path::Path,
112    expected_config_id: &str,
113) -> bool {
114    let probe = DaemonRequestFrame {
115        probe_only: true,
116        protocol_version: PROTOCOL_VERSION,
117        config_id: expected_config_id.to_string(),
118        ..Default::default()
119    };
120    let Ok(payload) = serde_json::to_vec(&probe) else {
121        return false;
122    };
123    let response = tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, async {
124        let mut stream = UnixStream::connect(sock).await.ok()?;
125        write_frame(&mut stream, &payload).await.ok()?;
126        let raw = read_frame(&mut stream).await.ok()?;
127        serde_json::from_slice::<DaemonResponseFrame>(&raw).ok()
128    })
129    .await
130    .ok()
131    .flatten();
132
133    let Some(resp) = response else {
134        return false;
135    };
136    let is_probe_ack = resp.ok
137        && resp.result.is_none()
138        && resp.error.is_none()
139        && resp.metrics.is_none()
140        && resp.request_id.is_none();
141    is_probe_ack
142        && !resp.version_mismatch
143        && !resp.namespace_mismatch
144        && !resp.config_mismatch
145        && resp.daemon_protocol_version == PROTOCOL_VERSION
146        && resp
147            .served_config_id
148            .as_deref()
149            .is_some_and(|served| config_ids_compatible(expected_config_id, served))
150}
151
152/// Whether connecting to an existing socket path is definitely unreachable.
153/// Timeouts and other errors remain ambiguous so cleanup fails closed.
154#[cfg(unix)]
155pub(super) async fn socket_is_unreachable(sock: &std::path::Path) -> bool {
156    match tokio::time::timeout(DUPLICATE_PROBE_TIMEOUT, UnixStream::connect(sock)).await {
157        Ok(Err(error)) => matches!(
158            error.kind(),
159            std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused
160        ),
161        _ => false,
162    }
163}
164
165/// What owns the daemon PID file, from the point of view of a process that wants
166/// to start. A live owner is never cleaned up: a draining incumbent closes its
167/// listener before it releases writers, so an unanswered socket is ambiguous and
168/// deleting its PID file is how two daemons end up on one store.
169#[cfg(unix)]
170pub(super) enum Incumbent {
171    /// A live process that answered the khived protocol on the socket.
172    Serving(u32),
173    /// A live PID still has an active or ambiguous rendezvous. Nothing removed.
174    Live(u32),
175    /// Nothing live owns the store; the socket and PID file were removed.
176    Stale,
177}
178
179/// Check whether `pid_file`/`sock` already name a live daemon and, if not,
180/// remove the stale rendezvous files so the caller may bind fresh.
181///
182/// A live protocol responder, reachable socket, or live holder of the PID-file
183/// lock means the caller must refuse to start. An unlocked PID with no listener
184/// is a reused PID and may be reclaimed.
185#[cfg(unix)]
186pub(super) async fn cleanup_stale_daemon(
187    sock: &std::path::Path,
188    pid_file: &std::path::Path,
189    allow_same_process_incumbent: bool,
190    expected_config_id: &str,
191) -> Incumbent {
192    let mut stale_pid_file_guard = None;
193    if let Ok(pid_str) = std::fs::read_to_string(pid_file) {
194        if let Ok(pid) = pid_str.trim().parse::<u32>() {
195            if pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
196                && is_process_running(pid)
197            {
198                if sock.exists() && socket_speaks_khived_protocol(sock, expected_config_id).await {
199                    return Incumbent::Serving(pid);
200                }
201                if sock.exists() && !socket_is_unreachable(sock).await {
202                    return Incumbent::Live(pid);
203                }
204                match try_acquire_pid_file_lock(pid_file) {
205                    Ok(Some(guard)) => stale_pid_file_guard = Some(guard),
206                    Ok(None) => return Incumbent::Live(pid),
207                    Err(e) => {
208                        tracing::warn!(
209                            error = %e,
210                            path = ?pid_file,
211                            "cannot check daemon PID-file lock"
212                        );
213                        return Incumbent::Live(pid);
214                    }
215                }
216            }
217        }
218    }
219    if sock.exists() {
220        if let Err(e) = std::fs::remove_file(sock) {
221            tracing::warn!(error = %e, path = ?sock, "failed to remove stale socket");
222        }
223    }
224    if pid_file.exists() {
225        if let Err(e) = std::fs::remove_file(pid_file) {
226            tracing::warn!(error = %e, path = ?pid_file, "failed to remove stale PID file");
227        }
228    }
229    drop(stale_pid_file_guard);
230    Incumbent::Stale
231}
232
233/// Create and lock `pid_file` exclusively (`O_EXCL`) and write this process's PID.
234///
235/// Uses `create_new(true)` rather than `create(true).truncate(true)` so
236/// this can never silently overwrite a PID file another process created —
237/// the held file lock also identifies a starting or draining daemon when its
238/// socket is not yet reachable.
239#[cfg(unix)]
240pub(super) fn write_pid_file_exclusive(
241    pid_file: &std::path::Path,
242) -> std::io::Result<std::fs::File> {
243    use std::os::unix::fs::OpenOptionsExt;
244
245    let mut opts = std::fs::OpenOptions::new();
246    opts.write(true).create_new(true).mode(0o600);
247    let mut f = opts.open(pid_file)?;
248    // SAFETY: flock is a POSIX advisory lock with no memory side effects.
249    let rc = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
250    if rc != 0 {
251        return Err(std::io::Error::last_os_error());
252    }
253    f.write_all(std::process::id().to_string().as_bytes())?;
254    Ok(f)
255}
256
257/// Try to lock an existing PID file without creating it. `Some(file)` means
258/// there is no daemon lock holder; `None` means a daemon still owns the file.
259#[cfg(unix)]
260pub(super) fn try_acquire_pid_file_lock(
261    pid_file: &std::path::Path,
262) -> std::io::Result<Option<std::fs::File>> {
263    let file = std::fs::OpenOptions::new()
264        .read(true)
265        .write(true)
266        .open(pid_file)?;
267    // SAFETY: flock is a POSIX advisory lock with no memory side effects.
268    let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
269    if rc == 0 {
270        return Ok(Some(file));
271    }
272    let error = std::io::Error::last_os_error();
273    if error.kind() == std::io::ErrorKind::WouldBlock
274        || error.raw_os_error() == Some(libc::EWOULDBLOCK)
275    {
276        Ok(None)
277    } else {
278        Err(error)
279    }
280}
281
282/// Remove the PID file on setup failure only while its path still names the
283/// file this start attempt created.
284#[cfg(unix)]
285pub(super) fn remove_pid_file_if_owned(pid_file: &std::path::Path, guard: &std::fs::File) {
286    let Ok(owned) = guard.metadata() else {
287        return;
288    };
289    let Ok(current) = std::fs::metadata(pid_file) else {
290        return;
291    };
292    if owned.dev() == current.dev() && owned.ino() == current.ino() {
293        if let Err(e) = std::fs::remove_file(pid_file) {
294            tracing::warn!(error = %e, path = ?pid_file, "failed to remove unbound PID file");
295        }
296    }
297}
298
299/// Return `true` if `pid_file` currently names an eligible live process that
300/// still answers on `sock` — i.e. a daemon already owns this rendezvous and it
301/// is safe to defer to it rather than treat the `AlreadyExists` PID-file
302/// collision as a boot failure. Eligibility requires a different PID in
303/// production; the explicit in-process harness may allow the current PID.
304#[cfg(unix)]
305pub(super) async fn pid_file_names_a_reachable_daemon(
306    pid_file: &std::path::Path,
307    sock: &std::path::Path,
308    allow_same_process_incumbent: bool,
309    expected_config_id: &str,
310) -> bool {
311    let Ok(pid_str) = std::fs::read_to_string(pid_file) else {
312        return false;
313    };
314    let Ok(pid) = pid_str.trim().parse::<u32>() else {
315        return false;
316    };
317    pid_can_name_incumbent(pid, std::process::id(), allow_same_process_incumbent)
318        && is_process_running(pid)
319        && sock.exists()
320        && socket_speaks_khived_protocol(sock, expected_config_id).await
321}
322
323#[cfg(unix)]
324pub(super) async fn drain(active: &std::sync::atomic::AtomicUsize) -> bool {
325    drain_with_timeout(active, drain_timeout()).await
326}
327
328/// Voluntary retirement keeps admitted workers and rendezvous ownership alive.
329#[cfg(unix)]
330pub(super) async fn drain_for_idle(
331    active: &std::sync::atomic::AtomicUsize,
332    timeout: std::time::Duration,
333) {
334    let deadline = tokio::time::Instant::now() + timeout;
335    let mut warned = false;
336    while active.load(std::sync::atomic::Ordering::SeqCst) + background_task_count() != 0 {
337        if !warned && tokio::time::Instant::now() >= deadline {
338            tracing::warn!(
339                "idle drain interval elapsed; retaining workers and rendezvous until settled"
340            );
341            warned = true;
342        }
343        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
344    }
345}
346
347#[cfg(unix)]
348pub(super) async fn drain_with_timeout(
349    active: &std::sync::atomic::AtomicUsize,
350    timeout: std::time::Duration,
351) -> bool {
352    use std::sync::atomic::Ordering;
353    // One sequentially-consistent order spans connection handoff and tracked
354    // task publication. Drain must not observe an ended connection and a
355    // stale pre-publication background count as two simultaneous zeroes.
356    let remaining = || active.load(Ordering::SeqCst) + background_task_count();
357    if remaining() == 0 {
358        return true;
359    }
360    let deadline = tokio::time::Instant::now() + timeout;
361    while remaining() > 0 {
362        if tokio::time::Instant::now() >= deadline {
363            tracing::warn!(
364                remaining_connections = active.load(Ordering::SeqCst),
365                remaining_background_tasks = background_task_count(),
366                outstanding_background_tasks = %background_task_names().join(", "),
367                "drain timeout reached; forcing shutdown"
368            );
369            return false;
370        }
371        tokio::select! {
372            _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {}
373            _ = tokio::time::sleep_until(deadline) => {}
374        }
375    }
376    true
377}
378
379#[cfg(unix)]
380pub(super) async fn finish_connection_tasks(
381    tasks: Vec<tokio::task::JoinHandle<()>>,
382    drained: bool,
383) {
384    if !drained {
385        for task in &tasks {
386            if !task.is_finished() {
387                task.abort();
388            }
389        }
390    }
391    for task in tasks {
392        let _ = task.await;
393    }
394}
395
396/// The bound `drain()` waits for tracked background tasks at daemon shutdown
397/// (`KHIVE_DRAIN_TIMEOUT_SECS`, default 10s). Public so component supervision
398/// can clamp per-component shutdown timeouts against it — a component timeout
399/// longer than the drain bound could never complete its abort/state
400/// transition before the daemon returns.
401pub fn drain_timeout() -> std::time::Duration {
402    let secs = khive_db::env::env_parse_or("KHIVE_DRAIN_TIMEOUT_SECS", DEFAULT_DRAIN_TIMEOUT_SECS);
403    std::time::Duration::from_secs(secs)
404}