Skip to main content

wire/
ensure_up.rs

1//! Background-process bootstrapper for the MCP path.
2//!
3//! Post-pair, an agent shouldn't have to ask the user "start the daemon?" —
4//! the MCP accept/dial tools invoke [`ensure_daemon_running`] + [`ensure_notify_running`]
5//! so push/pull and OS toasts are already armed by the time the agent surfaces
6//! "paired ✓" back to chat.
7//!
8//! ## Idempotency
9//!
10//! Each subcommand writes its pid record to `$WIRE_HOME/state/wire/<name>.pid`
11//! on spawn. The next call reads the record and skips spawning if the pid is
12//! still alive. Stale pid files (process died) are silently overwritten.
13//!
14//! ## Pid-file shape (P0.4, 0.5.11)
15//!
16//! The pid file used to be a raw integer (`12345\n`). Today's debug surfaced
17//! a process running an OLD binary text in memory under a current symlink,
18//! and `wire status` had no way to detect that. The pid file is now a
19//! versioned JSON record:
20//!
21//! ```json
22//! {
23//!   "schema": "wire-daemon-pid-v1",
24//!   "pid": 12345,
25//!   "bin_path": "/usr/local/bin/wire",
26//!   "version": "0.5.11",
27//!   "started_at": "2026-05-16T01:23:45Z",
28//!   "did": "did:wire:paul-mac",
29//!   "relay_url": "https://wireup.net"
30//! }
31//! ```
32//!
33//! The JSON `DaemonPid` form is the only supported on-disk format;
34//! `read_pid_record` reports anything else as `Corrupt`.
35//!
36//! ## Wait-until-alive
37//!
38//! On spawn, we wait briefly for the child to be alive before persisting the
39//! pid file. A concurrent CLI seeing the file pointing at a not-yet-bound
40//! PID is the "daemon reports running but can't accept connections" race
41//! spark flagged in our P0.4 design call.
42//!
43//! ## Detachment (Unix)
44//!
45//! Spawned with stdio nulled. Since `wire mcp` runs without a controlling
46//! TTY (it's a stdio MCP server, not a login shell), the spawned children
47//! inherit no TTY → no SIGHUP arrives when the parent exits, so they
48//! survive a Claude Code restart cycle. PIDs are reaped by init.
49//!
50//! Worst case: a child dies; the next accept/dial call respawns it.
51//! No data is lost (outbox/inbox is on disk, content-addressed dedupe).
52
53use std::path::PathBuf;
54use std::process::{Command, Stdio};
55use std::time::{Duration, Instant};
56
57use anyhow::Result;
58use serde::{Deserialize, Serialize};
59use serde_json::Value;
60
61/// Schema string written into every JSON pid file. Bumped if the pid-file
62/// shape ever changes incompatibly. Readers warn on unknown schema.
63pub const DAEMON_PID_SCHEMA: &str = "wire-daemon-pid-v1";
64
65/// Versioned daemon pid record — the JSON form written by 0.5.11+.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
67pub struct DaemonPid {
68    /// Schema discriminator. Always `wire-daemon-pid-v1` for now.
69    pub schema: String,
70    pub pid: u32,
71    /// Absolute path of the binary that was exec'd. Catches today's exact
72    /// bug: a stale 0.2.4 daemon process kept running under a symlink that
73    /// was repointed at 0.5.10 — `wire --version` says 0.5.10 but the
74    /// running daemon's text in memory is still 0.2.4.
75    pub bin_path: String,
76    /// CARGO_PKG_VERSION captured at spawn. Compared against the CLI's
77    /// own version on every invocation; mismatch = loud warn.
78    pub version: String,
79    /// RFC3339 timestamp of spawn.
80    pub started_at: String,
81    /// Self DID — catches multi-identity contamination (one user, two wire
82    /// identities on same host, daemon launched as wrong one). Cheap
83    /// field, expensive bug.
84    pub did: Option<String>,
85    /// Relay this daemon was bound to at spawn. Catches daemon-bound-to-
86    /// old-relay-after-migration drift.
87    pub relay_url: Option<String>,
88}
89
90/// Result of reading a pid file. JSON (full metadata) is the only
91/// supported on-disk form; anything else is `Corrupt`.
92#[derive(Debug, Clone)]
93pub enum PidRecord {
94    Json(DaemonPid),
95    Missing,
96    Corrupt(String),
97}
98
99impl PidRecord {
100    pub fn pid(&self) -> Option<u32> {
101        match self {
102            PidRecord::Json(d) => Some(d.pid),
103            _ => None,
104        }
105    }
106}
107
108/// Ensure a `wire daemon --interval 5` process is alive. Returns `Ok(true)`
109/// if a fresh process was spawned, `Ok(false)` if one was already running.
110pub fn ensure_daemon_running() -> Result<bool> {
111    ensure_background("daemon", &["daemon", "--interval", "5"])
112}
113
114/// Ensure a `wire notify --interval 2` process is alive (OS toasts on
115/// every new verified inbox event). Returns true if newly spawned.
116pub fn ensure_notify_running() -> Result<bool> {
117    ensure_background("notify", &["notify", "--interval", "2"])
118}
119
120fn pid_file(name: &str) -> Result<PathBuf> {
121    Ok(crate::config::state_dir()?.join(format!("{name}.pid")))
122}
123
124/// Snapshot of daemon liveness state read through ONE consistent
125/// view. Consumed by `wire status`, `wire doctor`'s `daemon` check,
126/// and `daemon_pid_consistency` so all three surfaces agree by
127/// construction — issue #2 root cause was three call sites that
128/// each computed liveness independently and disagreed for 25 min.
129#[derive(Debug, Clone)]
130pub struct DaemonLiveness {
131    /// PID claimed by `daemon.pid` (None if missing/corrupt).
132    pub pidfile_pid: Option<u32>,
133    /// True iff `pidfile_pid` is currently a live process.
134    pub pidfile_alive: bool,
135    /// Every PID matching `pgrep -f "wire daemon"`. Empty if pgrep is
136    /// unavailable (non-Unix systems, missing util) — the consumer
137    /// must not treat empty as "no daemons" without considering this.
138    pub pgrep_pids: Vec<u32>,
139    /// PIDs in `pgrep_pids` that do NOT match `pidfile_pid`. These are
140    /// orphan daemons racing the cursor with the pidfile-recorded one.
141    pub orphan_pids: Vec<u32>,
142    /// Full parsed pidfile record (Json / Missing / Corrupt).
143    pub record: PidRecord,
144}
145
146/// True iff `pid` is currently a live OS process. Delegates to the
147/// platform-aware check (`/proc` on Linux, `kill -0` on other Unix,
148/// `tasklist` on Windows) so callers never disagree across OSes. The old
149/// local `kill -0` path false-negatived on Windows (no `kill`), making
150/// `wire status`/`doctor` report the daemon DOWN while it was alive.
151pub fn pid_is_alive(pid: u32) -> bool {
152    crate::platform::process_alive(pid)
153}
154
155/// Read the daemon pid file + pgrep in one shot, producing a snapshot
156/// every caller can interpret identically. The point of this helper
157/// is that three independent callers used to compute liveness three
158/// different ways (#2): pidfile-pid-alive (cmd_status), pgrep-only
159/// (early check_daemon_health), neither (check_daemon_pid_consistency).
160/// Now all three flow through the same `DaemonLiveness`.
161pub fn daemon_liveness() -> DaemonLiveness {
162    let record = read_pid_record("daemon");
163    let pidfile_pid = record.pid();
164    let pidfile_alive = pidfile_pid.map(pid_is_alive).unwrap_or(false);
165    // Platform-aware cmdline scan (Unix `pgrep`, Windows PowerShell CIM).
166    // Field stays named `pgrep_pids` for callers; on Windows the old direct
167    // `pgrep` shell-out returned empty (no such tool), masking live daemons.
168    let pgrep_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
169    // A2 (v0.13.2): on a multi-session box EVERY session runs its own daemon,
170    // so the old "any `wire daemon` whose pid != my pidfile = orphan" rule
171    // flagged sibling sessions' LEGITIMATE daemons as orphans — `wire doctor`
172    // FAILed on the very multi-agent-per-box setup wire exists for. A true
173    // orphan is a wire daemon owned by NO session: exclude every session's
174    // pidfile pid, not just this session's.
175    let known_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
176        .map(|sessions| {
177            sessions
178                .iter()
179                .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
180                .collect()
181        })
182        .unwrap_or_default();
183    // v0.14.2 (#170 follow-up): also exclude the `wire daemon --all-sessions`
184    // supervisor. It's pgrep-matched by the "wire daemon" cmdline scan but
185    // ISN'T orphaned — it has its own pidfile at `sessions_root/supervisor.pid`
186    // and legitimately owns the orchestration role. Pre-fix the supervisor
187    // showed up under `!! orphan daemon process(es)` on every `wire status`
188    // even though it was the load-bearing process keeping every session
189    // daemon alive — confusing operators into thinking it was stale.
190    let supervisor_pid: Option<u32> = crate::session::sessions_root()
191        .ok()
192        .map(|root| root.join("supervisor.pid"))
193        .filter(|p| p.exists())
194        .and_then(|p| std::fs::read_to_string(p).ok())
195        .and_then(|s| s.trim().parse::<u32>().ok())
196        .filter(|p| pid_is_alive(*p));
197    let orphan_pids: Vec<u32> = pgrep_pids
198        .iter()
199        .filter(|p| {
200            Some(**p) != pidfile_pid
201                && !known_session_pids.contains(*p)
202                && Some(**p) != supervisor_pid
203        })
204        .copied()
205        .collect();
206    DaemonLiveness {
207        pidfile_pid,
208        pidfile_alive,
209        pgrep_pids,
210        orphan_pids,
211        record,
212    }
213}
214
215/// Read a pid file. Only the JSON `DaemonPid` form is supported; any
216/// other content is reported as `Corrupt`. Never panics.
217pub fn read_pid_record(name: &str) -> PidRecord {
218    let path = match pid_file(name) {
219        Ok(p) => p,
220        Err(_) => return PidRecord::Missing,
221    };
222    let body = match std::fs::read_to_string(&path) {
223        Ok(b) => b,
224        Err(_) => return PidRecord::Missing,
225    };
226    let trimmed = body.trim();
227    if trimmed.is_empty() {
228        return PidRecord::Missing;
229    }
230    match serde_json::from_str::<DaemonPid>(trimmed) {
231        Ok(d) => PidRecord::Json(d),
232        Err(e) => PidRecord::Corrupt(format!("JSON parse: {e}")),
233    }
234}
235
236/// Write a JSON pid record. P0.4: replaces the raw-int write.
237fn write_pid_record(name: &str, record: &DaemonPid) -> Result<()> {
238    let path = pid_file(name)?;
239    let body = serde_json::to_vec_pretty(record)?;
240    std::fs::write(&path, body)?;
241    Ok(())
242}
243
244/// Daemon-startup: claim the `daemon.pid` file for THIS process.
245///
246/// A daemon started directly (`wire daemon`, not via `ensure_background`)
247/// must write its own versioned-JSON pidfile so `wire status` / doctor /
248/// the singleton guard can see it. Idempotent: if the pidfile already
249/// records our PID we leave it untouched. (Historically this lived in
250/// `pending_pair::cleanup_on_startup` alongside the now-removed SAS
251/// pending-pair recovery; the pidfile write was never SAS-specific.)
252pub fn write_self_daemon_pid() -> Result<()> {
253    let path = pid_file("daemon")?;
254    let my_pid = std::process::id();
255    if path.exists()
256        && let Ok(s) = std::fs::read_to_string(&path)
257        && let Ok(rec) = serde_json::from_str::<DaemonPid>(s.trim())
258        && rec.pid == my_pid
259    {
260        // We already own this pidfile — nothing to do.
261        return Ok(());
262    }
263    if let Some(parent) = path.parent() {
264        std::fs::create_dir_all(parent).ok();
265    }
266    write_pid_record("daemon", &build_pid_record(my_pid))
267}
268
269/// Schema string written into every JSON last-sync file. Bumped if the
270/// shape ever changes incompatibly. Readers tolerate any schema string +
271/// fall back to "unknown last_sync" when they don't recognize it.
272pub const LAST_SYNC_FILE_SCHEMA: &str = "wire-daemon-last-sync-v1";
273
274/// Versioned record written by `wire daemon` after each successful sync
275/// cycle. Readers (`wire status`, `mcp__wire__wire_status`,
276/// `mcp__wire__wire_send` annotations) inspect it to surface
277/// "is the sync loop alive RIGHT NOW?" — distinct from "is there a
278/// process with `wire daemon` in its cmdline?" (the existing pidfile-
279/// alive check), which can be true while the loop has been wedged for
280/// minutes. v0.14.2 (#162): closes the silent-send class where the MCP
281/// surface reports `status:"queued"` while no one is actually pushing.
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub struct LastSyncRecord {
284    /// Schema discriminator. `wire-daemon-last-sync-v1`.
285    pub schema: String,
286    /// RFC3339 UTC timestamp of the most recently completed cycle.
287    pub ts: String,
288    /// Number of outbox events pushed in this cycle.
289    pub push_n: usize,
290    /// Number of inbox events pulled (verified + written) in this cycle.
291    pub pull_n: usize,
292    /// Number of inbox events rejected by signature/cursor checks.
293    pub rejected_n: usize,
294}
295
296fn last_sync_file() -> Result<PathBuf> {
297    Ok(crate::config::state_dir()?.join("last_sync.json"))
298}
299
300/// Write the last-sync record. Called by `cmd_daemon` after each cycle
301/// (including --once). Best-effort: any error logs to stderr but does NOT
302/// abort the daemon loop — a wedged pidfile path shouldn't take the sync
303/// loop down with it.
304pub fn write_last_sync_record(push_n: usize, pull_n: usize, rejected_n: usize) {
305    let record = LastSyncRecord {
306        schema: LAST_SYNC_FILE_SCHEMA.to_string(),
307        ts: time::OffsetDateTime::now_utc()
308            .format(&time::format_description::well_known::Rfc3339)
309            .unwrap_or_default(),
310        push_n,
311        pull_n,
312        rejected_n,
313    };
314    let _ = (|| -> Result<()> {
315        let path = last_sync_file()?;
316        if let Some(parent) = path.parent() {
317            std::fs::create_dir_all(parent)?;
318        }
319        let body = serde_json::to_vec_pretty(&record)?;
320        std::fs::write(&path, body)?;
321        Ok(())
322    })()
323    .map_err(|e| eprintln!("daemon: last-sync persist error (non-fatal): {e:#}"));
324}
325
326/// Read the last-sync record. Returns `None` if missing/corrupt — every
327/// caller should treat that as "unknown sync state, daemon may never
328/// have run" and surface it accordingly.
329pub fn read_last_sync_record() -> Option<LastSyncRecord> {
330    let path = last_sync_file().ok()?;
331    let body = std::fs::read_to_string(&path).ok()?;
332    serde_json::from_str(&body).ok()
333}
334
335/// Convenience: the wall-clock age (in whole seconds) of the most recent
336/// sync, or `None` if no record exists / the timestamp can't be parsed.
337/// Negative ages (clock skew between daemon + reader) are clamped to 0.
338pub fn last_sync_age_seconds() -> Option<u64> {
339    let rec = read_last_sync_record()?;
340    let parsed =
341        time::OffsetDateTime::parse(&rec.ts, &time::format_description::well_known::Rfc3339)
342            .ok()?;
343    let delta = time::OffsetDateTime::now_utc() - parsed;
344    let secs = delta.whole_seconds();
345    Some(secs.max(0) as u64)
346}
347
348/// Inspect the daemon singleton state. Returns `Some(pid)` iff the
349/// pidfile names a live `wire daemon` process — i.e., a singleton is
350/// currently held by another in-flight daemon. Returns `None` if the
351/// pidfile is missing, corrupt, or names a dead process.
352///
353/// v0.14.2 (#162): foreground `wire daemon` (the operator-typed kind,
354/// not the `ensure_background` spawn path) didn't write its own
355/// pidfile, so subsequent `ensure_daemon_running()` calls couldn't
356/// see it and would spawn duplicates. The duplicate-pull race is
357/// safe — per-path outbox locks prevent corruption — but it wastes
358/// relay polls and confuses operator diagnosis ("why are there 3
359/// daemons?"). The singleton helpers below let `cmd_daemon` claim
360/// the slot at startup + write its own pidfile, closing the gap.
361pub fn daemon_singleton_holder() -> Option<u32> {
362    match read_pid_record("daemon").pid() {
363        Some(pid) if pid_is_alive(pid) => Some(pid),
364        _ => None,
365    }
366}
367
368/// Claim the daemon-pid singleton by writing this process's pid +
369/// metadata to the pidfile. Callers should first check
370/// `daemon_singleton_holder()` — if Some, bail rather than overwrite.
371///
372/// Returns a `DaemonPidGuard` that removes the pidfile when dropped,
373/// so a graceful exit (SIGINT → normal Drop chain) cleans up.
374pub fn claim_daemon_singleton() -> Result<DaemonPidGuard> {
375    crate::config::ensure_dirs()?;
376    let pid = std::process::id();
377    let record = build_pid_record(pid);
378    write_pid_record("daemon", &record)?;
379    let path = pid_file("daemon")?;
380    Ok(DaemonPidGuard {
381        path,
382        owned_pid: pid,
383    })
384}
385
386/// Drop guard for a claimed daemon-pid singleton. On drop, removes
387/// the pidfile only if it still names the pid we wrote — protects
388/// against the case where another daemon raced in after we exited
389/// the singleton check but before we wrote, and we don't want to
390/// wipe their record on our exit.
391pub struct DaemonPidGuard {
392    path: PathBuf,
393    owned_pid: u32,
394}
395
396impl Drop for DaemonPidGuard {
397    fn drop(&mut self) {
398        // Only remove if the file still names US. If another wire
399        // daemon raced in and overwrote, leave their record alone.
400        if let Ok(body) = std::fs::read_to_string(&self.path) {
401            let still_ours = serde_json::from_str::<DaemonPid>(body.trim())
402                .map(|d| d.pid == self.owned_pid)
403                .unwrap_or_else(|_| {
404                    body.trim()
405                        .parse::<u32>()
406                        .map(|p| p == self.owned_pid)
407                        .unwrap_or(false)
408                });
409            if still_ours {
410                let _ = std::fs::remove_file(&self.path);
411            }
412        }
413    }
414}
415
416/// Build a `DaemonPid` for a freshly-spawned child. Reads bin_path,
417/// current binary version, identity DID, and bound relay URL.
418fn build_pid_record(pid: u32) -> DaemonPid {
419    let bin_path = std::env::current_exe()
420        .map(|p| p.to_string_lossy().to_string())
421        .unwrap_or_default();
422    let version = env!("CARGO_PKG_VERSION").to_string();
423    let started_at = time::OffsetDateTime::now_utc()
424        .format(&time::format_description::well_known::Rfc3339)
425        .unwrap_or_default();
426    let (did, relay_url) = identity_for_pid_record();
427    DaemonPid {
428        schema: DAEMON_PID_SCHEMA.to_string(),
429        pid,
430        bin_path,
431        version,
432        started_at,
433        did,
434        relay_url,
435    }
436}
437
438/// Best-effort: pull DID + relay_url from the configured identity. None
439/// fields are written as `null` so the file stays well-formed even before
440/// the operator runs `wire init`.
441fn identity_for_pid_record() -> (Option<String>, Option<String>) {
442    let did = crate::config::read_agent_card()
443        .ok()
444        .and_then(|card| card.get("did").and_then(Value::as_str).map(str::to_string));
445    let relay_url = crate::config::read_relay_state().ok().and_then(|state| {
446        state
447            .get("self")
448            .and_then(|s| s.get("relay_url"))
449            .and_then(Value::as_str)
450            .map(str::to_string)
451    });
452    (did, relay_url)
453}
454
455/// Wait briefly for `process_alive(pid)` to be true. Returns true if the
456/// child went live within the budget. Default budget is 500ms — enough for
457/// std::process::Command::spawn to fork + exec on any reasonable platform.
458fn wait_until_alive(pid: u32, budget: Duration) -> bool {
459    let deadline = Instant::now() + budget;
460    while Instant::now() < deadline {
461        if process_alive(pid) {
462            return true;
463        }
464        std::thread::sleep(Duration::from_millis(10));
465    }
466    process_alive(pid)
467}
468
469fn ensure_background(name: &str, args: &[&str]) -> Result<bool> {
470    // Test escape hatch — tests/mcp_pair.rs spawns wire mcp with this env
471    // var set so wire_accept/wire_dial don't fork persistent daemon/notify
472    // processes that survive the test's temp WIRE_HOME.
473    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
474        return Ok(false);
475    }
476
477    // Skip spawn if existing pid is still alive.
478    if let Some(pid) = read_pid_record(name).pid()
479        && process_alive(pid)
480    {
481        return Ok(false);
482    }
483
484    crate::config::ensure_dirs()?;
485    let exe = std::env::current_exe()?;
486    let child = Command::new(&exe)
487        .args(args)
488        .stdin(Stdio::null())
489        .stdout(Stdio::null())
490        .stderr(Stdio::null())
491        .spawn()?;
492
493    // P0.4: wait until the child is actually alive before persisting the
494    // pid file. Otherwise a concurrent CLI sees the file pointing at a
495    // PID that isn't yet bound to anything — "daemon reports running but
496    // can't accept connections" race.
497    let pid = child.id();
498    if !wait_until_alive(pid, Duration::from_millis(500)) {
499        anyhow::bail!(
500            "spawned `wire {}` (pid {pid}) did not appear alive within 500ms",
501            args.join(" ")
502        );
503    }
504
505    let record = build_pid_record(pid);
506    write_pid_record(name, &record)?;
507    Ok(true)
508}
509
510/// Check the running daemon's version against the CLI's CARGO_PKG_VERSION.
511/// Returns Some(stale_version) if they disagree, None if they match (or no
512/// daemon).
513///
514/// Called by `wire status` + `wire doctor`. The intent is loud, non-fatal
515/// warning — don't BLOCK CLI invocations on version mismatch (operator may
516/// be running a one-shot debug while daemon is old), but DO make it
517/// impossible to miss.
518pub fn daemon_version_mismatch() -> Option<String> {
519    let record = read_pid_record("daemon");
520    let pid = record.pid()?;
521    if !process_alive(pid) {
522        return None;
523    }
524    match record {
525        PidRecord::Json(d) => {
526            if d.version != env!("CARGO_PKG_VERSION") {
527                Some(d.version)
528            } else {
529                None
530            }
531        }
532        _ => None,
533    }
534}
535
536fn process_alive(pid: u32) -> bool {
537    crate::platform::process_alive(pid)
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn process_alive_self() {
546        assert!(process_alive(std::process::id()));
547    }
548
549    #[test]
550    fn process_alive_zero_is_false_or_self() {
551        assert!(!process_alive(99_999_999));
552    }
553
554    #[test]
555    fn pid_record_round_trips_via_json_form() {
556        // P0.4 contract: a record written by 0.5.11 must be readable by
557        // 0.5.11. If serde gets out of sync with the file format, every
558        // single CLI invocation breaks silently.
559        crate::config::test_support::with_temp_home(|| {
560            crate::config::ensure_dirs().unwrap();
561            let record = DaemonPid {
562                schema: DAEMON_PID_SCHEMA.to_string(),
563                pid: 12345,
564                bin_path: "/usr/local/bin/wire".to_string(),
565                version: "0.5.11".to_string(),
566                started_at: "2026-05-16T01:23:45Z".to_string(),
567                did: Some("did:wire:paul-mac".to_string()),
568                relay_url: Some("https://wireup.net".to_string()),
569            };
570            write_pid_record("daemon", &record).unwrap();
571            let read = read_pid_record("daemon");
572            match read {
573                PidRecord::Json(d) => assert_eq!(d, record),
574                other => panic!("expected JSON record, got {other:?}"),
575            }
576        });
577    }
578
579    #[test]
580    fn pid_record_corrupt_reports_corrupt_not_panic() {
581        // Today's debug had a stale pidfile pointing at a dead PID. The
582        // reader was tolerant. A future bug might write garbage; the reader
583        // must not panic — it must report Corrupt so wire doctor can
584        // surface it visibly.
585        crate::config::test_support::with_temp_home(|| {
586            crate::config::ensure_dirs().unwrap();
587            let path = super::pid_file("daemon").unwrap();
588            std::fs::write(&path, "not-a-pid-or-json {{{").unwrap();
589            let read = read_pid_record("daemon");
590            assert!(matches!(read, PidRecord::Corrupt(_)), "got {read:?}");
591        });
592    }
593
594    #[test]
595    fn daemon_version_mismatch_returns_none_when_no_pidfile() {
596        crate::config::test_support::with_temp_home(|| {
597            assert_eq!(daemon_version_mismatch(), None);
598        });
599    }
600}