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