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`] so push/pull is
5//! already armed by the time the agent surfaces "paired ✓" back to chat. OS
6//! toasts for inbound messages are folded into the daemon's own sync loop
7//! (see `cli::comms::notify_sweep_new_events`), so arming the daemon arms
8//! toasts too — no separate notify process.
9//!
10//! ## Idempotency
11//!
12//! Each subcommand writes its pid record to `$WIRE_HOME/state/wire/<name>.pid`
13//! on spawn. The next call reads the record and skips spawning if the pid is
14//! still alive. Stale pid files (process died) are silently overwritten.
15//!
16//! ## Pid-file shape (P0.4, 0.5.11)
17//!
18//! The pid file used to be a raw integer (`12345\n`). Today's debug surfaced
19//! a process running an OLD binary text in memory under a current symlink,
20//! and `wire status` had no way to detect that. The pid file is now a
21//! versioned JSON record:
22//!
23//! ```json
24//! {
25//!   "schema": "wire-daemon-pid-v1",
26//!   "pid": 12345,
27//!   "bin_path": "/usr/local/bin/wire",
28//!   "version": "0.5.11",
29//!   "started_at": "2026-05-16T01:23:45Z",
30//!   "did": "did:wire:paul-mac",
31//!   "relay_url": "https://wireup.net"
32//! }
33//! ```
34//!
35//! The JSON `DaemonPid` form is the only supported on-disk format;
36//! `read_pid_record` reports anything else as `Corrupt`.
37//!
38//! ## Wait-until-alive
39//!
40//! On spawn, we wait briefly for the child to be alive before persisting the
41//! pid file. A concurrent CLI seeing the file pointing at a not-yet-bound
42//! PID is the "daemon reports running but can't accept connections" race
43//! spark flagged in our P0.4 design call.
44//!
45//! ## Detachment (Unix)
46//!
47//! Spawned with stdio nulled. Since `wire mcp` runs without a controlling
48//! TTY (it's a stdio MCP server, not a login shell), the spawned children
49//! inherit no TTY → no SIGHUP arrives when the parent exits, so they
50//! survive a Claude Code restart cycle. PIDs are reaped by init.
51//!
52//! Worst case: a child dies; the next accept/dial call respawns it.
53//! No data is lost (outbox/inbox is on disk, content-addressed dedupe).
54
55use std::path::PathBuf;
56use std::process::{Command, Stdio};
57use std::time::{Duration, Instant};
58
59use anyhow::Result;
60use serde::{Deserialize, Serialize};
61use serde_json::Value;
62
63/// Schema string written into every JSON pid file. Bumped if the pid-file
64/// shape ever changes incompatibly. Readers warn on unknown schema.
65pub const DAEMON_PID_SCHEMA: &str = "wire-daemon-pid-v1";
66
67/// Versioned daemon pid record — the JSON form written by 0.5.11+.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct DaemonPid {
70    /// Schema discriminator. Always `wire-daemon-pid-v1` for now.
71    pub schema: String,
72    pub pid: u32,
73    /// Absolute path of the binary that was exec'd. Catches today's exact
74    /// bug: a stale 0.2.4 daemon process kept running under a symlink that
75    /// was repointed at 0.5.10 — `wire --version` says 0.5.10 but the
76    /// running daemon's text in memory is still 0.2.4.
77    pub bin_path: String,
78    /// CARGO_PKG_VERSION captured at spawn. Compared against the CLI's
79    /// own version on every invocation; mismatch = loud warn.
80    pub version: String,
81    /// RFC3339 timestamp of spawn.
82    pub started_at: String,
83    /// Self DID — catches multi-identity contamination (one user, two wire
84    /// identities on same host, daemon launched as wrong one). Cheap
85    /// field, expensive bug.
86    pub did: Option<String>,
87    /// Relay this daemon was bound to at spawn. Catches daemon-bound-to-
88    /// old-relay-after-migration drift.
89    pub relay_url: Option<String>,
90}
91
92/// Result of reading a pid file. JSON (full metadata) is the only
93/// supported on-disk form; anything else is `Corrupt`.
94#[derive(Debug, Clone)]
95pub enum PidRecord {
96    Json(DaemonPid),
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            _ => None,
106        }
107    }
108}
109
110/// Ensure a `wire daemon --interval 5` process is alive. Returns `Ok(true)`
111/// if a fresh process was spawned, `Ok(false)` if one was already running.
112pub fn ensure_daemon_running() -> Result<bool> {
113    ensure_background("daemon", &["daemon", "--interval", "5"])
114}
115
116fn pid_file(name: &str) -> Result<PathBuf> {
117    Ok(crate::config::state_dir()?.join(format!("{name}.pid")))
118}
119
120/// Snapshot of daemon liveness state read through ONE consistent
121/// view. Consumed by `wire status`, `wire doctor`'s `daemon` check,
122/// and `daemon_pid_consistency` so all three surfaces agree by
123/// construction — issue #2 root cause was three call sites that
124/// each computed liveness independently and disagreed for 25 min.
125#[derive(Debug, Clone)]
126pub struct DaemonLiveness {
127    /// PID claimed by `daemon.pid` (None if missing/corrupt).
128    pub pidfile_pid: Option<u32>,
129    /// True iff `pidfile_pid` is currently a live process.
130    pub pidfile_alive: bool,
131    /// Every PID matching `pgrep -f "wire daemon"`. Empty if pgrep is
132    /// unavailable (non-Unix systems, missing util) — the consumer
133    /// must not treat empty as "no daemons" without considering this.
134    pub pgrep_pids: Vec<u32>,
135    /// PIDs in `pgrep_pids` that do NOT match `pidfile_pid`. These are
136    /// orphan daemons racing the cursor with the pidfile-recorded one.
137    pub orphan_pids: Vec<u32>,
138    /// Full parsed pidfile record (Json / Missing / Corrupt).
139    pub record: PidRecord,
140}
141
142/// True iff `pid` is currently a live OS process. Delegates to the
143/// platform-aware check (`/proc` on Linux, `kill -0` on other Unix,
144/// `tasklist` on Windows) so callers never disagree across OSes. The old
145/// local `kill -0` path false-negatived on Windows (no `kill`), making
146/// `wire status`/`doctor` report the daemon DOWN while it was alive.
147pub fn pid_is_alive(pid: u32) -> bool {
148    crate::platform::process_alive(pid)
149}
150
151/// Cheap "is THIS session's daemon alive" — reads our own `daemon.pid` and
152/// checks that one pid. Unlike [`daemon_liveness`] it does NOT run the
153/// machine-wide process-enumeration scan (`find_processes_by_cmdline`) or
154/// `list_sessions` (which reads every by-key home's agent-card) or a
155/// per-session `pid_is_alive` sweep — work that only the orphan/`wire status`
156/// path needs. On Windows those each shell out (PowerShell CIM, `tasklist` ×N,
157/// hundreds of NTFS dir reads), so calling full `daemon_liveness` purely to read
158/// `pidfile_alive` made every `wire_send` cost seconds (#350). One pidfile read
159/// plus one `pid_is_alive` (one `tasklist` on Windows) yields the identical
160/// `daemon_seen` boolean for a fraction of the cost.
161pub fn daemon_pidfile_alive() -> bool {
162    read_pid_record("daemon")
163        .pid()
164        .map(pid_is_alive)
165        .unwrap_or(false)
166}
167
168/// Read the daemon pid file + pgrep in one shot, producing a snapshot
169/// every caller can interpret identically. The point of this helper
170/// is that three independent callers used to compute liveness three
171/// different ways (#2): pidfile-pid-alive (cmd_status), pgrep-only
172/// (early check_daemon_health), neither (check_daemon_pid_consistency).
173/// Now all three flow through the same `DaemonLiveness`.
174pub fn daemon_liveness() -> DaemonLiveness {
175    let record = read_pid_record("daemon");
176    let pidfile_pid = record.pid();
177    let pidfile_alive = pidfile_pid.map(pid_is_alive).unwrap_or(false);
178    // Platform-aware cmdline scan (Unix `pgrep`, Windows PowerShell CIM).
179    // Field stays named `pgrep_pids` for callers; on Windows the old direct
180    // `pgrep` shell-out returned empty (no such tool), masking live daemons.
181    let pgrep_pids: Vec<u32> = crate::platform::find_processes_by_cmdline("wire daemon");
182    // A2 (v0.13.2): on a multi-session box EVERY session runs its own daemon,
183    // so the old "any `wire daemon` whose pid != my pidfile = orphan" rule
184    // flagged sibling sessions' LEGITIMATE daemons as orphans — `wire doctor`
185    // FAILed on the very multi-agent-per-box setup wire exists for. A true
186    // orphan is a wire daemon owned by NO session: exclude every session's
187    // pidfile pid, not just this session's.
188    let known_session_pids: std::collections::HashSet<u32> = crate::session::list_sessions()
189        .map(|sessions| {
190            sessions
191                .iter()
192                .filter_map(|s| crate::session::session_daemon_pid(&s.home_dir))
193                .collect()
194        })
195        .unwrap_or_default();
196    // v0.14.2 (#170 follow-up): also exclude the `wire daemon --all-sessions`
197    // supervisor. It's pgrep-matched by the "wire daemon" cmdline scan but
198    // ISN'T orphaned — it has its own pidfile at `sessions_root/supervisor.pid`
199    // and legitimately owns the orchestration role. Pre-fix the supervisor
200    // showed up under `!! orphan daemon process(es)` on every `wire status`
201    // even though it was the load-bearing process keeping every session
202    // daemon alive — confusing operators into thinking it was stale.
203    let supervisor_pid: Option<u32> = crate::session::sessions_root()
204        .ok()
205        .map(|root| root.join("supervisor.pid"))
206        .filter(|p| p.exists())
207        .and_then(|p| std::fs::read_to_string(p).ok())
208        .and_then(|s| s.trim().parse::<u32>().ok())
209        .filter(|p| pid_is_alive(*p));
210    // v0.15.1: scope the orphan check to daemons that serve OUR WIRE_HOME.
211    // `pgrep "wire daemon"` is machine-global, but a daemon only "races
212    // our relay cursor" if it points at the SAME state tree. Pre-fix, a
213    // fresh install / any non-default WIRE_HOME ran the global scan but
214    // built its exclusion set (known_session_pids, supervisor) from the
215    // CURRENT home's sessions_root — so the operator's real default-home
216    // daemons all showed up as "orphan daemon process(es)... Multiple
217    // daemons race the relay cursor" on the very first `wire status`,
218    // even though they touch a completely different home.
219    let our_home = std::env::var("WIRE_HOME").ok();
220    let orphan_pids: Vec<u32> = pgrep_pids
221        .iter()
222        .copied()
223        .filter(|p| {
224            is_orphan_for_home(
225                *p,
226                pidfile_pid,
227                &known_session_pids,
228                supervisor_pid,
229                our_home.as_deref(),
230                crate::session::read_wire_home_from_pid(*p).as_deref(),
231            )
232        })
233        .collect();
234    DaemonLiveness {
235        pidfile_pid,
236        pidfile_alive,
237        pgrep_pids,
238        orphan_pids,
239        record,
240    }
241}
242
243/// Pure orphan predicate (pid-home reader injected for testability).
244///
245/// `pid` is a true orphan — a `wire daemon` racing OUR relay cursor with
246/// no legitimate owner — iff ALL hold:
247/// - it is not our own pidfile pid,
248/// - it is not any registered session's daemon pid,
249/// - it is not the `--all-sessions` supervisor,
250/// - AND it serves the SAME WIRE_HOME as us (`pid_home == our_home`,
251///   where `None == None` means both serve the default home).
252///
253/// The home check is the v0.15.1 fix: it is strictly subtractive (only
254/// ever removes a candidate), so it can never invent an orphan — it just
255/// stops a daemon for a *different* home (the operator's real install,
256/// seen by the machine-global `pgrep` from inside a fresh/temp home) from
257/// being mislabeled as racing our cursor. A pid whose home can't be read
258/// on this platform (`pid_home == None` on Windows) only matches when our
259/// home is also unreadable/default — the safe direction for the noise.
260fn is_orphan_for_home(
261    pid: u32,
262    pidfile_pid: Option<u32>,
263    known_session_pids: &std::collections::HashSet<u32>,
264    supervisor_pid: Option<u32>,
265    our_home: Option<&str>,
266    pid_home: Option<&str>,
267) -> bool {
268    Some(pid) != pidfile_pid
269        && !known_session_pids.contains(&pid)
270        && Some(pid) != supervisor_pid
271        && pid_home == our_home
272}
273
274/// Read a pid file. Only the JSON `DaemonPid` form is supported; any
275/// other content is reported as `Corrupt`. Never panics.
276pub fn read_pid_record(name: &str) -> PidRecord {
277    let path = match pid_file(name) {
278        Ok(p) => p,
279        Err(_) => return PidRecord::Missing,
280    };
281    let body = match std::fs::read_to_string(&path) {
282        Ok(b) => b,
283        Err(_) => return PidRecord::Missing,
284    };
285    let trimmed = body.trim();
286    if trimmed.is_empty() {
287        return PidRecord::Missing;
288    }
289    match serde_json::from_str::<DaemonPid>(trimmed) {
290        Ok(d) => PidRecord::Json(d),
291        Err(e) => PidRecord::Corrupt(format!("JSON parse: {e}")),
292    }
293}
294
295/// Write a JSON pid record. P0.4: replaces the raw-int write.
296fn write_pid_record(name: &str, record: &DaemonPid) -> Result<()> {
297    let path = pid_file(name)?;
298    let body = serde_json::to_vec_pretty(record)?;
299    std::fs::write(&path, body)?;
300    Ok(())
301}
302
303/// Daemon-startup: claim the `daemon.pid` file for THIS process.
304///
305/// A daemon started directly (`wire daemon`, not via `ensure_background`)
306/// must write its own versioned-JSON pidfile so `wire status` / doctor /
307/// the singleton guard can see it. Idempotent: if the pidfile already
308/// records our PID we leave it untouched. (Historically this lived in
309/// `pending_pair::cleanup_on_startup` alongside the now-removed SAS
310/// pending-pair recovery; the pidfile write was never SAS-specific.)
311pub fn write_self_daemon_pid() -> Result<()> {
312    write_self_role_pid("daemon")
313}
314
315/// Long-running-role startup: claim the `<role>.pid` file for THIS
316/// process inside the active `WIRE_HOME`. Same on-disk JSON shape as
317/// `daemon.pid`, just keyed by role.
318///
319/// #247 finding 4: the per-role pidfile is what lets the cross-platform
320/// identity-collision check map another wire process's PID back to the
321/// `WIRE_HOME` it serves. Windows has no portable way to read another
322/// process's environment, so the env-based POSIX path
323/// (`/proc/<pid>/environ` / `ps -E`) doesn't translate — but every
324/// inbox-owning long-running role (daemon / mcp / monitor / notify)
325/// living under `<WIRE_HOME>/state/wire/<role>.pid` IS a portable
326/// signal: a Windows waiter walks `list_sessions()` × roles, matches
327/// the candidate PID against each pidfile, and reads off the session's
328/// home. The POSIX path keeps its env-based fast path; this gives
329/// Windows the same coverage without an `NtQueryInformationProcess`
330/// FFI dep.
331///
332/// Idempotent: if the pidfile already records our PID we leave it
333/// alone.
334pub fn write_self_role_pid(role: &str) -> Result<()> {
335    let path = pid_file(role)?;
336    let my_pid = std::process::id();
337    if path.exists()
338        && let Ok(s) = std::fs::read_to_string(&path)
339        && let Ok(rec) = serde_json::from_str::<DaemonPid>(s.trim())
340        && rec.pid == my_pid
341    {
342        return Ok(());
343    }
344    if let Some(parent) = path.parent() {
345        std::fs::create_dir_all(parent).ok();
346    }
347    write_pid_record(role, &build_pid_record(my_pid))
348}
349
350/// Schema string written into every JSON last-sync file. Bumped if the
351/// shape ever changes incompatibly. Readers tolerate any schema string +
352/// fall back to "unknown last_sync" when they don't recognize it.
353pub const LAST_SYNC_FILE_SCHEMA: &str = "wire-daemon-last-sync-v1";
354
355/// Versioned record written by `wire daemon` after each successful sync
356/// cycle. Readers (`wire status`, `mcp__wire__wire_status`,
357/// `mcp__wire__wire_send` annotations) inspect it to surface
358/// "is the sync loop alive RIGHT NOW?" — distinct from "is there a
359/// process with `wire daemon` in its cmdline?" (the existing pidfile-
360/// alive check), which can be true while the loop has been wedged for
361/// minutes. v0.14.2 (#162): closes the silent-send class where the MCP
362/// surface reports `status:"queued"` while no one is actually pushing.
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
364pub struct LastSyncRecord {
365    /// Schema discriminator. `wire-daemon-last-sync-v1`.
366    pub schema: String,
367    /// RFC3339 UTC timestamp of the most recently completed cycle.
368    pub ts: String,
369    /// Number of outbox events pushed in this cycle.
370    pub push_n: usize,
371    /// Number of inbox events pulled (verified + written) in this cycle.
372    pub pull_n: usize,
373    /// Number of inbox events rejected by signature/cursor checks.
374    pub rejected_n: usize,
375}
376
377fn last_sync_file() -> Result<PathBuf> {
378    Ok(crate::config::state_dir()?.join("last_sync.json"))
379}
380
381/// Write the last-sync record. Called by `cmd_daemon` after each cycle
382/// (including --once). Best-effort: any error logs to stderr but does NOT
383/// abort the daemon loop — a wedged pidfile path shouldn't take the sync
384/// loop down with it.
385pub fn write_last_sync_record(push_n: usize, pull_n: usize, rejected_n: usize) {
386    let record = LastSyncRecord {
387        schema: LAST_SYNC_FILE_SCHEMA.to_string(),
388        ts: time::OffsetDateTime::now_utc()
389            .format(&time::format_description::well_known::Rfc3339)
390            .unwrap_or_default(),
391        push_n,
392        pull_n,
393        rejected_n,
394    };
395    let _ = (|| -> Result<()> {
396        let path = last_sync_file()?;
397        if let Some(parent) = path.parent() {
398            std::fs::create_dir_all(parent)?;
399        }
400        let body = serde_json::to_vec_pretty(&record)?;
401        std::fs::write(&path, body)?;
402        Ok(())
403    })()
404    .map_err(|e| eprintln!("daemon: last-sync persist error (non-fatal): {e:#}"));
405}
406
407/// Read the last-sync record. Returns `None` if missing/corrupt — every
408/// caller should treat that as "unknown sync state, daemon may never
409/// have run" and surface it accordingly.
410pub fn read_last_sync_record() -> Option<LastSyncRecord> {
411    let path = last_sync_file().ok()?;
412    let body = std::fs::read_to_string(&path).ok()?;
413    serde_json::from_str(&body).ok()
414}
415
416/// Convenience: the wall-clock age (in whole seconds) of the most recent
417/// sync, or `None` if no record exists / the timestamp can't be parsed.
418/// Negative ages (clock skew between daemon + reader) are clamped to 0.
419pub fn last_sync_age_seconds() -> Option<u64> {
420    let rec = read_last_sync_record()?;
421    let parsed =
422        time::OffsetDateTime::parse(&rec.ts, &time::format_description::well_known::Rfc3339)
423            .ok()?;
424    let delta = time::OffsetDateTime::now_utc() - parsed;
425    let secs = delta.whole_seconds();
426    Some(secs.max(0) as u64)
427}
428
429/// Inspect the daemon singleton state. Returns `Some(pid)` iff the
430/// pidfile names a live `wire daemon` process — i.e., a singleton is
431/// currently held by another in-flight daemon. Returns `None` if the
432/// pidfile is missing, corrupt, or names a dead process.
433///
434/// v0.14.2 (#162): foreground `wire daemon` (the operator-typed kind,
435/// not the `ensure_background` spawn path) didn't write its own
436/// pidfile, so subsequent `ensure_daemon_running()` calls couldn't
437/// see it and would spawn duplicates. The duplicate-pull race is
438/// safe — per-path outbox locks prevent corruption — but it wastes
439/// relay polls and confuses operator diagnosis ("why are there 3
440/// daemons?"). The singleton helpers below let `cmd_daemon` claim
441/// the slot at startup + write its own pidfile, closing the gap.
442pub fn daemon_singleton_holder() -> Option<u32> {
443    // Exclude our OWN pid: `ensure_background` records the spawned daemon's pid
444    // in the "daemon" pidfile right after spawn (the P0.4 alive-confirmation
445    // write), and the daemon's own startup singleton check then reads that same
446    // pidfile. Without this self-exclusion the daemon sees its own pid as a live
447    // "other" holder, logs "another daemon is already running", and exits — so a
448    // freshly-`wire up`'d session ends up with NO running daemon and the first
449    // connection silently never completes (the receiver never pulls). A
450    // manually-started daemon dodged this only because nothing pre-wrote its
451    // pid. Self is never "another" daemon.
452    let me = std::process::id();
453    match read_pid_record("daemon").pid() {
454        Some(pid) if pid != me && pid_is_alive(pid) => Some(pid),
455        _ => None,
456    }
457}
458
459/// Claim the daemon-pid singleton by writing this process's pid +
460/// metadata to the pidfile. Callers should first check
461/// `daemon_singleton_holder()` — if Some, bail rather than overwrite.
462///
463/// Returns a `DaemonPidGuard` that removes the pidfile when dropped,
464/// so a graceful exit (SIGINT → normal Drop chain) cleans up.
465pub fn claim_daemon_singleton() -> Result<DaemonPidGuard> {
466    crate::config::ensure_dirs()?;
467    let pid = std::process::id();
468    let record = build_pid_record(pid);
469    write_pid_record("daemon", &record)?;
470    let path = pid_file("daemon")?;
471    Ok(DaemonPidGuard {
472        path,
473        owned_pid: pid,
474    })
475}
476
477/// Drop guard for a claimed daemon-pid singleton. On drop, removes
478/// the pidfile only if it still names the pid we wrote — protects
479/// against the case where another daemon raced in after we exited
480/// the singleton check but before we wrote, and we don't want to
481/// wipe their record on our exit.
482pub struct DaemonPidGuard {
483    path: PathBuf,
484    owned_pid: u32,
485}
486
487impl Drop for DaemonPidGuard {
488    fn drop(&mut self) {
489        // Only remove if the file still names US. If another wire
490        // daemon raced in and overwrote, leave their record alone.
491        if let Ok(body) = std::fs::read_to_string(&self.path) {
492            let still_ours = serde_json::from_str::<DaemonPid>(body.trim())
493                .map(|d| d.pid == self.owned_pid)
494                .unwrap_or_else(|_| {
495                    body.trim()
496                        .parse::<u32>()
497                        .map(|p| p == self.owned_pid)
498                        .unwrap_or(false)
499                });
500            if still_ours {
501                let _ = std::fs::remove_file(&self.path);
502            }
503        }
504    }
505}
506
507/// Build a `DaemonPid` for a freshly-spawned child. Reads bin_path,
508/// current binary version, identity DID, and bound relay URL.
509fn build_pid_record(pid: u32) -> DaemonPid {
510    let bin_path = std::env::current_exe()
511        .map(|p| p.to_string_lossy().to_string())
512        .unwrap_or_default();
513    let version = env!("CARGO_PKG_VERSION").to_string();
514    let started_at = time::OffsetDateTime::now_utc()
515        .format(&time::format_description::well_known::Rfc3339)
516        .unwrap_or_default();
517    let (did, relay_url) = identity_for_pid_record();
518    DaemonPid {
519        schema: DAEMON_PID_SCHEMA.to_string(),
520        pid,
521        bin_path,
522        version,
523        started_at,
524        did,
525        relay_url,
526    }
527}
528
529/// Best-effort: pull DID + relay_url from the configured identity. None
530/// fields are written as `null` so the file stays well-formed even before
531/// the operator runs `wire init`.
532fn identity_for_pid_record() -> (Option<String>, Option<String>) {
533    let did = crate::config::read_agent_card()
534        .ok()
535        .and_then(|card| card.get("did").and_then(Value::as_str).map(str::to_string));
536    let relay_url = crate::config::read_relay_state().ok().and_then(|state| {
537        state
538            .get("self")
539            .and_then(|s| s.get("relay_url"))
540            .and_then(Value::as_str)
541            .map(str::to_string)
542    });
543    (did, relay_url)
544}
545
546/// Wait briefly for `process_alive(pid)` to be true. Returns true if the
547/// child went live within the budget. Default budget is 500ms — enough for
548/// std::process::Command::spawn to fork + exec on any reasonable platform.
549fn wait_until_alive(pid: u32, budget: Duration) -> bool {
550    let deadline = Instant::now() + budget;
551    while Instant::now() < deadline {
552        if process_alive(pid) {
553            return true;
554        }
555        std::thread::sleep(Duration::from_millis(10));
556    }
557    process_alive(pid)
558}
559
560fn ensure_background(name: &str, args: &[&str]) -> Result<bool> {
561    // Test escape hatch — tests/mcp_pair.rs spawns wire mcp with this env
562    // var set so wire_accept/wire_dial don't fork persistent daemon/notify
563    // processes that survive the test's temp WIRE_HOME.
564    if std::env::var("WIRE_MCP_SKIP_AUTO_UP").is_ok() {
565        return Ok(false);
566    }
567
568    // Skip spawn if existing pid is still alive.
569    if let Some(pid) = read_pid_record(name).pid()
570        && process_alive(pid)
571    {
572        return Ok(false);
573    }
574
575    crate::config::ensure_dirs()?;
576    let exe = std::env::current_exe()?;
577    let mut cmd = Command::new(&exe);
578    cmd.args(args).stdin(Stdio::null()).stdout(Stdio::null());
579    // Capture the spawned daemon's stderr to a logfile instead of /dev/null so
580    // a daemon that dies on startup leaves a trace (otherwise its death is
581    // invisible — exactly the silent-fail class this guards). Best-effort: fall
582    // back to null if the log can't be opened.
583    let stderr_log = crate::config::state_dir()
584        .ok()
585        .map(|d| d.join(format!("{name}-spawn.log")));
586    match stderr_log
587        .as_ref()
588        .and_then(|p| std::fs::File::create(p).ok())
589    {
590        Some(f) => {
591            cmd.stderr(Stdio::from(f));
592        }
593        None => {
594            cmd.stderr(Stdio::null());
595        }
596    }
597
598    let child = cmd.spawn()?;
599
600    // P0.4: wait until the child is actually alive before persisting the
601    // pid file. Otherwise a concurrent CLI sees the file pointing at a
602    // PID that isn't yet bound to anything — "daemon reports running but
603    // can't accept connections" race.
604    let pid = child.id();
605    if !wait_until_alive(pid, Duration::from_millis(500)) {
606        anyhow::bail!(
607            "spawned `wire {}` (pid {pid}) did not appear alive within 500ms",
608            args.join(" ")
609        );
610    }
611
612    let record = build_pid_record(pid);
613    write_pid_record(name, &record)?;
614    Ok(true)
615}
616
617/// Check the running daemon's version against the CLI's CARGO_PKG_VERSION.
618/// Returns Some(stale_version) if they disagree, None if they match (or no
619/// daemon).
620///
621/// Called by `wire status` + `wire doctor`. The intent is loud, non-fatal
622/// warning — don't BLOCK CLI invocations on version mismatch (operator may
623/// be running a one-shot debug while daemon is old), but DO make it
624/// impossible to miss.
625pub fn daemon_version_mismatch() -> Option<String> {
626    let record = read_pid_record("daemon");
627    let pid = record.pid()?;
628    if !process_alive(pid) {
629        return None;
630    }
631    match record {
632        PidRecord::Json(d) => {
633            if d.version != env!("CARGO_PKG_VERSION") {
634                Some(d.version)
635            } else {
636                None
637            }
638        }
639        _ => None,
640    }
641}
642
643fn process_alive(pid: u32) -> bool {
644    crate::platform::process_alive(pid)
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn process_alive_self() {
653        assert!(process_alive(std::process::id()));
654    }
655
656    #[test]
657    fn orphan_excludes_daemon_serving_a_different_home() {
658        // The v0.15.1 regression: a fresh install (our_home = temp) runs
659        // a machine-global pgrep that sees the operator's real default-home
660        // daemon (pid_home = None). It must NOT be flagged as an orphan
661        // racing our cursor.
662        let empty = std::collections::HashSet::new();
663        assert!(!is_orphan_for_home(
664            42,
665            None,
666            &empty,
667            None,
668            Some("/tmp/fresh/home"), // we run under a temp WIRE_HOME
669            None,                    // the real daemon serves the default home
670        ));
671        // A foreign Some-home daemon is likewise not ours.
672        assert!(!is_orphan_for_home(
673            42,
674            None,
675            &empty,
676            None,
677            Some("/tmp/fresh/home"),
678            Some("/Users/op/other/home"),
679        ));
680    }
681
682    #[test]
683    fn orphan_flags_unowned_daemon_on_same_home() {
684        // A genuine orphan: same home as us, not our pidfile, not a known
685        // session, not the supervisor → still flagged (feature preserved).
686        let empty = std::collections::HashSet::new();
687        // Both default home (None == None).
688        assert!(is_orphan_for_home(42, Some(7), &empty, Some(9), None, None));
689        // Both the same explicit home.
690        assert!(is_orphan_for_home(
691            42,
692            None,
693            &empty,
694            None,
695            Some("/h"),
696            Some("/h")
697        ));
698    }
699
700    #[test]
701    fn orphan_excludes_self_session_and_supervisor_even_on_same_home() {
702        let mut known = std::collections::HashSet::new();
703        known.insert(100u32);
704        // our own pidfile pid
705        assert!(!is_orphan_for_home(7, Some(7), &known, Some(9), None, None));
706        // a registered session daemon
707        assert!(!is_orphan_for_home(
708            100,
709            Some(7),
710            &known,
711            Some(9),
712            None,
713            None
714        ));
715        // the supervisor
716        assert!(!is_orphan_for_home(9, Some(7), &known, Some(9), None, None));
717    }
718
719    #[test]
720    fn process_alive_zero_is_false_or_self() {
721        assert!(!process_alive(99_999_999));
722    }
723
724    #[test]
725    fn pid_record_round_trips_via_json_form() {
726        // P0.4 contract: a record written by 0.5.11 must be readable by
727        // 0.5.11. If serde gets out of sync with the file format, every
728        // single CLI invocation breaks silently.
729        crate::config::test_support::with_temp_home(|| {
730            crate::config::ensure_dirs().unwrap();
731            let record = DaemonPid {
732                schema: DAEMON_PID_SCHEMA.to_string(),
733                pid: 12345,
734                bin_path: "/usr/local/bin/wire".to_string(),
735                version: "0.5.11".to_string(),
736                started_at: "2026-05-16T01:23:45Z".to_string(),
737                did: Some("did:wire:paul-mac".to_string()),
738                relay_url: Some("https://wireup.net".to_string()),
739            };
740            write_pid_record("daemon", &record).unwrap();
741            let read = read_pid_record("daemon");
742            match read {
743                PidRecord::Json(d) => assert_eq!(d, record),
744                other => panic!("expected JSON record, got {other:?}"),
745            }
746        });
747    }
748
749    #[test]
750    fn pid_record_corrupt_reports_corrupt_not_panic() {
751        // Today's debug had a stale pidfile pointing at a dead PID. The
752        // reader was tolerant. A future bug might write garbage; the reader
753        // must not panic — it must report Corrupt so wire doctor can
754        // surface it visibly.
755        crate::config::test_support::with_temp_home(|| {
756            crate::config::ensure_dirs().unwrap();
757            let path = super::pid_file("daemon").unwrap();
758            std::fs::write(&path, "not-a-pid-or-json {{{").unwrap();
759            let read = read_pid_record("daemon");
760            assert!(matches!(read, PidRecord::Corrupt(_)), "got {read:?}");
761        });
762    }
763
764    #[test]
765    fn daemon_version_mismatch_returns_none_when_no_pidfile() {
766        crate::config::test_support::with_temp_home(|| {
767            assert_eq!(daemon_version_mismatch(), None);
768        });
769    }
770
771    #[test]
772    fn daemon_pidfile_alive_false_without_pidfile() {
773        // No daemon.pid → false, with no process-enumeration shell-out. (The
774        // send-path annotation reads only this, not the full daemon_liveness
775        // scan — the #350 Windows hot-path fix.)
776        crate::config::test_support::with_temp_home(|| {
777            assert!(!daemon_pidfile_alive());
778        });
779    }
780}