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}