Skip to main content

mati_core/mcp/
metadata.rs

1//! Daemon metadata — PID file, session UUID, and Unix permission hardening.
2//!
3//! The on-disk file is `~/.mati/<slug>/mati.pid`. Its internal representation
4//! is [`DaemonMetadata`], which carries the daemon PID and a session UUID.
5//!
6//! ## Atomic publication
7//!
8//! Metadata is published atomically: write to `mati.pid.tmp`, set mode 0600,
9//! then rename over `mati.pid`. This eliminates the window where a reader sees
10//! a partially-written file.
11//!
12//! ## Permission model (Unix-only)
13//!
14//! - Runtime dir (`~/.mati/<slug>/`): mode 0700
15//! - Metadata file (`mati.pid`): mode 0600
16//! - Socket file (`mati.sock`): mode 0600 (set after bind)
17//!
18//! ## Stale-socket cleanup
19//!
20//! On startup, the daemon checks for an existing socket+metadata. If the
21//! recorded PID is dead, the files are removed. If the PID is alive, startup
22//! is refused. The socket is never blindly unlinked.
23
24use std::path::Path;
25
26use anyhow::{Context, Result};
27use serde::{Deserialize, Serialize};
28use uuid::Uuid;
29
30/// Owner identity — who created this daemon socket.
31///
32/// Used by `mati daemon stop` to refuse killing an MCP server session,
33/// and by proxy mode to determine whether to connect.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum DaemonOwner {
37    /// Started via `mati daemon start`.
38    Daemon,
39    /// Started via `mati serve` (MCP stdio server with embedded socket).
40    Mcp,
41}
42
43impl std::fmt::Display for DaemonOwner {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::Daemon => write!(f, "daemon"),
47            Self::Mcp => write!(f, "mcp"),
48        }
49    }
50}
51
52/// On-disk daemon metadata. Persisted as `mati.pid`, read by the CLI proxy
53/// and hook scripts to route through the daemon socket.
54///
55/// The session UUID is a session marker for audit/provenance — NOT an
56/// authentication token. Peer identity is established via Unix peer
57/// credentials (`peer_cred()`).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct DaemonMetadata {
60    /// PID of the daemon process.
61    pub pid: u32,
62    /// Session UUID — included in every IPC request for audit correlation.
63    /// Generated fresh on each daemon startup.
64    pub session: Uuid,
65    /// Who started this daemon (daemon vs mcp server).
66    pub owner: DaemonOwner,
67    /// Version of the binary serving this store.
68    ///
69    /// Hooks and the MCP server can resolve to *different* binaries — hooks
70    /// run `.claude/hooks/mati`, which `mati init` pinned to whichever
71    /// executable ran it, while `.mcp.json` registers a bare `mati` off PATH.
72    /// A rebuild of one and not the other puts a stale client against a live
73    /// daemon, and hooks fail open on a protocol mismatch, so the symptom is
74    /// silent under-enforcement. Recording the version here lets `mati doctor`
75    /// name the mismatch instead.
76    ///
77    /// `#[serde(default)]`: a `mati.pid` written by an older binary has no
78    /// version, and must still parse.
79    #[serde(default)]
80    pub version: String,
81}
82
83impl DaemonMetadata {
84    /// Create metadata for the current process.
85    pub fn new(owner: DaemonOwner) -> Self {
86        Self {
87            pid: std::process::id(),
88            session: Uuid::new_v4(),
89            owner,
90            version: env!("CARGO_PKG_VERSION").to_string(),
91        }
92    }
93}
94
95// ── File paths ──────────────────────────────────────────────────────────────
96
97const METADATA_FILENAME: &str = "mati.pid";
98const METADATA_TMP_FILENAME: &str = "mati.pid.tmp";
99const SOCKET_FILENAME: &str = "mati.sock";
100
101/// Return the metadata file path for a given mati root.
102///
103/// Crate-internal: callers in `mcp::server` use it for rollback-on-bind-fail
104/// in the daemon-socket task. Outside the crate, prefer `read_metadata` /
105/// `publish_metadata` rather than constructing paths directly.
106pub(crate) fn metadata_path(root: &Path) -> std::path::PathBuf {
107    root.join(METADATA_FILENAME)
108}
109
110/// Return the socket file path for a given mati root.
111pub fn socket_path(root: &Path) -> std::path::PathBuf {
112    root.join(SOCKET_FILENAME)
113}
114
115// ── Permission hardening (Unix-only) ────────────────────────────────────────
116
117/// Ensure the runtime directory exists with mode 0700.
118///
119/// Creates `~/.mati/<slug>/` if absent. Always re-applies 0700 in case a
120/// previous run or manual change left weaker permissions.
121pub fn ensure_runtime_dir(root: &Path) -> Result<()> {
122    std::fs::create_dir_all(root)
123        .with_context(|| format!("cannot create runtime dir at {}", root.display()))?;
124    set_mode(root, 0o700).with_context(|| format!("cannot set mode 0700 on {}", root.display()))?;
125    Ok(())
126}
127
128/// Set mode 0600 on the socket file after `UnixListener::bind()`.
129///
130/// `bind()` creates the socket with permissions derived from the process umask.
131/// This call tightens them to owner-only regardless of umask.
132pub fn harden_socket(sock_path: &Path) -> Result<()> {
133    set_mode(sock_path, 0o600)
134        .with_context(|| format!("cannot set mode 0600 on {}", sock_path.display()))
135}
136
137/// Set Unix file mode. No-op on non-Unix (compile-gated).
138#[cfg(unix)]
139fn set_mode(path: &Path, mode: u32) -> Result<()> {
140    use std::os::unix::fs::PermissionsExt;
141    let perms = std::fs::Permissions::from_mode(mode);
142    std::fs::set_permissions(path, perms)?;
143    Ok(())
144}
145
146#[cfg(not(unix))]
147fn set_mode(_path: &Path, _mode: u32) -> Result<()> {
148    Ok(())
149}
150
151// ── Atomic metadata publication ─────────────────────────────────────────────
152
153/// Atomically publish daemon metadata to `mati.pid`.
154///
155/// Writes to `mati.pid.tmp` with mode 0600, then renames over `mati.pid`.
156/// The rename is atomic on Unix when both paths are on the same filesystem
157/// (always true within `~/.mati/<slug>/`).
158pub fn publish_metadata(root: &Path, metadata: &DaemonMetadata) -> Result<()> {
159    let tmp_path = root.join(METADATA_TMP_FILENAME);
160    let final_path = metadata_path(root);
161
162    let json = serde_json::to_string(metadata).context("failed to serialize daemon metadata")?;
163
164    std::fs::write(&tmp_path, json.as_bytes())
165        .with_context(|| format!("failed to write {}", tmp_path.display()))?;
166
167    // Set permissions BEFORE rename so the file is never visible with wrong mode.
168    set_mode(&tmp_path, 0o600)?;
169
170    std::fs::rename(&tmp_path, &final_path).with_context(|| {
171        format!(
172            "failed to rename {} → {}",
173            tmp_path.display(),
174            final_path.display()
175        )
176    })?;
177
178    Ok(())
179}
180
181// ── Metadata reading ────────────────────────────────────────────────────────
182
183/// Read daemon metadata from `mati.pid`.
184///
185/// Returns `None` if the file does not exist or cannot be parsed.
186/// Supports the v2 JSON format `{"pid":N,"session":"uuid","owner":"daemon"}`.
187/// Falls back to the legacy v1 formats for backward compatibility during
188/// the migration window.
189pub fn read_metadata(root: &Path) -> Option<DaemonMetadata> {
190    let content = std::fs::read_to_string(metadata_path(root)).ok()?;
191    let trimmed = content.trim();
192
193    // Try v2 format first (full DaemonMetadata).
194    if let Ok(meta) = serde_json::from_str::<DaemonMetadata>(trimmed) {
195        return Some(meta);
196    }
197
198    // Legacy plain PID format: "1234" — try before generic JSON parse
199    // so a bare number is not consumed by serde_json::Value.
200    if let Ok(pid) = trimmed.parse::<u32>() {
201        return Some(DaemonMetadata {
202            pid,
203            session: Uuid::nil(),
204            owner: DaemonOwner::Daemon,
205            version: String::new(),
206        });
207    }
208
209    // Legacy v1 JSON: {"pid":N,"owner":"daemon"|"mcp"} — no session field.
210    if let Ok(val) = serde_json::from_str::<serde_json::Value>(trimmed) {
211        let pid = val.get("pid").and_then(|v| v.as_u64())? as u32;
212        let owner_str = val
213            .get("owner")
214            .and_then(|v| v.as_str())
215            .unwrap_or("daemon");
216        let owner = match owner_str {
217            "mcp" => DaemonOwner::Mcp,
218            _ => DaemonOwner::Daemon,
219        };
220        return Some(DaemonMetadata {
221            pid,
222            // Legacy metadata has no session — generate one so callers always
223            // have a UUID. The daemon will reject requests with this UUID
224            // (SessionMismatch), forcing the proxy to re-read after daemon restart.
225            session: Uuid::nil(),
226            owner,
227            version: String::new(),
228        });
229    }
230
231    None
232}
233
234// ── PID liveness ────────────────────────────────────────────────────────────
235
236/// Check whether a PID is still alive.
237///
238/// Uses `kill(pid, 0)` which checks existence without sending a signal.
239/// Returns true if the process exists (even if owned by another user — EPERM).
240#[cfg(unix)]
241pub fn is_pid_alive(pid: u32) -> bool {
242    // SAFETY: kill(pid, 0) is a standard POSIX liveness check. It sends no
243    // signal — it only tests whether the PID exists and is reachable.
244    let ret = unsafe { libc::kill(pid as libc::pid_t, 0) };
245    if ret == 0 {
246        return true;
247    }
248    // EPERM means the process exists but belongs to another user — still alive.
249    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
250}
251
252#[cfg(not(unix))]
253pub fn is_pid_alive(_pid: u32) -> bool {
254    true // Conservative: assume alive on non-Unix
255}
256
257/// Returns the effective UID of the current process.
258///
259/// Used by the peer credential check to compare against connecting peers.
260#[cfg(unix)]
261pub fn current_euid() -> u32 {
262    // SAFETY: geteuid() is a pure read with no side effects.
263    unsafe { libc::geteuid() }
264}
265
266#[cfg(not(unix))]
267pub fn current_euid() -> u32 {
268    0
269}
270
271/// Returns the calling thread's QoS class as a human-readable string.
272///
273/// Included in `serve_start` lifecycle events so that a silent failure of
274/// `pthread_set_qos_class_self_np` is visible in `mati doctor` output
275/// before the kernel-panic symptoms recur.
276#[cfg(target_os = "macos")]
277pub fn current_qos_class_str() -> &'static str {
278    extern "C" {
279        fn qos_class_self() -> libc::c_uint;
280    }
281    // SAFETY: qos_class_self() is a pure read; it queries the current thread's
282    // QoS class from the kernel without any side effects.
283    match unsafe { qos_class_self() } {
284        0x21 => "user_interactive",
285        0x19 => "user_initiated",
286        0x15 => "default",
287        0x11 => "utility",
288        0x09 => "background",
289        _ => "unknown",
290    }
291}
292
293#[cfg(not(target_os = "macos"))]
294pub fn current_qos_class_str() -> &'static str {
295    "n/a"
296}
297
298// ── SIGTERM / SIGKILL escalation ────────────────────────────────────────────
299
300/// How long to poll for `is_pid_alive` after sending SIGKILL before
301/// declaring the process [`KillOutcome::Stuck`].
302///
303/// Historical defaults and rationale for each step:
304///
305/// - **500ms** (pre-γ): worked for lightly-loaded shutdowns where the
306///   kernel reaped within the first tens of ms.
307/// - **2s** (γ-C7 followup `04ef6e2`): targeted the case where a
308///   slow-shutdown daemon exited at ~600ms.
309/// - **5s** (this commit, γ-C7-followup-2): smoke evidence from
310///   `mati_step_27_stop.out` plus the `serve_shutdown signal_sigterm`
311///   lifecycle event shows the daemon's `store.close()` path can be in
312///   mid-fsync when SIGKILL hits. The kernel MUST wait for the
313///   uninterruptible fsync to complete before fully tearing down the
314///   process, during which `kill(pid, 0)` keeps reporting alive. Under
315///   smoke load (tantivy index commit + dual SurrealKV WAL fsync), this
316///   teardown can legitimately take 2-4 seconds. 5s provides headroom
317///   without unbounded patience — a genuinely-wedged daemon still
318///   surfaces as Stuck within 25s total (20s SIGTERM + 5s SIGKILL).
319const SIGKILL_REAP_WINDOW: std::time::Duration = std::time::Duration::from_secs(5);
320
321/// Outcome of [`kill_and_wait`]. Carries elapsed wall time so callers can
322/// report or log exactly how the kill resolved.
323#[derive(Debug)]
324pub enum KillOutcome {
325    /// Process exited within the SIGTERM budget.
326    ExitedClean(std::time::Duration),
327    /// SIGTERM was ignored or absorbed; SIGKILL succeeded.
328    KilledHard(std::time::Duration),
329    /// Process is still alive after SIGKILL — manual intervention required.
330    /// Carries a [`StuckDiagnostic`] so callers can surface the actual
331    /// process state at the moment we gave up. γ smoke surfaced cases
332    /// where the daemon was effectively gone (lock released, next CLI
333    /// command worked) but our `kill(0)` poll kept reporting alive; the
334    /// diagnostic snapshot lets us distinguish kill(0)-lying-after-SIGKILL,
335    /// zombie state, PID reuse (different process at that PID now), and
336    /// genuinely-still-alive cases on the next failure.
337    Stuck(StuckDiagnostic),
338}
339
340/// Diagnostic data captured at the moment [`KillOutcome::Stuck`] is
341/// returned. Includes timing for each phase and a `ps`-driven snapshot
342/// of the process state at both the start of the kill and the giving-up
343/// point — enabling root-cause analysis without re-running the failure.
344#[derive(Debug, Clone)]
345pub struct StuckDiagnostic {
346    pub pid: u32,
347    /// Elapsed wall time from [`kill_and_wait`] / [`kill_directly`] entry.
348    pub total_elapsed_ms: u64,
349    /// Time spent in the SIGTERM phase. `None` if [`kill_directly`] was
350    /// used (no SIGTERM phase).
351    pub sigterm_elapsed_ms: Option<u64>,
352    /// Time spent polling after SIGKILL.
353    pub sigkill_elapsed_ms: u64,
354    /// Process state when the kill started (via `ps -o ...`).
355    pub initial_snapshot: PidSnapshot,
356    /// Process state when we gave up (via `ps -o ...`).
357    pub final_snapshot: PidSnapshot,
358}
359
360/// `ps -o`-derived snapshot of a PID. Used by [`StuckDiagnostic`] to
361/// pin down why `kill_and_wait` gave up.
362///
363/// On the failure path we cross-check `kill(pid, 0)`'s lying-alive report
364/// against three orthogonal indicators:
365///
366/// - **`lstart`** changed between initial and final → the PID was reused
367///   by a different process (kernel reaped the old one, assigned PID to a
368///   new spawn).
369/// - **`state`** is `Z` → process really is a zombie awaiting reap by its
370///   parent. `kill(0)` succeeds because the proc entry exists; the
371///   process holds no resources.
372/// - **all fields `None`** → `ps` reports the PID is gone but `kill(0)`
373///   still says alive: macOS kernel proc-table lag (the proc structure
374///   hasn't been fully torn down even though the process has exited).
375/// - **same `lstart`, normal `state`** → process is genuinely still
376///   alive. Real Stuck case — daemon shutdown is wedged.
377#[derive(Debug, Clone, Default)]
378pub struct PidSnapshot {
379    /// Process start time as reported by `ps -o lstart=`. `None` if ps
380    /// can't find the PID.
381    pub lstart: Option<String>,
382    /// Process state: 'R' running, 'S' sleeping, 'Z' zombie, etc.
383    pub state: Option<String>,
384    /// Process command name as reported by `ps -o comm=`.
385    pub comm: Option<String>,
386}
387
388impl PidSnapshot {
389    /// Render as a compact one-line diagnostic string suitable for
390    /// inclusion in lifecycle events and stderr.
391    pub fn render(&self) -> String {
392        match (&self.lstart, &self.state, &self.comm) {
393            (None, None, None) => "ps:gone".into(),
394            _ => format!(
395                "lstart={:?} state={:?} comm={:?}",
396                self.lstart.as_deref().unwrap_or("?"),
397                self.state.as_deref().unwrap_or("?"),
398                self.comm.as_deref().unwrap_or("?")
399            ),
400        }
401    }
402}
403
404/// Snapshot the named `ps` field for `pid`. Returns `None` if `ps` can't
405/// find the PID (process gone) or the call fails.
406fn ps_field(pid: u32, field: &str) -> Option<String> {
407    let pid_str = pid.to_string();
408    let output = std::process::Command::new("ps")
409        .args(["-o", &format!("{field}="), "-p", &pid_str])
410        .output()
411        .ok()?;
412    if !output.status.success() {
413        return None;
414    }
415    let trimmed = String::from_utf8_lossy(&output.stdout).trim().to_string();
416    if trimmed.is_empty() {
417        None
418    } else {
419        Some(trimmed)
420    }
421}
422
423/// Capture a `PidSnapshot` via three `ps` calls (lstart, state, comm).
424/// Each call is ~10ms on macOS; total ~30ms. Only invoked on the Stuck
425/// path so the cost doesn't touch the hot path.
426pub fn snapshot_pid(pid: u32) -> PidSnapshot {
427    PidSnapshot {
428        lstart: ps_field(pid, "lstart"),
429        state: ps_field(pid, "state"),
430        comm: ps_field(pid, "comm"),
431    }
432}
433
434/// Send SIGTERM to `pid`. Returns `true` on success or when the kernel
435/// reports the process is already gone (`ESRCH`). `kill(2)` returning
436/// any other error counts as failure — caller surfaces it to the user.
437#[cfg(unix)]
438fn send_sigterm(pid: u32) -> bool {
439    // SAFETY: `kill(pid, SIGTERM)` is a standard POSIX system call. The
440    // worst case is an ESRCH return — we treat that as success because
441    // the contract is "stop this process" and a nonexistent process is
442    // already stopped.
443    let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
444    if ret == 0 {
445        return true;
446    }
447    let errno = std::io::Error::last_os_error().raw_os_error();
448    matches!(errno, Some(libc::ESRCH))
449}
450
451#[cfg(not(unix))]
452fn send_sigterm(_pid: u32) -> bool {
453    false
454}
455
456/// Send SIGKILL to `pid` and poll for exit. γ-C6: used by
457/// `mati daemon stop --force` to bypass the SIGTERM grace period and
458/// terminate the daemon immediately. The reaping window matches the
459/// SIGKILL escalation phase of [`kill_and_wait`] — see
460/// `SIGKILL_REAP_WINDOW` for the rationale.
461pub async fn kill_directly(pid: u32) -> KillOutcome {
462    let started = std::time::Instant::now();
463    let initial_snapshot = snapshot_pid(pid);
464    #[cfg(unix)]
465    {
466        // SAFETY: SIGKILL is non-catchable; the process either exits or
467        // we surface Stuck. `kill(2)` is a standard system call.
468        let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
469        if ret != 0 {
470            let errno = std::io::Error::last_os_error().raw_os_error();
471            if !matches!(errno, Some(libc::ESRCH)) {
472                tracing::warn!(pid, ?errno, "kill_directly: SIGKILL rejected by kernel");
473                let elapsed_ms = started.elapsed().as_millis() as u64;
474                return KillOutcome::Stuck(StuckDiagnostic {
475                    pid,
476                    total_elapsed_ms: elapsed_ms,
477                    sigterm_elapsed_ms: None,
478                    sigkill_elapsed_ms: elapsed_ms,
479                    initial_snapshot,
480                    final_snapshot: snapshot_pid(pid),
481                });
482            }
483            // ESRCH — already gone, treat as success.
484            return KillOutcome::KilledHard(started.elapsed());
485        }
486    }
487
488    let sigkill_start = std::time::Instant::now();
489    if poll_until_exit(pid, SIGKILL_REAP_WINDOW, sigkill_start).await {
490        return KillOutcome::KilledHard(started.elapsed());
491    }
492    let sigkill_elapsed_ms = sigkill_start.elapsed().as_millis() as u64;
493    KillOutcome::Stuck(StuckDiagnostic {
494        pid,
495        total_elapsed_ms: started.elapsed().as_millis() as u64,
496        sigterm_elapsed_ms: None,
497        sigkill_elapsed_ms,
498        initial_snapshot,
499        final_snapshot: snapshot_pid(pid),
500    })
501}
502
503/// Send SIGTERM to `pid`, wait up to `timeout` for the process to exit, and
504/// escalate to SIGKILL with `SIGKILL_REAP_WINDOW` of reaping budget if it
505/// does not.
506///
507/// Used by both `mati daemon stop` and the unresponsive-recovery branch of
508/// `ensure_daemon` so the synchronous-exit guarantee is identical across
509/// both paths. Pre-condition: caller has authorized the kill (`--force`
510/// gate, ownership check) and knows the PID is alive.
511pub async fn kill_and_wait(pid: u32, timeout: std::time::Duration) -> KillOutcome {
512    let started = std::time::Instant::now();
513    let initial_snapshot = snapshot_pid(pid);
514
515    if !send_sigterm(pid) {
516        tracing::warn!(pid, "kill_and_wait: SIGTERM rejected by kernel");
517        let elapsed_ms = started.elapsed().as_millis() as u64;
518        return KillOutcome::Stuck(StuckDiagnostic {
519            pid,
520            total_elapsed_ms: elapsed_ms,
521            sigterm_elapsed_ms: Some(elapsed_ms),
522            sigkill_elapsed_ms: 0,
523            initial_snapshot,
524            final_snapshot: snapshot_pid(pid),
525        });
526    }
527
528    let sigterm_start = std::time::Instant::now();
529    if poll_until_exit(pid, timeout, started).await {
530        return KillOutcome::ExitedClean(started.elapsed());
531    }
532    let sigterm_elapsed_ms = sigterm_start.elapsed().as_millis() as u64;
533
534    tracing::warn!(
535        pid,
536        timeout_secs = timeout.as_secs(),
537        "process did not exit within SIGTERM budget — sending SIGKILL"
538    );
539    let sigkill_start = std::time::Instant::now();
540    #[cfg(unix)]
541    {
542        // SAFETY: SIGKILL is non-catchable; the process either exits or
543        // we surface Stuck. `kill(2)` is a standard system call.
544        let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
545        if ret != 0 {
546            let errno = std::io::Error::last_os_error().raw_os_error();
547            // ESRCH means it exited on the late SIGTERM; the poll below reports
548            // that. Any other errno means the signal never landed, and
549            // `is_pid_alive` counts EPERM as alive, so polling only stalls the
550            // same Stuck verdict by a full reap window. Matches `kill_directly`.
551            if !matches!(errno, Some(libc::ESRCH)) {
552                tracing::warn!(pid, ?errno, "kill_and_wait: SIGKILL rejected by kernel");
553                return KillOutcome::Stuck(StuckDiagnostic {
554                    pid,
555                    total_elapsed_ms: started.elapsed().as_millis() as u64,
556                    sigterm_elapsed_ms: Some(sigterm_elapsed_ms),
557                    sigkill_elapsed_ms: sigkill_start.elapsed().as_millis() as u64,
558                    initial_snapshot,
559                    final_snapshot: snapshot_pid(pid),
560                });
561            }
562        }
563    }
564
565    if poll_until_exit(pid, SIGKILL_REAP_WINDOW, sigkill_start).await {
566        return KillOutcome::KilledHard(started.elapsed());
567    }
568    let sigkill_elapsed_ms = sigkill_start.elapsed().as_millis() as u64;
569
570    KillOutcome::Stuck(StuckDiagnostic {
571        pid,
572        total_elapsed_ms: started.elapsed().as_millis() as u64,
573        sigterm_elapsed_ms: Some(sigterm_elapsed_ms),
574        sigkill_elapsed_ms,
575        initial_snapshot,
576        final_snapshot: snapshot_pid(pid),
577    })
578}
579
580/// Poll [`is_pid_alive`] until the PID is **effectively gone** or `budget`
581/// elapses (from `started`). Returns `true` if the process is gone or in a
582/// zombie state, `false` if it's still genuinely alive when the budget runs
583/// out.
584///
585/// ## Why zombie detection is needed
586///
587/// γ-C7 followup smoke surfaced a real zombie scenario: when the daemon's
588/// parent process is a `mati serve` proxy (post-γ-C4 architecture), and
589/// the proxy doesn't call `waitpid()` on its children, the daemon process
590/// exits cleanly under SIGKILL but its proc-table entry stays as a zombie
591/// (`<defunct>`, state `'Z'`) until the proxy is killed or exits.
592///
593/// `kill(pid, 0)` continues returning success for zombies — the kernel
594/// considers the proc entry "alive" until reaped. So a pure `kill(0)`
595/// poll loop hangs until the budget expires, returning a false `Stuck`
596/// even though the zombie holds no FDs, no locks, no resources.
597///
598/// This was empirically captured by the `StuckDiagnostic` instrumentation
599/// added in commit `dd5f5a0`: the final snapshot showed
600/// `state="Z" comm="<defunct>"` — the smoking gun.
601///
602/// ## How the zombie check works
603///
604/// Every `ZOMBIE_CHECK_INTERVAL` poll iterations, when `kill(0)` reports
605/// alive, also run `ps -o state= -p <pid>`. If the state starts with `Z`,
606/// the process is a zombie — functionally dead (locks released by the
607/// kernel at exit, no further user-code execution) — and we return `true`.
608///
609/// `ps_field` spawns a subprocess (~10ms), so it's amortized across
610/// every 5 polls (250ms) to keep the per-iteration overhead low. Zombie
611/// state is monotonic — once entered, it never reverts — so sub-second
612/// detection is unnecessary.
613async fn poll_until_exit(
614    pid: u32,
615    budget: std::time::Duration,
616    started: std::time::Instant,
617) -> bool {
618    const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(50);
619    const ZOMBIE_CHECK_INTERVAL: u32 = 5;
620    let deadline = started + budget;
621    let mut iter: u32 = 0;
622    while std::time::Instant::now() < deadline {
623        if !is_pid_alive(pid) {
624            return true;
625        }
626        // Cheap zombie check on a sub-rate to avoid spawning ps on every
627        // 50ms tick. State only ever transitions toward `Z` from running
628        // states (R/S/T), never back.
629        if iter % ZOMBIE_CHECK_INTERVAL == 0 {
630            if let Some(state) = ps_field(pid, "state") {
631                if state.starts_with('Z') {
632                    return true;
633                }
634            }
635        }
636        iter = iter.wrapping_add(1);
637        tokio::time::sleep(POLL_INTERVAL).await;
638    }
639    false
640}
641
642// ── Peer credentials ────────────────────────────────────────────────────────
643
644/// Peer identity from a Unix socket connection. Carried through the request
645/// pipeline into handlers and the audit record.
646#[derive(Debug, Clone)]
647pub struct PeerContext {
648    /// Effective UID of the connecting process.
649    pub uid: u32,
650    /// PID of the connecting process (available on Linux and macOS, None on
651    /// platforms where `peer_cred()` does not expose it).
652    pub pid: Option<u32>,
653}
654
655/// Verify that a connecting peer has the same effective UID as the daemon.
656///
657/// Returns `Some(PeerContext)` on success, `None` on mismatch or failure.
658/// On `None`, the caller MUST drop the connection and continue the accept
659/// loop — never crash.
660///
661/// This enforces the Unix-socket UID boundary: only processes running as
662/// the same user can talk to the daemon.
663pub fn check_peer_cred(stream: &tokio::net::UnixStream, daemon_euid: u32) -> Option<PeerContext> {
664    match stream.peer_cred() {
665        Ok(cred) => {
666            let peer_uid = cred.uid();
667            if peer_uid != daemon_euid {
668                tracing::warn!(
669                    peer_uid,
670                    daemon_uid = daemon_euid,
671                    "peer UID mismatch — dropping connection"
672                );
673                return None;
674            }
675            let peer_pid = cred.pid().map(|p| p as u32);
676            tracing::trace!(peer_uid, ?peer_pid, "peer credential check passed");
677            Some(PeerContext {
678                uid: peer_uid,
679                pid: peer_pid,
680            })
681        }
682        Err(e) => {
683            tracing::warn!(error = %e, "peer_cred() failed — dropping connection");
684            None
685        }
686    }
687}
688
689// ── Stale-socket cleanup ────────────────────────────────────────────────────
690
691/// Outcome of a stale-socket check.
692#[derive(Debug, PartialEq, Eq)]
693pub enum StaleCheckResult {
694    /// No metadata or socket — safe to proceed with startup.
695    Clean,
696    /// Metadata references a dead PID — stale files cleaned up, safe to proceed.
697    StaleRemoved,
698    /// Metadata references a live PID — daemon is running, refuse startup.
699    LiveDaemon {
700        pid: u32,
701        owner: DaemonOwner,
702        session: Uuid,
703    },
704    /// Metadata is absent but socket file exists — ambiguous state.
705    /// Caller should probe the socket before deciding.
706    OrphanSocket,
707}
708
709/// Check for stale daemon state and clean up if safe.
710///
711/// This implements the safe stale-socket protocol:
712/// 1. Read metadata if present
713/// 2. Test PID liveness
714/// 3. If live daemon exists, return `LiveDaemon` (refuse startup)
715/// 4. Only remove stale socket+metadata when PID is dead
716///
717/// The socket is NEVER blindly unlinked.
718pub fn check_and_cleanup_stale(root: &Path) -> StaleCheckResult {
719    let meta_path = metadata_path(root);
720    let sock_path = socket_path(root);
721
722    let has_metadata = meta_path.exists();
723    let has_socket = sock_path.exists();
724
725    if !has_metadata && !has_socket {
726        return StaleCheckResult::Clean;
727    }
728
729    // Socket exists but no metadata — ambiguous. Caller must probe.
730    if !has_metadata && has_socket {
731        return StaleCheckResult::OrphanSocket;
732    }
733
734    // Metadata exists — parse and check PID liveness.
735    let metadata = match read_metadata(root) {
736        Some(m) => m,
737        None => {
738            // Metadata file exists but is corrupt/unreadable.
739            // Treat as stale: remove both files.
740            tracing::warn!("daemon metadata corrupt — removing stale files");
741            let _ = std::fs::remove_file(&meta_path);
742            let _ = std::fs::remove_file(&sock_path);
743            return StaleCheckResult::StaleRemoved;
744        }
745    };
746
747    if is_pid_alive(metadata.pid) {
748        return StaleCheckResult::LiveDaemon {
749            pid: metadata.pid,
750            owner: metadata.owner,
751            session: metadata.session,
752        };
753    }
754
755    // PID is dead — clean up stale files.
756    tracing::info!(
757        pid = metadata.pid,
758        owner = %metadata.owner,
759        "removing stale daemon files (PID dead)"
760    );
761    let _ = std::fs::remove_file(&sock_path);
762    let _ = std::fs::remove_file(&meta_path);
763    // Also remove the starting sentinel if present.
764    let _ = std::fs::remove_file(root.join("mati.starting"));
765
766    StaleCheckResult::StaleRemoved
767}
768
769// ── Lifecycle log ───────────────────────────────────────────────────────────
770
771const LIFECYCLE_FILENAME: &str = "lifecycle.log";
772
773/// Maximum number of lines retained in `lifecycle.log`. Trimmed at
774/// `install_panic_hook` time (single-writer window: we hold the kernel
775/// flock, so no concurrent daemon can race the rotation), and again on
776/// every idle-check tick of a long-running daemon (`cli::daemon`'s
777/// `IDLE_CHECK_INTERVAL_SECS` loop) — a daemon kept alive by continuous
778/// use never restarts, and every `mati serve` proxy it hands off to also
779/// appends `serve_start`/`serve_shutdown` lines, so the startup-only trim
780/// alone does not bound growth. At ~150 bytes per line, 10k lines ≈ 1.5 MB
781/// — enough to retain a year of normal lifecycle events while bounding
782/// growth in pathological respawn loops.
783pub const MAX_LIFECYCLE_LINES: usize = 10_000;
784
785/// Hard ceiling on the byte size of `lifecycle.log` we will read into
786/// memory at startup. The legitimate cap (10k lines × ~150 B ≈ 1.5 MB)
787/// fits comfortably inside this; the ceiling exists only to prevent
788/// startup OOM if an external process or buggy actor wrote pathological
789/// content into the log (e.g. a 4 GB file of garbage). Above this size,
790/// the trim path nukes the file rather than reading it. Lifecycle events
791/// are best-effort observability — losing them on extreme corruption is
792/// strictly preferable to refusing to start the daemon (P9: graceful
793/// degradation, never block Claude on a mati outage).
794const LIFECYCLE_TRIM_MAX_READ_BYTES: u64 = 64 * 1024 * 1024;
795
796/// Best-effort one-time trim of `lifecycle.log` to its last N lines.
797///
798/// Uses tmp+rename for atomic replacement so a crash during rotation
799/// leaves either the old log or the new log on disk, never a partial
800/// truncation. Errors are silently ignored — log rotation must never
801/// block startup.
802///
803/// Hard size guard: if the on-disk file exceeds
804/// `LIFECYCLE_TRIM_MAX_READ_BYTES`, the file is truncated to empty
805/// without being read. This protects startup from OOM on a pathological
806/// log (P9). The legitimate cap is ~1.5 MB so the threshold is not hit
807/// under any normal operation.
808///
809/// Safe to call from a steady-state daemon, not only at startup: the
810/// tmp+rename swap changes `lifecycle.log`'s inode, which would drop
811/// events out from under a concurrent `wait_for_ready` tail if one were
812/// running — but a tail only exists during the narrow spawn window, and a
813/// daemon calling this from its own idle-check loop is by definition long
814/// past that window (its own readiness event already fired, and any peer
815/// sentinel a fresh spawn would look for has long since expired).
816pub fn trim_lifecycle_log(root: &Path, max_lines: usize) {
817    let path = root.join(LIFECYCLE_FILENAME);
818
819    // Size guard: refuse to read pathological files into memory. Truncate
820    // to empty and continue. Best-effort — if `metadata` or `write` fails,
821    // we just return; startup must not block on log rotation.
822    if let Ok(meta) = std::fs::metadata(&path) {
823        if meta.is_file() && meta.len() > LIFECYCLE_TRIM_MAX_READ_BYTES {
824            let _ = std::fs::write(&path, b"");
825            return;
826        }
827    }
828
829    let content = match std::fs::read_to_string(&path) {
830        Ok(c) => c,
831        Err(_) => return, // log doesn't exist yet, or can't be read
832    };
833    // `lines()` does not yield trailing empty line, so length == event count.
834    let line_count = content.lines().count();
835    if line_count <= max_lines {
836        return;
837    }
838    let skip = line_count - max_lines;
839    let kept: String = content.lines().skip(skip).flat_map(|l| [l, "\n"]).collect();
840    // Atomic replace.
841    let tmp = path.with_extension("log.tmp");
842    if std::fs::write(&tmp, kept).is_err() {
843        return;
844    }
845    let _ = std::fs::rename(&tmp, &path);
846}
847
848/// Hard cap on a single lifecycle.log line, in bytes.
849///
850/// POSIX guarantees that `write(2)` calls of size ≤ `PIPE_BUF` (4096 bytes
851/// on Linux, ≥512 on every conformant system) on a file opened with
852/// `O_APPEND` are atomic with respect to other writers. Above that, two
853/// concurrent appenders can interleave bytes mid-line, producing torn
854/// records that confuse `lines()` consumers and the trim path.
855///
856/// Multiple processes can write here simultaneously: any running daemon
857/// instance, the panic hook firing in a background thread, sibling-process
858/// startup logging during stale cleanup. A pathological panic payload
859/// (large `Debug`-formatted struct, JSON dump of a serde error) can easily
860/// exceed 4 KB and tear the log.
861///
862/// 3900 bytes leaves headroom for the `{ts}\t{pid}\t{event}\t` prefix (well
863/// under 100 bytes in practice) plus the trailing `\n`, while staying
864/// safely below PIPE_BUF.
865const LIFECYCLE_MAX_LINE_BYTES: usize = 3900;
866
867/// Append a single event to `~/.mati/<slug>/lifecycle.log`.
868///
869/// Format: `unix_ts<TAB>pid<TAB>event<TAB>detail<NL>`. Newlines and tabs in
870/// `detail` are replaced with spaces so each event remains exactly one line.
871/// Lines exceeding `LIFECYCLE_MAX_LINE_BYTES` are truncated at a UTF-8 char
872/// boundary so concurrent appenders never produce torn records.
873///
874/// Best-effort — every failure path is silenced. Lifecycle logging must
875/// never block startup, shutdown, or panic paths.
876pub fn record_lifecycle_event(root: &Path, event: &str, detail: &str) {
877    use std::io::Write;
878    let path = root.join(LIFECYCLE_FILENAME);
879    let ts = std::time::SystemTime::now()
880        .duration_since(std::time::UNIX_EPOCH)
881        .unwrap_or_default()
882        .as_secs();
883    let pid = std::process::id();
884    let safe_detail: String = detail
885        .chars()
886        .map(|c| match c {
887            '\t' | '\n' | '\r' => ' ',
888            c => c,
889        })
890        .collect();
891    let mut line = format!("{ts}\t{pid}\t{event}\t{safe_detail}\n");
892    if line.len() > LIFECYCLE_MAX_LINE_BYTES {
893        // Reserve one byte for the trailing '\n' we re-add below. Walk back
894        // to the nearest UTF-8 char boundary so we never split a multibyte
895        // character — a torn UTF-8 sequence would corrupt `read_to_string`
896        // consumers. UTF-8 chars are ≤4 bytes, so this loop runs at most
897        // 3 iterations. Equivalent to `floor_char_boundary` (stable in
898        // 1.91) but works on the project's MSRV (1.82).
899        let mut cut = LIFECYCLE_MAX_LINE_BYTES - 1;
900        while cut > 0 && !line.is_char_boundary(cut) {
901            cut -= 1;
902        }
903        line.truncate(cut);
904        line.push('\n');
905    }
906    // Use the pre-opened fd when it matches this exact log path — avoids
907    // open(2) in the panic hook where VFS stalls are possible under memory
908    // pressure on macOS. Fall back to open-by-path for any other root
909    // (including test callers with arbitrary temp dirs).
910    //
911    // No mutex is needed: `O_APPEND` + line ≤ PIPE_BUF makes `write(2)`
912    // atomic at the kernel level, so concurrent emitters can share the fd
913    // without user-space locking. `<&File as Write>::write_all` lets us emit
914    // through a shared reference.
915    let used_preopen = if let Some(pre) = LIFECYCLE_LOG_FILE.get() {
916        if pre.path == path {
917            let _ = (&pre.file).write_all(line.as_bytes());
918            true
919        } else {
920            false
921        }
922    } else {
923        false
924    };
925
926    if !used_preopen {
927        if let Ok(mut f) = std::fs::OpenOptions::new()
928            .create(true)
929            .append(true)
930            .open(&path)
931        {
932            let _ = f.write_all(line.as_bytes());
933        }
934    }
935}
936
937// ── Panic hook ──────────────────────────────────────────────────────────────
938
939/// Cached daemon root used by the panic hook to clean up sock + pid files.
940/// Set by [`install_panic_hook`]; never overwritten.
941static PANIC_HOOK_ROOT: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
942
943/// Pre-opened lifecycle log file handle, paired with its canonical path
944/// and a pre-formatted pid prefix.
945///
946/// Opened at `install_panic_hook` time so the panic hook can call `write(2)`
947/// directly instead of `open(2)`. On macOS under memory pressure, `open(2)`
948/// can stall waiting for VFS resources; a pre-opened fd avoids that window.
949///
950/// `record_lifecycle_event` uses this handle only when the requested path
951/// matches, so test callers with arbitrary temp dirs always open by path.
952///
953/// **No `Mutex` around the file.** The fd is opened with `O_APPEND` and every
954/// emitted line is capped below `PIPE_BUF` (`LIFECYCLE_MAX_LINE_BYTES = 3900`),
955/// so the kernel guarantees `write(2)` calls are atomic w.r.t. concurrent
956/// appenders — both intra-process and cross-process. We use
957/// `<&File as std::io::Write>::write_all` to emit through a shared reference.
958/// Dropping the user-space mutex also removes a deadlock hazard on the panic
959/// path (a thread holding the mutex while panicking would self-deadlock when
960/// the hook tried to relock it).
961///
962/// `pid_prefix` is the bytes of `"<pid>\t"` formatted once at install time so
963/// the no-alloc panic path can copy it into a stack buffer without calling
964/// `format!`.
965struct PreOpenedLog {
966    path: std::path::PathBuf,
967    file: std::fs::File,
968    pid_prefix: Vec<u8>,
969}
970
971static LIFECYCLE_LOG_FILE: std::sync::OnceLock<PreOpenedLog> = std::sync::OnceLock::new();
972
973/// Test/diagnostic helper: returns `true` if `install_panic_hook` has run and
974/// successfully pre-opened the lifecycle log fd. Integration tests use this
975/// to assert the panic hook is wired up; it is `#[doc(hidden)]` to discourage
976/// production callers from depending on the pre-open state.
977#[doc(hidden)]
978pub fn is_lifecycle_log_preopened() -> bool {
979    LIFECYCLE_LOG_FILE.get().is_some()
980}
981
982// ── No-alloc panic write path ───────────────────────────────────────────────
983//
984// The panic hook may run with a corrupted allocator (e.g., panic-on-OOM,
985// allocator state poisoned by the bug being reported). Heap allocations on
986// the panic path can hang or abort the runtime before the lifecycle event is
987// recorded. The functions below let the hook emit a lifecycle line with zero
988// heap allocations: timestamp formatted into a stack buffer via
989// `u64_to_decimal_bytes`, pid pre-formatted at install time, detail strings
990// sanitized in place, and the line written directly through the pre-opened
991// fd via `<&File as Write>::write_all`.
992//
993// This is best-effort. If `LIFECYCLE_LOG_FILE` is unset (install_panic_hook
994// never ran, or the open(2) at install time failed), the no-alloc writer
995// returns false and the caller falls back to the heap path.
996
997/// Format `n` as decimal ASCII into the start of `out`, returning the number
998/// of bytes written. Stack-only — never allocates. `out` must be ≥ 20 bytes
999/// (u64 max = `18_446_744_073_709_551_615` is 20 digits).
1000fn u64_to_decimal_bytes(mut n: u64, out: &mut [u8]) -> usize {
1001    if n == 0 {
1002        if out.is_empty() {
1003            return 0;
1004        }
1005        out[0] = b'0';
1006        return 1;
1007    }
1008    // Write digits backwards into a tmp stack buffer, then reverse-copy.
1009    let mut tmp = [0u8; 20];
1010    let mut len = 0;
1011    while n > 0 && len < tmp.len() {
1012        tmp[len] = b'0' + (n % 10) as u8;
1013        n /= 10;
1014        len += 1;
1015    }
1016    let take = len.min(out.len());
1017    for i in 0..take {
1018        out[i] = tmp[len - 1 - i];
1019    }
1020    take
1021}
1022
1023/// Build a lifecycle log line into `out` with no heap allocations. Returns
1024/// the number of bytes written (always ≤ `LIFECYCLE_MAX_LINE_BYTES`).
1025///
1026/// Mirrors the heap path's format: `{ts}\t{pid}\t{event}\t{detail}\n`, where
1027/// `detail` is `detail_parts` joined by single spaces. Bytes from
1028/// `detail_parts` matching `\t \n \r` are replaced with space (same
1029/// sanitization as the heap path's `safe_detail`).
1030///
1031/// Truncation rules match the heap path: fill the buffer up to
1032/// `LIFECYCLE_MAX_LINE_BYTES - 1`, walk back to the most recent UTF-8 char
1033/// boundary if a truncation would split a multibyte character, then append
1034/// the trailing `\n`. Each `&str` part is itself valid UTF-8, so we use
1035/// `str::is_char_boundary` per-part rather than scanning the whole buffer.
1036fn write_lifecycle_line(
1037    out: &mut [u8; LIFECYCLE_MAX_LINE_BYTES],
1038    ts: u64,
1039    pid_prefix: &[u8],
1040    event: &str,
1041    detail_parts: &[&str],
1042) -> usize {
1043    // Reserve the final byte for the trailing newline.
1044    let cap = LIFECYCLE_MAX_LINE_BYTES - 1;
1045    let mut pos: usize = 0;
1046
1047    // Copy raw bytes (no sanitization) up to `cap`.
1048    fn push_raw(out: &mut [u8], pos: &mut usize, src: &[u8], cap: usize) {
1049        let remaining = cap.saturating_sub(*pos);
1050        let n = src.len().min(remaining);
1051        out[*pos..*pos + n].copy_from_slice(&src[..n]);
1052        *pos += n;
1053    }
1054
1055    // ts (decimal ASCII, stack-only).
1056    let mut ts_buf = [0u8; 20];
1057    let ts_len = u64_to_decimal_bytes(ts, &mut ts_buf);
1058    push_raw(out, &mut pos, &ts_buf[..ts_len], cap);
1059    push_raw(out, &mut pos, b"\t", cap);
1060
1061    // pid prefix (already includes trailing tab).
1062    push_raw(out, &mut pos, pid_prefix, cap);
1063
1064    // event tag — never sanitized (matches heap path, where the format-string
1065    // separators are real \t and only `safe_detail` is mapped).
1066    push_raw(out, &mut pos, event.as_bytes(), cap);
1067    push_raw(out, &mut pos, b"\t", cap);
1068
1069    // detail_parts joined by single space, sanitized byte-by-byte. We
1070    // sanitize per-part because (a) the join separator is already a space
1071    // and (b) `\t \n \r` are 1-byte ASCII so a byte-level swap preserves
1072    // UTF-8 validity.
1073    for (i, part) in detail_parts.iter().enumerate() {
1074        if i > 0 {
1075            push_raw(out, &mut pos, b" ", cap);
1076        }
1077        let bytes = part.as_bytes();
1078        let remaining = cap.saturating_sub(pos);
1079        let mut take = bytes.len().min(remaining);
1080        // If we'd split a multibyte char, walk back to the previous boundary.
1081        // `bytes` is the byte view of a `&str`, so we can use the str API.
1082        if take < bytes.len() {
1083            while take > 0 && !part.is_char_boundary(take) {
1084                take -= 1;
1085            }
1086        }
1087        for j in 0..take {
1088            out[pos + j] = match bytes[j] {
1089                b'\t' | b'\n' | b'\r' => b' ',
1090                b => b,
1091            };
1092        }
1093        pos += take;
1094    }
1095
1096    // Trailing newline — always fits because `cap = LIFECYCLE_MAX_LINE_BYTES - 1`.
1097    out[pos] = b'\n';
1098    pos + 1
1099}
1100
1101/// No-alloc lifecycle writer used by the panic hook. Returns `false` if the
1102/// pre-opened fd is unavailable, or if the requested `root` does not match
1103/// the root the panic hook was installed for, so the caller can fall back
1104/// to the heap path.
1105///
1106/// Allocation budget: zero. The line is built into a `[u8; LIFECYCLE_MAX_LINE_BYTES]`
1107/// stack buffer; emission is `(&File).write_all(...)`, a single `write(2)`
1108/// for the small (< PIPE_BUF) line. The path-equality gate uses
1109/// `Path::parent()` (returns `&Path`, no heap) and `PartialEq` on `Path`
1110/// (component iteration, no heap), mirroring the heap writer's `pre.path == path`
1111/// check without the `root.join(LIFECYCLE_FILENAME)` allocation.
1112fn record_lifecycle_event_no_alloc(root: &Path, event: &str, detail_parts: &[&str]) -> bool {
1113    use std::io::Write;
1114    let Some(pre) = LIFECYCLE_LOG_FILE.get() else {
1115        return false;
1116    };
1117    // Discriminate by root so test/dev callers with arbitrary temp dirs
1118    // route through the heap fallback. `pre.path` was constructed as
1119    // `root.join(LIFECYCLE_FILENAME)`, so its parent is exactly the root
1120    // that was registered at install time.
1121    if pre.path.parent() != Some(root) {
1122        return false;
1123    }
1124    let ts = std::time::SystemTime::now()
1125        .duration_since(std::time::UNIX_EPOCH)
1126        .unwrap_or_default()
1127        .as_secs();
1128    let mut buf = [0u8; LIFECYCLE_MAX_LINE_BYTES];
1129    let n = write_lifecycle_line(&mut buf, ts, &pre.pid_prefix, event, detail_parts);
1130    (&pre.file).write_all(&buf[..n]).is_ok()
1131}
1132
1133/// Idempotent cleanup the panic hook performs on every panic.
1134///
1135/// Removes daemon sock + pid files (kernel auto-releases the SurrealKV flock,
1136/// so file unlink is enough for sibling-process recovery) and appends a
1137/// `panic` lifecycle event with location + payload. Best-effort throughout:
1138/// every fs operation swallows its error so the panic still surfaces.
1139///
1140/// **Lifecycle event is written via the no-alloc path when possible** — the
1141/// hook may run with a corrupted allocator, so we avoid `format!` /
1142/// `PathBuf::join` / `chars().collect()` on the panic path. If the pre-opened
1143/// fd is unavailable (install_panic_hook never ran or its open(2) failed),
1144/// we fall back to the heap path so the event still lands on disk.
1145///
1146/// Crate-internal: the only callers are this module's `install_panic_hook`
1147/// and its `#[cfg(test)]` block. Same-module tests have access to private
1148/// items, so this does not need to be `pub` for testability.
1149pub(crate) fn run_panic_cleanup(root: &Path, location: &str, payload: &str) {
1150    let _ = std::fs::remove_file(socket_path(root));
1151    let _ = std::fs::remove_file(metadata_path(root));
1152    if !record_lifecycle_event_no_alloc(root, "panic", &[location, payload]) {
1153        record_lifecycle_event(root, "panic", &format!("{location} {payload}"));
1154    }
1155}
1156
1157/// Install a global panic hook that runs `run_panic_cleanup` before
1158/// delegating to the default hook.
1159///
1160/// Idempotent — only the first call's `root` is honored (subsequent calls are
1161/// no-ops). Safe to call from any startup path.
1162///
1163/// The hook runs on the panicking thread before unwinding, so it fires for
1164/// every panic in every tokio worker (tokio's spawn-boundary `catch_unwind`
1165/// invokes the hook before catching).
1166pub fn install_panic_hook(root: std::path::PathBuf) {
1167    if PANIC_HOOK_ROOT.set(root.clone()).is_err() {
1168        return;
1169    }
1170    // One-time lifecycle.log rotation. Single-writer window: we just
1171    // acquired the kernel flock to start serving, so no concurrent daemon
1172    // is rotating in parallel.
1173    trim_lifecycle_log(&root, MAX_LIFECYCLE_LINES);
1174
1175    // Pre-open the lifecycle log so the panic hook only calls write(2), not
1176    // open(2). On macOS under memory pressure, open(2) can stall in the VFS
1177    // layer; holding the fd from startup removes that stall from the panic path.
1178    //
1179    // Also pre-format the "<pid>\t" prefix bytes here so the no-alloc panic
1180    // writer can copy them into a stack buffer without calling `format!`.
1181    // pid is process-global and stable, so caching it once is sound.
1182    let log_path = root.join(LIFECYCLE_FILENAME);
1183    if let Ok(f) = std::fs::OpenOptions::new()
1184        .create(true)
1185        .append(true)
1186        .open(&log_path)
1187    {
1188        let pid = std::process::id();
1189        let mut pid_buf = [0u8; 20];
1190        let pid_len = u64_to_decimal_bytes(pid as u64, &mut pid_buf);
1191        let mut pid_prefix = Vec::with_capacity(pid_len + 1);
1192        pid_prefix.extend_from_slice(&pid_buf[..pid_len]);
1193        pid_prefix.push(b'\t');
1194        let _ = LIFECYCLE_LOG_FILE.set(PreOpenedLog {
1195            path: log_path,
1196            file: f,
1197            pid_prefix,
1198        });
1199    }
1200
1201    let default_hook = std::panic::take_hook();
1202    std::panic::set_hook(Box::new(move |info| {
1203        if let Some(root) = PANIC_HOOK_ROOT.get() {
1204            let location = info
1205                .location()
1206                .map(|l| format!("{}:{}", l.file(), l.line()))
1207                .unwrap_or_else(|| "<unknown>".to_string());
1208            let payload = info
1209                .payload()
1210                .downcast_ref::<&str>()
1211                .copied()
1212                .or_else(|| info.payload().downcast_ref::<String>().map(String::as_str))
1213                .unwrap_or("<non-string panic>");
1214            run_panic_cleanup(root, &location, payload);
1215        }
1216        default_hook(info);
1217    }));
1218}
1219
1220// ── Tests ───────────────────────────────────────────────────────────────────
1221
1222#[cfg(test)]
1223mod tests {
1224    use super::*;
1225
1226    #[test]
1227    fn metadata_roundtrip() {
1228        let meta = DaemonMetadata::new(DaemonOwner::Daemon);
1229        let json = serde_json::to_string(&meta).unwrap();
1230        let back: DaemonMetadata = serde_json::from_str(&json).unwrap();
1231        assert_eq!(back.pid, meta.pid);
1232        assert_eq!(back.session, meta.session);
1233        assert_eq!(back.owner, DaemonOwner::Daemon);
1234    }
1235
1236    #[test]
1237    fn metadata_mcp_owner_roundtrip() {
1238        let meta = DaemonMetadata {
1239            pid: 42,
1240            session: Uuid::new_v4(),
1241            owner: DaemonOwner::Mcp,
1242            version: String::new(),
1243        };
1244        let json = serde_json::to_string(&meta).unwrap();
1245        let back: DaemonMetadata = serde_json::from_str(&json).unwrap();
1246        assert_eq!(back.owner, DaemonOwner::Mcp);
1247    }
1248
1249    #[test]
1250    fn read_metadata_v2_format() {
1251        let dir = tempfile::tempdir().unwrap();
1252        let session = Uuid::new_v4();
1253        let meta = DaemonMetadata {
1254            pid: 1234,
1255            session,
1256            owner: DaemonOwner::Daemon,
1257            version: String::new(),
1258        };
1259        publish_metadata(dir.path(), &meta).unwrap();
1260
1261        let read = read_metadata(dir.path()).unwrap();
1262        assert_eq!(read.pid, 1234);
1263        assert_eq!(read.session, session);
1264        assert_eq!(read.owner, DaemonOwner::Daemon);
1265    }
1266
1267    #[test]
1268    fn read_metadata_legacy_v1_json() {
1269        let dir = tempfile::tempdir().unwrap();
1270        std::fs::write(dir.path().join("mati.pid"), r#"{"pid":5678,"owner":"mcp"}"#).unwrap();
1271
1272        let read = read_metadata(dir.path()).unwrap();
1273        assert_eq!(read.pid, 5678);
1274        assert_eq!(read.owner, DaemonOwner::Mcp);
1275        // Legacy format has no session — should get nil UUID.
1276        assert!(read.session.is_nil());
1277    }
1278
1279    #[test]
1280    fn read_metadata_legacy_plain_pid() {
1281        let dir = tempfile::tempdir().unwrap();
1282        std::fs::write(dir.path().join("mati.pid"), "9999\n").unwrap();
1283
1284        let read = read_metadata(dir.path()).unwrap();
1285        assert_eq!(read.pid, 9999);
1286        assert_eq!(read.owner, DaemonOwner::Daemon);
1287        assert!(read.session.is_nil());
1288    }
1289
1290    #[test]
1291    fn read_metadata_missing_returns_none() {
1292        let dir = tempfile::tempdir().unwrap();
1293        assert!(read_metadata(dir.path()).is_none());
1294    }
1295
1296    #[test]
1297    fn read_metadata_corrupt_returns_none() {
1298        let dir = tempfile::tempdir().unwrap();
1299        std::fs::write(dir.path().join("mati.pid"), "not json at all ~~~").unwrap();
1300        assert!(read_metadata(dir.path()).is_none());
1301    }
1302
1303    #[cfg(unix)]
1304    #[test]
1305    fn publish_metadata_sets_mode_0600() {
1306        use std::os::unix::fs::PermissionsExt;
1307        let dir = tempfile::tempdir().unwrap();
1308        let meta = DaemonMetadata::new(DaemonOwner::Daemon);
1309        publish_metadata(dir.path(), &meta).unwrap();
1310
1311        let perms = std::fs::metadata(dir.path().join("mati.pid"))
1312            .unwrap()
1313            .permissions();
1314        assert_eq!(
1315            perms.mode() & 0o777,
1316            0o600,
1317            "metadata file should be mode 0600"
1318        );
1319    }
1320
1321    #[cfg(unix)]
1322    #[test]
1323    fn publish_metadata_is_atomic() {
1324        let dir = tempfile::tempdir().unwrap();
1325
1326        // Write initial metadata.
1327        let meta1 = DaemonMetadata {
1328            pid: 1,
1329            session: Uuid::new_v4(),
1330            owner: DaemonOwner::Daemon,
1331            version: String::new(),
1332        };
1333        publish_metadata(dir.path(), &meta1).unwrap();
1334
1335        // Overwrite atomically.
1336        let meta2 = DaemonMetadata {
1337            pid: 2,
1338            session: Uuid::new_v4(),
1339            owner: DaemonOwner::Mcp,
1340            version: String::new(),
1341        };
1342        publish_metadata(dir.path(), &meta2).unwrap();
1343
1344        // Read should see meta2, not a partial mix.
1345        let read = read_metadata(dir.path()).unwrap();
1346        assert_eq!(read.pid, 2);
1347        assert_eq!(read.owner, DaemonOwner::Mcp);
1348
1349        // Temp file should not be left behind.
1350        assert!(!dir.path().join("mati.pid.tmp").exists());
1351    }
1352
1353    #[cfg(unix)]
1354    #[test]
1355    fn ensure_runtime_dir_sets_mode_0700() {
1356        use std::os::unix::fs::PermissionsExt;
1357        let dir = tempfile::tempdir().unwrap();
1358        let root = dir.path().join("test_root");
1359
1360        ensure_runtime_dir(&root).unwrap();
1361
1362        let perms = std::fs::metadata(&root).unwrap().permissions();
1363        assert_eq!(
1364            perms.mode() & 0o777,
1365            0o700,
1366            "runtime dir should be mode 0700"
1367        );
1368    }
1369
1370    #[test]
1371    fn is_pid_alive_for_current_process() {
1372        assert!(is_pid_alive(std::process::id()));
1373    }
1374
1375    #[test]
1376    fn is_pid_alive_for_dead_pid() {
1377        assert!(!is_pid_alive(4_000_000));
1378    }
1379
1380    #[test]
1381    fn stale_check_clean_when_no_files() {
1382        let dir = tempfile::tempdir().unwrap();
1383        assert_eq!(check_and_cleanup_stale(dir.path()), StaleCheckResult::Clean);
1384    }
1385
1386    #[test]
1387    fn stale_check_removes_dead_pid() {
1388        let dir = tempfile::tempdir().unwrap();
1389        let meta = DaemonMetadata {
1390            pid: 4_000_000, // almost certainly dead
1391            session: Uuid::new_v4(),
1392            owner: DaemonOwner::Daemon,
1393            version: String::new(),
1394        };
1395        publish_metadata(dir.path(), &meta).unwrap();
1396        std::fs::write(dir.path().join("mati.sock"), "").unwrap();
1397
1398        let result = check_and_cleanup_stale(dir.path());
1399        assert_eq!(result, StaleCheckResult::StaleRemoved);
1400        assert!(!dir.path().join("mati.pid").exists());
1401        assert!(!dir.path().join("mati.sock").exists());
1402    }
1403
1404    #[test]
1405    fn stale_check_live_daemon_detected() {
1406        let dir = tempfile::tempdir().unwrap();
1407        let meta = DaemonMetadata {
1408            pid: std::process::id(), // our own PID — alive
1409            session: Uuid::new_v4(),
1410            owner: DaemonOwner::Daemon,
1411            version: String::new(),
1412        };
1413        publish_metadata(dir.path(), &meta).unwrap();
1414
1415        match check_and_cleanup_stale(dir.path()) {
1416            StaleCheckResult::LiveDaemon { pid, .. } => {
1417                assert_eq!(pid, std::process::id());
1418            }
1419            other => panic!("expected LiveDaemon, got {:?}", other),
1420        }
1421    }
1422
1423    #[test]
1424    fn stale_check_orphan_socket() {
1425        let dir = tempfile::tempdir().unwrap();
1426        // Socket exists but no metadata file.
1427        std::fs::write(dir.path().join("mati.sock"), "").unwrap();
1428
1429        assert_eq!(
1430            check_and_cleanup_stale(dir.path()),
1431            StaleCheckResult::OrphanSocket
1432        );
1433    }
1434
1435    #[test]
1436    fn stale_check_corrupt_metadata_cleaned_up() {
1437        let dir = tempfile::tempdir().unwrap();
1438        std::fs::write(dir.path().join("mati.pid"), "garbage!!!").unwrap();
1439        std::fs::write(dir.path().join("mati.sock"), "").unwrap();
1440
1441        let result = check_and_cleanup_stale(dir.path());
1442        assert_eq!(result, StaleCheckResult::StaleRemoved);
1443        assert!(!dir.path().join("mati.pid").exists());
1444        assert!(!dir.path().join("mati.sock").exists());
1445    }
1446
1447    // ── Peer credential tests ───────────────────────────────────────────
1448
1449    /// Test peer credential check with a real Unix socket pair.
1450    /// Both endpoints run as the same user (test process), so the UID matches.
1451    #[cfg(unix)]
1452    #[tokio::test]
1453    async fn peer_cred_accepts_same_uid() {
1454        let dir = tempfile::tempdir().unwrap();
1455        let sock_path = dir.path().join("test.sock");
1456
1457        let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
1458        let connect_fut = tokio::net::UnixStream::connect(&sock_path);
1459        let accept_fut = listener.accept();
1460
1461        let (client_result, accept_result) = tokio::join!(connect_fut, accept_fut);
1462        let _client = client_result.unwrap();
1463        let (server_stream, _) = accept_result.unwrap();
1464
1465        let daemon_euid = current_euid();
1466        let peer = check_peer_cred(&server_stream, daemon_euid);
1467        assert!(
1468            peer.is_some(),
1469            "same-user connection should pass peer check"
1470        );
1471
1472        let ctx = peer.unwrap();
1473        assert_eq!(ctx.uid, daemon_euid);
1474        // PID should be available on macOS and Linux.
1475        assert!(ctx.pid.is_some(), "peer PID should be available");
1476    }
1477
1478    /// Test that a UID mismatch is correctly rejected.
1479    /// We simulate this by passing a fake daemon_euid that doesn't match.
1480    #[cfg(unix)]
1481    #[tokio::test]
1482    async fn peer_cred_rejects_uid_mismatch() {
1483        let dir = tempfile::tempdir().unwrap();
1484        let sock_path = dir.path().join("test_mismatch.sock");
1485
1486        let listener = tokio::net::UnixListener::bind(&sock_path).unwrap();
1487        let connect_fut = tokio::net::UnixStream::connect(&sock_path);
1488        let accept_fut = listener.accept();
1489
1490        let (client_result, accept_result) = tokio::join!(connect_fut, accept_fut);
1491        let _client = client_result.unwrap();
1492        let (server_stream, _) = accept_result.unwrap();
1493
1494        // Use a fake daemon_euid that won't match the test process.
1495        let fake_euid = current_euid().wrapping_add(1);
1496        let peer = check_peer_cred(&server_stream, fake_euid);
1497        assert!(peer.is_none(), "mismatched UID should be rejected");
1498    }
1499
1500    #[test]
1501    fn lifecycle_log_appends_one_line_per_event() {
1502        let dir = tempfile::tempdir().unwrap();
1503        record_lifecycle_event(dir.path(), "start", "owner=mcp");
1504        record_lifecycle_event(dir.path(), "shutdown", "reason=signal");
1505        let contents = std::fs::read_to_string(dir.path().join("lifecycle.log")).unwrap();
1506        let lines: Vec<&str> = contents.lines().collect();
1507        assert_eq!(lines.len(), 2, "exactly two events recorded");
1508        for line in &lines {
1509            // ts<TAB>pid<TAB>event<TAB>detail
1510            let cols: Vec<&str> = line.split('\t').collect();
1511            assert_eq!(cols.len(), 4, "each line has 4 tab-separated fields");
1512            // ts and pid must be valid integers.
1513            assert!(cols[0].parse::<u64>().is_ok());
1514            assert!(cols[1].parse::<u32>().is_ok());
1515        }
1516        assert!(lines[0].contains("\tstart\towner=mcp"));
1517        assert!(lines[1].contains("\tshutdown\treason=signal"));
1518    }
1519
1520    #[test]
1521    fn lifecycle_log_strips_newlines_and_tabs_in_detail() {
1522        let dir = tempfile::tempdir().unwrap();
1523        record_lifecycle_event(dir.path(), "panic", "line1\nline2\twith tab\rcr");
1524        let contents = std::fs::read_to_string(dir.path().join("lifecycle.log")).unwrap();
1525        // Exactly one newline (the trailing one) — so exactly one logical line.
1526        assert_eq!(contents.matches('\n').count(), 1);
1527        assert!(contents.contains("line1 line2 with tab cr"));
1528    }
1529
1530    #[test]
1531    fn lifecycle_log_silently_succeeds_when_dir_missing() {
1532        // Should not panic when target directory does not exist — best-effort.
1533        let dir = tempfile::tempdir().unwrap();
1534        let bogus = dir.path().join("nonexistent-subdir");
1535        record_lifecycle_event(&bogus, "start", "x");
1536        assert!(!bogus.join("lifecycle.log").exists());
1537    }
1538
1539    /// Concurrent appenders interleave bytes mid-line above PIPE_BUF. A
1540    /// pathological panic payload (large Debug-formatted struct, JSON dump
1541    /// from a serde error) can easily exceed 4 KB. We cap the on-disk line
1542    /// well below PIPE_BUF so POSIX append atomicity holds. The line still
1543    /// ends with `\n` so `lines()` consumers and the trim path see a clean
1544    /// record, and the truncation point sits on a UTF-8 char boundary so a
1545    /// multibyte character is never split mid-encoding.
1546    #[test]
1547    fn lifecycle_log_caps_line_below_pipe_buf() {
1548        let dir = tempfile::tempdir().unwrap();
1549        // 10 KB of `é` (2-byte UTF-8) — exercises both the size cap AND the
1550        // char-boundary requirement. A naive byte-truncate would land mid-
1551        // multibyte and produce invalid UTF-8 on disk.
1552        let huge_detail: String = "é".repeat(5_000); // 10_000 bytes
1553        record_lifecycle_event(dir.path(), "panic", &huge_detail);
1554
1555        let log = std::fs::read_to_string(dir.path().join("lifecycle.log")).unwrap();
1556        assert!(
1557            log.len() <= LIFECYCLE_MAX_LINE_BYTES,
1558            "line on disk ({} bytes) must not exceed cap ({})",
1559            log.len(),
1560            LIFECYCLE_MAX_LINE_BYTES
1561        );
1562        assert!(
1563            log.ends_with('\n'),
1564            "truncated line must still end with newline so lines() yields one record"
1565        );
1566        assert!(
1567            log.contains("\tpanic\t"),
1568            "event tag must survive truncation (it sits in the prefix)"
1569        );
1570        // `read_to_string` itself would have errored if truncation split a
1571        // UTF-8 char, but assert explicitly so the failure mode is named.
1572        assert!(
1573            log.is_char_boundary(log.len()),
1574            "truncation must land on UTF-8 char boundary"
1575        );
1576    }
1577
1578    #[test]
1579    fn run_panic_cleanup_removes_sock_pid_and_appends_lifecycle_event() {
1580        let dir = tempfile::tempdir().unwrap();
1581        // Pre-create the daemon files the panic hook is supposed to remove.
1582        std::fs::write(dir.path().join("mati.sock"), "").unwrap();
1583        std::fs::write(dir.path().join("mati.pid"), r#"{"pid":42}"#).unwrap();
1584
1585        run_panic_cleanup(dir.path(), "src/example.rs:99", "boom");
1586
1587        // Files removed.
1588        assert!(
1589            !dir.path().join("mati.sock").exists(),
1590            "panic hook must remove mati.sock so sibling daemons can rebind"
1591        );
1592        assert!(
1593            !dir.path().join("mati.pid").exists(),
1594            "panic hook must remove mati.pid so sibling stale-checks see no live daemon"
1595        );
1596        // Lifecycle event recorded with location + payload preserved.
1597        let log = std::fs::read_to_string(dir.path().join("lifecycle.log")).unwrap();
1598        assert!(log.contains("\tpanic\t"), "event tagged 'panic'");
1599        assert!(log.contains("src/example.rs:99"), "location preserved");
1600        assert!(log.contains("boom"), "payload preserved");
1601    }
1602
1603    #[test]
1604    fn run_panic_cleanup_is_safe_when_files_already_absent() {
1605        // The panic hook may run after another path has already cleaned up
1606        // (e.g., explicit shutdown ran first, then a panic during exit).
1607        // Cleanup must be idempotent — no crash, no error.
1608        let dir = tempfile::tempdir().unwrap();
1609        run_panic_cleanup(dir.path(), "src/x.rs:1", "noop");
1610        // Lifecycle log should still be written even when no files needed removal.
1611        assert!(dir.path().join("lifecycle.log").exists());
1612    }
1613
1614    #[test]
1615    fn trim_lifecycle_log_keeps_last_n_lines() {
1616        let dir = tempfile::tempdir().unwrap();
1617        let path = dir.path().join(LIFECYCLE_FILENAME);
1618        // Write 100 events, trim to last 10.
1619        let body: String = (0..100)
1620            .map(|i| format!("{i}\t{i}\tevent{i}\tdetail{i}\n"))
1621            .collect();
1622        std::fs::write(&path, body).unwrap();
1623
1624        trim_lifecycle_log(dir.path(), 10);
1625
1626        let after = std::fs::read_to_string(&path).unwrap();
1627        let lines: Vec<&str> = after.lines().collect();
1628        assert_eq!(lines.len(), 10, "trimmed log should have exactly N lines");
1629        // Kept the last 10: events 90..=99.
1630        assert!(
1631            lines[0].contains("\tevent90\t"),
1632            "first kept line: {}",
1633            lines[0]
1634        );
1635        assert!(
1636            lines[9].contains("\tevent99\t"),
1637            "last kept line: {}",
1638            lines[9]
1639        );
1640        // No leftover .tmp.
1641        assert!(!path.with_extension("log.tmp").exists());
1642    }
1643
1644    #[test]
1645    fn trim_lifecycle_log_noop_when_under_cap() {
1646        let dir = tempfile::tempdir().unwrap();
1647        let path = dir.path().join(LIFECYCLE_FILENAME);
1648        let body = "0\t0\tstart\tdetail\n1\t0\tstop\tclean\n";
1649        std::fs::write(&path, body).unwrap();
1650        let before = std::fs::read(&path).unwrap();
1651
1652        trim_lifecycle_log(dir.path(), 10);
1653
1654        let after = std::fs::read(&path).unwrap();
1655        assert_eq!(before, after, "trim must be a no-op when under cap");
1656    }
1657
1658    /// Regression: pass-21 checkpoint B. If a hostile or buggy actor wrote
1659    /// a multi-gigabyte `lifecycle.log` (or filled the file with binary
1660    /// garbage that happens to be huge), the previous trim path would
1661    /// `read_to_string` the entire file at daemon startup and OOM the
1662    /// process. Startup must never block or OOM on a corrupt log
1663    /// (P9: graceful degradation). The size guard truncates pathological
1664    /// files to empty and continues, sacrificing the (already corrupt)
1665    /// observability in favor of a successful daemon start.
1666    #[test]
1667    fn trim_lifecycle_log_truncates_pathologically_huge_file() {
1668        let dir = tempfile::tempdir().unwrap();
1669        let path = dir.path().join(LIFECYCLE_FILENAME);
1670
1671        // Write a file just over the read-cap. We don't need a real 64 MB
1672        // file to exercise the guard — we sparse-extend the file so the
1673        // metadata len() reads above the threshold without actually
1674        // allocating that much disk. (On the systems mati supports this
1675        // produces a sparse file; on filesystems that don't honor sparse
1676        // writes the test just uses a real 64 MB+1 byte file. Either way
1677        // the assertion holds.)
1678        {
1679            use std::io::{Seek, SeekFrom, Write};
1680            let mut f = std::fs::File::create(&path).unwrap();
1681            // Seek past the threshold so the file's reported length
1682            // exceeds LIFECYCLE_TRIM_MAX_READ_BYTES without writing the
1683            // intervening bytes. set_len would also work but seek+write
1684            // is the most portable form.
1685            f.seek(SeekFrom::Start(LIFECYCLE_TRIM_MAX_READ_BYTES + 1))
1686                .unwrap();
1687            f.write_all(b"x").unwrap();
1688        }
1689        let pre_size = std::fs::metadata(&path).unwrap().len();
1690        assert!(
1691            pre_size > LIFECYCLE_TRIM_MAX_READ_BYTES,
1692            "test setup: file must exceed the read cap"
1693        );
1694
1695        // The trim must not panic, must not OOM, and must reduce the
1696        // file's size to zero (it was truncated as pathological).
1697        trim_lifecycle_log(dir.path(), 10);
1698
1699        let post_meta = std::fs::metadata(&path).unwrap();
1700        assert!(
1701            post_meta.is_file(),
1702            "lifecycle.log should still exist after pathological trim"
1703        );
1704        assert_eq!(
1705            post_meta.len(),
1706            0,
1707            "pathologically large lifecycle.log must be truncated to empty so startup does not OOM"
1708        );
1709        // No leftover .tmp from the truncation path (we don't use tmp+rename here).
1710        assert!(!path.with_extension("log.tmp").exists());
1711    }
1712
1713    /// The size guard must not fire on legitimate (sub-cap) files —
1714    /// regression check that the new ceiling does not break the normal
1715    /// trim path.
1716    #[test]
1717    fn trim_lifecycle_log_size_guard_does_not_fire_under_cap() {
1718        let dir = tempfile::tempdir().unwrap();
1719        let path = dir.path().join(LIFECYCLE_FILENAME);
1720        // 100 events ≈ 2 KB, well under the 64 MB cap.
1721        let body: String = (0..100)
1722            .map(|i| format!("{i}\t{i}\tevent{i}\tdetail{i}\n"))
1723            .collect();
1724        std::fs::write(&path, &body).unwrap();
1725
1726        trim_lifecycle_log(dir.path(), 10);
1727
1728        // Size guard should NOT have nuked the file — normal trim path
1729        // ran instead and kept the last 10 events.
1730        let after = std::fs::read_to_string(&path).unwrap();
1731        let lines: Vec<&str> = after.lines().collect();
1732        assert_eq!(
1733            lines.len(),
1734            10,
1735            "normal trim path must run for sub-cap files"
1736        );
1737        assert!(lines[0].contains("event90"));
1738        assert!(lines[9].contains("event99"));
1739    }
1740
1741    #[test]
1742    fn trim_lifecycle_log_silently_succeeds_on_missing_log() {
1743        let dir = tempfile::tempdir().unwrap();
1744        // No log file yet — must not panic, must not create one.
1745        trim_lifecycle_log(dir.path(), 10);
1746        assert!(!dir.path().join(LIFECYCLE_FILENAME).exists());
1747    }
1748
1749    #[test]
1750    fn install_panic_hook_is_idempotent() {
1751        // Multiple calls must not crash. We can't easily test that the
1752        // FIRST root is honored across subsequent calls (that would
1753        // require process-global state inspection), but the contract is
1754        // "second call is a no-op" — exercised here.
1755        let dir = tempfile::tempdir().unwrap();
1756        install_panic_hook(dir.path().to_path_buf());
1757        install_panic_hook(dir.path().join("a-different-root"));
1758        // No assertion needed — test passes if neither call panics.
1759    }
1760
1761    /// `u64_to_decimal_bytes` must produce the same digits as `format!("{n}")`
1762    /// across boundary cases (zero, single digit, max u64). Any divergence
1763    /// would silently corrupt the panic-path lifecycle entry's timestamp.
1764    #[test]
1765    fn u64_to_decimal_bytes_matches_format() {
1766        for n in [
1767            0u64,
1768            1,
1769            9,
1770            10,
1771            99,
1772            100,
1773            12345,
1774            1_700_000_000,
1775            u64::MAX / 2,
1776            u64::MAX,
1777        ] {
1778            let mut buf = [0u8; 20];
1779            let len = u64_to_decimal_bytes(n, &mut buf);
1780            assert_eq!(
1781                std::str::from_utf8(&buf[..len]).unwrap(),
1782                n.to_string(),
1783                "decimal mismatch for {n}"
1784            );
1785        }
1786    }
1787
1788    /// Parity guard for Fix 3: the no-alloc panic-path formatter
1789    /// (`write_lifecycle_line`) must produce byte-identical output to the
1790    /// heap path's `format!("{ts}\t{pid}\t{event}\t{safe_detail}\n")` for
1791    /// representative inputs. If they ever drift, an external log consumer
1792    /// (`mati doctor`'s `read_lifecycle_tail`, the integration tests' line
1793    /// parsers) will silently see panic-path entries differently from
1794    /// normal-path entries.
1795    #[test]
1796    fn no_alloc_panic_format_matches_heap_format() {
1797        // Fixed inputs so the test is deterministic — the real writer reads
1798        // ts from the wall clock; here we pass it explicitly.
1799        let ts: u64 = 1_700_000_000;
1800        let pid: u32 = 42;
1801        let pid_prefix = format!("{pid}\t");
1802        let event = "panic";
1803
1804        // Helper: reproduce the heap path's full formatting + truncation
1805        // from `record_lifecycle_event` so we can compare bytes.
1806        fn heap_format(ts: u64, pid_prefix: &str, event: &str, detail: &str) -> String {
1807            let safe_detail: String = detail
1808                .chars()
1809                .map(|c| match c {
1810                    '\t' | '\n' | '\r' => ' ',
1811                    c => c,
1812                })
1813                .collect();
1814            let mut line = format!("{ts}\t{pid_prefix}{event}\t{safe_detail}\n");
1815            if line.len() > LIFECYCLE_MAX_LINE_BYTES {
1816                let mut cut = LIFECYCLE_MAX_LINE_BYTES - 1;
1817                while cut > 0 && !line.is_char_boundary(cut) {
1818                    cut -= 1;
1819                }
1820                line.truncate(cut);
1821                line.push('\n');
1822            }
1823            line
1824        }
1825
1826        // Representative case 1: a typical panic with location + payload.
1827        let location = "src/mcp/server.rs:128";
1828        let payload = "boom!";
1829        let detail = format!("{location} {payload}");
1830        let heap = heap_format(ts, &pid_prefix, event, &detail);
1831        let mut buf = [0u8; LIFECYCLE_MAX_LINE_BYTES];
1832        let n = write_lifecycle_line(
1833            &mut buf,
1834            ts,
1835            pid_prefix.as_bytes(),
1836            event,
1837            &[location, payload],
1838        );
1839        assert_eq!(
1840            std::str::from_utf8(&buf[..n]).unwrap(),
1841            heap,
1842            "panic-path format must match heap path for typical input"
1843        );
1844
1845        // Representative case 2: payload contains \t \n \r — sanitization
1846        // must produce identical output through both paths.
1847        let location_2 = "src/x.rs:1";
1848        let payload_2 = "line1\nline2\twith tab\rcr";
1849        let detail_2 = format!("{location_2} {payload_2}");
1850        let heap_2 = heap_format(ts, &pid_prefix, event, &detail_2);
1851        let mut buf_2 = [0u8; LIFECYCLE_MAX_LINE_BYTES];
1852        let n2 = write_lifecycle_line(
1853            &mut buf_2,
1854            ts,
1855            pid_prefix.as_bytes(),
1856            event,
1857            &[location_2, payload_2],
1858        );
1859        assert_eq!(
1860            std::str::from_utf8(&buf_2[..n2]).unwrap(),
1861            heap_2,
1862            "panic-path format must match heap path with embedded control chars"
1863        );
1864
1865        // Representative case 3: empty detail (e.g., a `start` event with no
1866        // detail string). Heap path passes "" as detail; no-alloc passes
1867        // a single empty `&str`.
1868        let heap_3 = heap_format(ts, &pid_prefix, "start", "");
1869        let mut buf_3 = [0u8; LIFECYCLE_MAX_LINE_BYTES];
1870        let n3 = write_lifecycle_line(&mut buf_3, ts, pid_prefix.as_bytes(), "start", &[""]);
1871        assert_eq!(
1872            std::str::from_utf8(&buf_3[..n3]).unwrap(),
1873            heap_3,
1874            "panic-path format must match heap path with empty detail"
1875        );
1876    }
1877
1878    /// `record_lifecycle_event_no_alloc` must return `false` (not panic, not
1879    /// silently succeed) when the requested root does not match the
1880    /// preopened-fd root — that's how `run_panic_cleanup` knows to fall back
1881    /// to the heap path. The `Some` branch with a matching root is covered
1882    /// by `tests/panic_hook_preopen.rs`, which owns its own process.
1883    #[test]
1884    fn record_lifecycle_event_no_alloc_returns_false_for_unknown_root() {
1885        // Use a temp dir that no test would have called install_panic_hook
1886        // on. Whether or not LIFECYCLE_LOG_FILE has been set by a sibling
1887        // test in this binary, this temp dir cannot be the registered root,
1888        // so the path-equality gate must reject it.
1889        let dir = tempfile::tempdir().unwrap();
1890        assert!(!record_lifecycle_event_no_alloc(
1891            dir.path(),
1892            "smoke",
1893            &["from-tests"]
1894        ));
1895    }
1896
1897    #[test]
1898    fn peer_context_pid_is_optional() {
1899        let ctx = PeerContext {
1900            uid: 501,
1901            pid: None,
1902        };
1903        assert!(ctx.pid.is_none());
1904
1905        let ctx2 = PeerContext {
1906            uid: 501,
1907            pid: Some(1234),
1908        };
1909        assert_eq!(ctx2.pid, Some(1234));
1910    }
1911}