Skip to main content

running_process/broker/
doctor.rs

1//! Read-only `broker doctor` environment diagnostics (#354, v1.x-5 from #228).
2//!
3//! `doctor` inspects the local broker environment and reports a flat list of
4//! PASS / WARN / FAIL checks. It never mutates anything: no files are
5//! created, deleted, or rewritten, no processes are spawned, and no daemon
6//! state is changed. Stale artifacts are *reported*, never repaired.
7//!
8//! Check areas:
9//!
10//! 1. Environment-variable sanity for every `RUNNING_PROCESS_*` knob,
11//!    including a loud WARN when test-only seams are set.
12//! 2. Broker endpoint reachability: derive the default per-user shared
13//!    broker endpoint, attempt a connection, and — when something is
14//!    listening — run a deadline-bounded Hello probe to report the daemon
15//!    version, negotiated protocol, and decoded server capability bits.
16//! 3. Service-definition directory health plus per-file `.servicedef`
17//!    parse/validation results (same loader the broker Hello path uses).
18//! 4. Unix socket hygiene: count stale `*.sock` files in the broker runtime
19//!    directory (connect-refused ⇒ stale). Reported, not deleted.
20//! 5. Platform path budget: derived pipe/socket path length against the
21//!    platform limit (`MAX_PATH` on Windows, `sun_path` on Unix).
22//! 6. systemd KillMode (#391): WARN when systemd-managed with
23//!    `KillMode=control-group` (or undeterminable). The only check that may
24//!    spawn a process — a read-only `systemctl show -p KillMode` query on
25//!    Linux, and only when `INVOCATION_ID` indicates systemd management.
26//! 7. Version/build info: crate version, negotiated protocol version, and
27//!    framing version.
28//!
29//! Every check is fault-isolated: a panic inside one check is converted to
30//! a FAIL for that check and the remaining checks still run.
31
32use std::path::{Path, PathBuf};
33use std::sync::mpsc;
34use std::thread;
35use std::time::Duration;
36
37use prost::Message;
38
39use crate::broker::capabilities::CAP_HANDLE_PASSING;
40use crate::broker::client::{
41    broker_disabled_by_env, connect_local_socket, RUNNING_PROCESS_DISABLE_ENV,
42    RUNNING_PROCESS_FAKE_BACKEND_ENV,
43};
44use crate::broker::lifecycle::names::{backend_pipe, shared_broker_pipe, PipePathError};
45use crate::broker::lifecycle::sid::user_sid_hash;
46use crate::broker::protocol::{
47    hello_reply::Result as HelloReplyResult, read_frame, write_frame, ErrorCode, Frame, FrameKind,
48    Hello, HelloReply, PayloadEncoding, CONTROL_PAYLOAD_PROTOCOL, PROTOCOL_VERSION,
49};
50use crate::broker::server::service_def_loader::{
51    service_definition_dir, ServiceDefinitionLoader, SERVICE_DEF_EXTENSION,
52};
53use crate::broker::{secure_dir, FRAMING_VERSION_V1};
54
55use crate::env_vars::EnvVar;
56
57/// Wall-clock bound on the Hello probe so doctor can never hang on a
58/// listener that accepts but never replies.
59pub const DOCTOR_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
60
61/// Service name the reachability probe sends in `Hello.service_name`.
62///
63/// A real broker refuses it with `ERROR_SERVICE_UNKNOWN` (unless an
64/// operator actually installed a service with this name), which still
65/// proves framing, protocol negotiation, and the daemon protocol range.
66pub const DOCTOR_PROBE_SERVICE: &str = "rp-doctor-probe";
67
68/// Outcome of one doctor check.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum DoctorStatus {
71    /// Healthy.
72    Pass,
73    /// Suspicious or non-default but not fatal. Never affects exit code.
74    Warn,
75    /// Broken. Any FAIL makes the doctor exit code 1.
76    Fail,
77}
78
79impl DoctorStatus {
80    /// Stable uppercase label used in both text and JSON output.
81    pub fn as_str(self) -> &'static str {
82        match self {
83            DoctorStatus::Pass => "PASS",
84            DoctorStatus::Warn => "WARN",
85            DoctorStatus::Fail => "FAIL",
86        }
87    }
88}
89
90/// One named check with its outcome and a one-line detail.
91#[derive(Clone, Debug)]
92pub struct DoctorCheck {
93    /// Stable check identifier, e.g. `env:RUNNING_PROCESS_DISABLE`.
94    pub name: String,
95    /// PASS / WARN / FAIL.
96    pub status: DoctorStatus,
97    /// Human-readable one-line detail.
98    pub detail: String,
99}
100
101impl DoctorCheck {
102    fn pass(name: impl Into<String>, detail: impl Into<String>) -> Self {
103        Self {
104            name: name.into(),
105            status: DoctorStatus::Pass,
106            detail: detail.into(),
107        }
108    }
109
110    fn warn(name: impl Into<String>, detail: impl Into<String>) -> Self {
111        Self {
112            name: name.into(),
113            status: DoctorStatus::Warn,
114            detail: detail.into(),
115        }
116    }
117
118    fn fail(name: impl Into<String>, detail: impl Into<String>) -> Self {
119        Self {
120            name: name.into(),
121            status: DoctorStatus::Fail,
122            detail: detail.into(),
123        }
124    }
125}
126
127/// Aggregated doctor run.
128#[derive(Clone, Debug, Default)]
129pub struct DoctorReport {
130    /// Every check that ran, in execution order.
131    pub checks: Vec<DoctorCheck>,
132}
133
134impl DoctorReport {
135    /// True when at least one check FAILed. WARNs do not count.
136    pub fn has_failures(&self) -> bool {
137        self.checks
138            .iter()
139            .any(|check| check.status == DoctorStatus::Fail)
140    }
141
142    /// Process exit code contract: 0 when no FAIL, 1 otherwise.
143    pub fn exit_code(&self) -> i32 {
144        if self.has_failures() {
145            1
146        } else {
147            0
148        }
149    }
150
151    /// Stable machine-readable JSON document.
152    ///
153    /// Shape (frozen — only additive changes allowed):
154    /// `{"schema_version":1,"command":"doctor","exit_code":0,
155    ///   "checks":[{"check":"...","status":"PASS","detail":"..."}]}`
156    pub fn to_json(&self) -> String {
157        let checks: Vec<serde_json::Value> = self
158            .checks
159            .iter()
160            .map(|check| {
161                serde_json::json!({
162                    "check": check.name,
163                    "status": check.status.as_str(),
164                    "detail": check.detail,
165                })
166            })
167            .collect();
168        serde_json::json!({
169            "schema_version": 1,
170            "command": "doctor",
171            "exit_code": self.exit_code(),
172            "checks": checks,
173        })
174        .to_string()
175    }
176
177    /// Human-readable table plus a one-line summary.
178    pub fn render_text(&self) -> String {
179        let name_width = self
180            .checks
181            .iter()
182            .map(|check| check.name.len())
183            .max()
184            .unwrap_or(0);
185        let mut out = String::new();
186        for check in &self.checks {
187            out.push_str(&format!(
188                "{:<4}  {:<name_width$}  {}\n",
189                check.status.as_str(),
190                check.name,
191                check.detail,
192            ));
193        }
194        let pass = self.count(DoctorStatus::Pass);
195        let warn = self.count(DoctorStatus::Warn);
196        let fail = self.count(DoctorStatus::Fail);
197        out.push_str(&format!(
198            "doctor: {} checks — {pass} pass, {warn} warn, {fail} fail\n",
199            self.checks.len()
200        ));
201        out
202    }
203
204    fn count(&self, status: DoctorStatus) -> usize {
205        self.checks
206            .iter()
207            .filter(|check| check.status == status)
208            .count()
209    }
210}
211
212/// Inputs for [`run_doctor`]. `Default` derives everything from the
213/// environment exactly like a broker client would.
214#[derive(Clone, Debug, Default)]
215pub struct DoctorOptions {
216    /// Probe this broker endpoint instead of the derived per-user shared
217    /// broker endpoint.
218    pub broker_endpoint: Option<String>,
219    /// Inspect this service-definition directory instead of the resolved
220    /// platform default (`paths.service_definition_dir` contract).
221    pub service_definition_dir: Option<PathBuf>,
222}
223
224/// Run every doctor check and aggregate the report.
225///
226/// Read-only by contract. Each check area is individually fault-isolated:
227/// a panic in one area becomes a FAIL entry and the rest still run.
228pub fn run_doctor(options: &DoctorOptions) -> DoctorReport {
229    let mut checks = Vec::new();
230    checks.extend(isolated("env", env_var_checks));
231    {
232        let endpoint = options.broker_endpoint.clone();
233        checks.extend(isolated("broker:endpoint", move || {
234            vec![broker_endpoint_check(endpoint.as_deref())]
235        }));
236    }
237    {
238        let dir = options
239            .service_definition_dir
240            .clone()
241            .unwrap_or_else(service_definition_dir);
242        checks.extend(isolated("servicedef:dir", move || {
243            service_definition_checks(&dir)
244        }));
245    }
246    checks.extend(isolated("sockets:runtime-dir", || {
247        vec![socket_hygiene_check()]
248    }));
249    checks.extend(isolated("filesystem:inodes", || {
250        vec![inode_pressure_check()]
251    }));
252    checks.extend(isolated("platform:path-budget", || {
253        vec![platform_path_budget_check()]
254    }));
255    checks.extend(isolated("platform:systemd-killmode", || {
256        vec![systemd_killmode_check()]
257    }));
258    checks.extend(isolated("build:version", || vec![version_check()]));
259    DoctorReport { checks }
260}
261
262/// Run one check area, converting a panic into a FAIL for that area.
263fn isolated<F>(area: &str, body: F) -> Vec<DoctorCheck>
264where
265    F: FnOnce() -> Vec<DoctorCheck> + std::panic::UnwindSafe,
266{
267    match std::panic::catch_unwind(body) {
268        Ok(checks) => checks,
269        Err(payload) => vec![DoctorCheck::fail(
270            area,
271            format!("check panicked: {}", panic_message(payload.as_ref())),
272        )],
273    }
274}
275
276fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
277    if let Some(message) = payload.downcast_ref::<&str>() {
278        (*message).to_string()
279    } else if let Some(message) = payload.downcast_ref::<String>() {
280        message.clone()
281    } else {
282        "non-string panic payload".to_string()
283    }
284}
285
286// ---------------------------------------------------------------------------
287// 1. Environment-variable sanity
288// ---------------------------------------------------------------------------
289
290/// Check every running-process environment knob.
291pub fn env_var_checks() -> Vec<DoctorCheck> {
292    let mut checks = vec![disable_env_check(), fake_backend_env_check()];
293    checks.push(informational_env_check(
294        crate::env_vars::NO_TRACKING,
295        "unset (daemon IPC tracking enabled)",
296        "daemon IPC tracking disabled",
297    ));
298    checks.push(informational_env_check(
299        crate::env_vars::DAEMON_SCOPE,
300        "unset (user-scoped daemon)",
301        "CWD-scoped daemon (test-isolation mode)",
302    ));
303    checks.push(informational_env_check(
304        crate::env_vars::SERVICE_DEF_DIR,
305        "unset (platform default service-definition dir)",
306        "service-definition dir overridden",
307    ));
308    checks.push(informational_env_check(
309        crate::env_vars::BROKER_V1_SOCKET,
310        "unset (derived broker endpoint)",
311        "broker admin endpoint overridden",
312    ));
313    checks
314}
315
316fn disable_env_check() -> DoctorCheck {
317    let name = format!("env:{RUNNING_PROCESS_DISABLE_ENV}");
318    match broker_disabled_by_env() {
319        Ok(false) => DoctorCheck::pass(name, "unset (broker enabled)"),
320        Ok(true) => DoctorCheck::warn(
321            name,
322            "set to \"1\" — broker disabled; consumers use their direct fallback path",
323        ),
324        Err(err) => DoctorCheck::fail(name, err.to_string()),
325    }
326}
327
328fn fake_backend_env_check() -> DoctorCheck {
329    let name = format!("env:{RUNNING_PROCESS_FAKE_BACKEND_ENV}");
330    match crate::env_vars::FAKE_BACKEND.os() {
331        None => DoctorCheck::pass(name, "unset"),
332        Some(value) if value.is_empty() => {
333            DoctorCheck::warn(name, "set but empty (seam ignored) — unset it")
334        }
335        Some(value) => DoctorCheck::warn(
336            name,
337            format!(
338                "TEST-ONLY seam is set to {:?} — broker negotiation is bypassed; \
339                 never set this in production",
340                value.to_string_lossy()
341            ),
342        ),
343    }
344}
345
346fn informational_env_check(env: EnvVar, unset_detail: &str, set_description: &str) -> DoctorCheck {
347    let name = format!("env:{}", env.name);
348    match env.os() {
349        None => DoctorCheck::pass(name, unset_detail),
350        Some(value) => DoctorCheck::warn(
351            name,
352            format!("set to {:?} — {set_description}", value.to_string_lossy()),
353        ),
354    }
355}
356
357// ---------------------------------------------------------------------------
358// 2. Broker endpoint reachability
359// ---------------------------------------------------------------------------
360
361/// Derive the default per-user shared-broker endpoint string.
362pub fn default_broker_endpoint() -> Result<String, String> {
363    let sid_hash = user_sid_hash().map_err(|err| err.to_string())?;
364    let pipe = shared_broker_pipe(&sid_hash).map_err(|err| err.to_string())?;
365    pipe_path_string(pipe.windows, pipe.unix)
366        .ok_or_else(|| "pipe path has no platform form".to_string())
367}
368
369fn pipe_path_string(windows: Option<String>, unix: Option<PathBuf>) -> Option<String> {
370    windows.or_else(|| unix.map(|path| path.to_string_lossy().into_owned()))
371}
372
373/// Probe `endpoint` (or the derived default) for a listening broker.
374pub fn broker_endpoint_check(endpoint: Option<&str>) -> DoctorCheck {
375    const NAME: &str = "broker:endpoint";
376    let endpoint = match endpoint {
377        Some(endpoint) => endpoint.to_string(),
378        None => match default_broker_endpoint() {
379            Ok(endpoint) => endpoint,
380            Err(err) => {
381                return DoctorCheck::fail(NAME, format!("cannot derive broker endpoint: {err}"));
382            }
383        },
384    };
385    let stream = match connect_local_socket(&endpoint) {
386        Ok(stream) => stream,
387        Err(err) => {
388            return DoctorCheck::warn(NAME, format!("no broker listening at {endpoint} ({err})"));
389        }
390    };
391    match hello_probe(stream) {
392        Ok(ProbeOutcome::Negotiated {
393            daemon_version,
394            negotiated_protocol,
395            server_capabilities,
396        }) => DoctorCheck::pass(
397            NAME,
398            format!(
399                "broker listening at {endpoint}: daemon {daemon_version}, \
400                 protocol v{negotiated_protocol}, capabilities {}",
401                describe_capabilities(server_capabilities)
402            ),
403        ),
404        Ok(ProbeOutcome::Refused {
405            code,
406            daemon_min_protocol,
407            daemon_max_protocol,
408        }) => DoctorCheck::pass(
409            NAME,
410            format!(
411                "broker listening at {endpoint}: protocol v{daemon_min_protocol}..v{daemon_max_protocol}, \
412                 probe refused with {code:?} (expected for the doctor probe service)"
413            ),
414        ),
415        Err(err) => DoctorCheck::warn(
416            NAME,
417            format!("{endpoint} accepted a connection but the v1 Hello probe failed: {err}"),
418        ),
419    }
420}
421
422enum ProbeOutcome {
423    Negotiated {
424        daemon_version: String,
425        negotiated_protocol: u32,
426        server_capabilities: u64,
427    },
428    Refused {
429        code: ErrorCode,
430        daemon_min_protocol: u32,
431        daemon_max_protocol: u32,
432    },
433}
434
435/// Send one Hello for [`DOCTOR_PROBE_SERVICE`] and classify the reply.
436///
437/// Runs on a helper thread bounded by [`DOCTOR_PROBE_TIMEOUT`] because
438/// local-socket streams have no portable read timeout; on timeout the
439/// abandoned stream stays with the helper thread.
440fn hello_probe(stream: crate::platform::ipc::Stream) -> Result<ProbeOutcome, String> {
441    let (result_tx, result_rx) = mpsc::channel();
442    thread::spawn(move || {
443        let mut stream = stream;
444        let _ = result_tx.send(hello_probe_blocking(&mut stream));
445    });
446    match result_rx.recv_timeout(DOCTOR_PROBE_TIMEOUT) {
447        Ok(outcome) => outcome,
448        Err(_) => Err(format!(
449            "no HelloReply within {DOCTOR_PROBE_TIMEOUT:?} (listener is not a v1 broker?)"
450        )),
451    }
452}
453
454fn hello_probe_blocking(stream: &mut crate::platform::ipc::Stream) -> Result<ProbeOutcome, String> {
455    let hello = Hello {
456        client_min_protocol: PROTOCOL_VERSION,
457        client_max_protocol: PROTOCOL_VERSION,
458        service_name: DOCTOR_PROBE_SERVICE.into(),
459        wanted_version: "0.0.0".into(),
460        client_version: env!("CARGO_PKG_VERSION").into(),
461        client_capabilities: 0,
462        auth_token: Vec::new(),
463        request_id: "doctor-probe".into(),
464        connection_id: 0,
465        peer_pid: std::process::id(),
466        client_lib_name: "running-process-doctor".into(),
467        client_lib_version: env!("CARGO_PKG_VERSION").into(),
468        peer_attestation_nonce: Vec::new(),
469        capability_token: Vec::new(),
470        client_keepalive_secs: 0,
471    };
472    let request_frame = Frame {
473        envelope_version: PROTOCOL_VERSION,
474        kind: FrameKind::Request as i32,
475        payload_protocol: CONTROL_PAYLOAD_PROTOCOL,
476        payload: hello.encode_to_vec(),
477        request_id: 1,
478        payload_encoding: PayloadEncoding::None as i32,
479        deadline_unix_ms: 0,
480        traceparent: String::new(),
481        tracestate: String::new(),
482    };
483    write_frame(stream, &request_frame.encode_to_vec())
484        .map_err(|err| format!("failed to write Hello frame: {err}"))?;
485    let response_bytes =
486        read_frame(stream).map_err(|err| format!("failed to read HelloReply frame: {err}"))?;
487    let response_frame = Frame::decode(response_bytes.as_slice())
488        .map_err(|err| format!("failed to decode response Frame: {err}"))?;
489    let reply = HelloReply::decode(response_frame.payload.as_slice())
490        .map_err(|err| format!("failed to decode HelloReply: {err}"))?;
491    match reply.result.ok_or("HelloReply carried no result")? {
492        HelloReplyResult::Negotiated(negotiated) => Ok(ProbeOutcome::Negotiated {
493            daemon_version: negotiated.daemon_version,
494            negotiated_protocol: negotiated.negotiated_protocol,
495            server_capabilities: negotiated.server_capabilities,
496        }),
497        HelloReplyResult::Refused(refused) => Ok(ProbeOutcome::Refused {
498            code: ErrorCode::try_from(refused.code).unwrap_or(ErrorCode::Unspecified),
499            daemon_min_protocol: refused.daemon_min_protocol,
500            daemon_max_protocol: refused.daemon_max_protocol,
501        }),
502    }
503}
504
505/// Render a capability bitmap with the registry's known bit names.
506pub fn describe_capabilities(bits: u64) -> String {
507    if bits == 0 {
508        return "none".to_string();
509    }
510    let mut names = Vec::new();
511    if bits & CAP_HANDLE_PASSING != 0 {
512        names.push("HANDLE_PASSING".to_string());
513    }
514    let unknown = bits & !CAP_HANDLE_PASSING;
515    if unknown != 0 {
516        names.push(format!("unknown:0x{unknown:x}"));
517    }
518    format!("0x{bits:x} [{}]", names.join(", "))
519}
520
521// ---------------------------------------------------------------------------
522// 3. Service-definition directory + per-file validation
523// ---------------------------------------------------------------------------
524
525/// Check the service-definition directory and every `.servicedef` in it.
526pub fn service_definition_checks(dir: &Path) -> Vec<DoctorCheck> {
527    const DIR_CHECK: &str = "servicedef:dir";
528    let display = dir.display();
529    if !dir.exists() {
530        return vec![DoctorCheck::warn(
531            DIR_CHECK,
532            format!("{display} does not exist (no service definitions installed)"),
533        )];
534    }
535    if !dir.is_dir() {
536        return vec![DoctorCheck::fail(
537            DIR_CHECK,
538            format!("{display} exists but is not a directory"),
539        )];
540    }
541    match secure_dir::private_dir_permissions_are_private(dir) {
542        Ok(true) => {}
543        Ok(false) => {
544            return vec![DoctorCheck::fail(
545                DIR_CHECK,
546                format!(
547                    "{display} has insecure permissions (must be current-user-only); \
548                     the broker refuses to load definitions from it"
549                ),
550            )];
551        }
552        Err(err) => {
553            return vec![DoctorCheck::fail(
554                DIR_CHECK,
555                format!("cannot inspect permissions of {display}: {err}"),
556            )];
557        }
558    }
559
560    let entries = match std::fs::read_dir(dir) {
561        Ok(entries) => entries,
562        Err(err) => {
563            return vec![DoctorCheck::fail(
564                DIR_CHECK,
565                format!("cannot enumerate {display}: {err}"),
566            )];
567        }
568    };
569    let mut files: Vec<PathBuf> = entries
570        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
571        .filter(|path| {
572            path.extension()
573                .map(|ext| ext == SERVICE_DEF_EXTENSION)
574                .unwrap_or(false)
575        })
576        .collect();
577    files.sort();
578
579    let mut checks = vec![DoctorCheck::pass(
580        DIR_CHECK,
581        format!(
582            "{display} (private, {} .{SERVICE_DEF_EXTENSION} file{})",
583            files.len(),
584            if files.len() == 1 { "" } else { "s" }
585        ),
586    )];
587
588    let loader = ServiceDefinitionLoader::new(dir);
589    for path in files {
590        let file_name = path
591            .file_name()
592            .map(|name| name.to_string_lossy().into_owned())
593            .unwrap_or_else(|| path.display().to_string());
594        let check_name = format!("servicedef:{file_name}");
595        let Some(service_name) = path
596            .file_stem()
597            .map(|stem| stem.to_string_lossy().into_owned())
598        else {
599            checks.push(DoctorCheck::fail(check_name, "file has no stem"));
600            continue;
601        };
602        match loader.load(&service_name) {
603            Ok(definition) => checks.push(DoctorCheck::pass(
604                check_name,
605                format!(
606                    "valid (service {:?}, binary {:?})",
607                    definition.service_name, definition.binary_path
608                ),
609            )),
610            Err(err) => checks.push(DoctorCheck::fail(check_name, err.to_string())),
611        }
612    }
613    checks
614}
615
616// ---------------------------------------------------------------------------
617// 4. Socket/pipe hygiene
618// ---------------------------------------------------------------------------
619
620/// Report stale `*.sock` files in the broker runtime directory (Unix).
621///
622/// A socket file counts as stale when connecting to it is refused —
623/// nothing is listening behind it. Doctor only reports the count; it
624/// never deletes anything.
625pub fn socket_hygiene_check() -> DoctorCheck {
626    const NAME: &str = "sockets:runtime-dir";
627    // Whether there is anything to be stale, asked of the transport rather
628    // than of the host: a named pipe has no directory entry and disappears
629    // with its last handle, so there is no residue to count. Branching on
630    // `cfg(windows)` said the same thing in terms that stop being true if a
631    // host ever changes transport.
632    if !crate::platform::ipc::endpoint_is_filesystem_backed() {
633        return DoctorCheck::pass(
634            NAME,
635            "not applicable: this host's endpoints leave no filesystem residue",
636        );
637    }
638    {
639        let Some(dir) = broker_runtime_dir() else {
640            return DoctorCheck::fail(NAME, "cannot derive broker runtime directory");
641        };
642        let display = dir.display();
643        if !dir.exists() {
644            return DoctorCheck::pass(NAME, format!("{display} does not exist (no sockets)"));
645        }
646        let entries = match std::fs::read_dir(&dir) {
647            Ok(entries) => entries,
648            Err(err) => {
649                return DoctorCheck::fail(NAME, format!("cannot enumerate {display}: {err}"));
650            }
651        };
652        let mut total = 0usize;
653        let mut stale = 0usize;
654        for path in entries.filter_map(|entry| entry.ok().map(|entry| entry.path())) {
655            if path.extension().map(|ext| ext == "sock").unwrap_or(false) {
656                total += 1;
657                let endpoint = path.to_string_lossy();
658                if let Err(err) = connect_local_socket(&endpoint) {
659                    if err.kind() == std::io::ErrorKind::ConnectionRefused {
660                        stale += 1;
661                    }
662                }
663            }
664        }
665        if stale == 0 {
666            DoctorCheck::pass(
667                NAME,
668                format!("{display}: {total} socket file(s), none stale"),
669            )
670        } else {
671            DoctorCheck::warn(
672                NAME,
673                format!(
674                    "{display}: {stale} of {total} socket file(s) are stale \
675                     (connect refused) — not deleted, doctor is read-only"
676                ),
677            )
678        }
679    }
680}
681
682/// Parent directory of the per-user broker sockets, derived from the
683/// shared-broker pipe path.
684///
685/// Returns `None` where the endpoint has no filesystem path at all, which is
686/// the same condition `socket_hygiene_check` tests before calling this -- the
687/// `pipe.unix` field is simply absent there.
688fn broker_runtime_dir() -> Option<PathBuf> {
689    let sid_hash = user_sid_hash().ok()?;
690    let pipe = shared_broker_pipe(&sid_hash).ok()?;
691    pipe.unix
692        .and_then(|path| path.parent().map(Path::to_path_buf))
693}
694
695// ---------------------------------------------------------------------------
696// 4b. Inode pressure on the daemon data dir filesystem (#390)
697// ---------------------------------------------------------------------------
698
699/// Free-inode fraction below which the check WARNs.
700const INODE_WARN_FREE_RATIO: f64 = 0.05;
701/// Free-inode fraction below which the check FAILs.
702const INODE_FAIL_FREE_RATIO: f64 = 0.01;
703
704/// Report inode usage/headroom of the daemon data dir filesystem.
705///
706/// Windows filesystems have no fixed inode table, so the check PASSes as
707/// not-applicable there instead of faking numbers. Same for Unix
708/// filesystems reporting a zero inode total (e.g. btrfs).
709pub fn inode_pressure_check() -> DoctorCheck {
710    const NAME: &str = "filesystem:inodes";
711    let dir = crate::client::paths::data_dir();
712    let display = dir.display();
713    match crate::broker::fs_health::daemon_data_dir_inode_usage() {
714        Ok(Some(usage)) => {
715            let free_ratio = if usage.total == 0 {
716                1.0
717            } else {
718                usage.free as f64 / usage.total as f64
719            };
720            let detail = format!(
721                "{display}: {} of {} inodes free ({:.1}% used)",
722                usage.free,
723                usage.total,
724                usage.used_ratio() * 100.0
725            );
726            if free_ratio < INODE_FAIL_FREE_RATIO {
727                DoctorCheck::fail(
728                    NAME,
729                    format!("{detail} — inode exhaustion imminent; daemon writes will ENOSPC"),
730                )
731            } else if free_ratio < INODE_WARN_FREE_RATIO {
732                DoctorCheck::warn(NAME, format!("{detail} — low inode headroom"))
733            } else {
734                DoctorCheck::pass(NAME, detail)
735            }
736        }
737        Ok(None) => DoctorCheck::pass(
738            NAME,
739            format!("{display}: filesystem has no fixed inode table (not applicable)"),
740        ),
741        Err(err) => DoctorCheck::warn(
742            NAME,
743            format!("cannot probe inode usage of {display}: {err}"),
744        ),
745    }
746}
747
748// ---------------------------------------------------------------------------
749// 5. Platform path budget
750// ---------------------------------------------------------------------------
751
752/// Slack (bytes) below the platform path limit that triggers a WARN.
753const PATH_BUDGET_WARN_SLACK: usize = 8;
754
755/// Check the longest standard pipe name (a backend pipe) against the
756/// platform path-length limit. This bit the test suite repeatedly on
757/// macOS, where `sun_path` is only 104 bytes.
758pub fn platform_path_budget_check() -> DoctorCheck {
759    const NAME: &str = "platform:path-budget";
760    let budget = crate::platform::ipc::endpoint_name_limit();
761    let (limit, limit_label) = (budget.max_bytes, budget.label);
762    let sid_hash = match user_sid_hash() {
763        Ok(hash) => hash,
764        Err(err) => {
765            return DoctorCheck::fail(NAME, format!("cannot compute user SID hash: {err}"));
766        }
767    };
768    // Backend pipes carry the longest standard suffix (32 hex chars), so
769    // they exhaust the budget first.
770    match backend_pipe(&sid_hash, &[0u8; 16]) {
771        Ok(pipe) => {
772            let Some(path) = pipe_path_string(pipe.windows, pipe.unix) else {
773                return DoctorCheck::fail(NAME, "derived pipe path has no platform form");
774            };
775            let len = path.len();
776            let detail =
777                format!("backend pipe path is {len} of {limit} bytes ({limit_label}): {path}");
778            if len + PATH_BUDGET_WARN_SLACK >= limit {
779                DoctorCheck::warn(
780                    NAME,
781                    format!("{detail} — within {PATH_BUDGET_WARN_SLACK} bytes of the limit"),
782                )
783            } else {
784                DoctorCheck::pass(NAME, detail)
785            }
786        }
787        Err(err @ PipePathError::PathTooLong { .. }) => DoctorCheck::fail(
788            NAME,
789            format!("derived backend pipe path exceeds the {limit_label} budget: {err}"),
790        ),
791        Err(err) => DoctorCheck::fail(NAME, format!("cannot derive backend pipe path: {err}")),
792    }
793}
794
795// ---------------------------------------------------------------------------
796// 6. systemd KillMode (#391)
797// ---------------------------------------------------------------------------
798
799/// WARN when running under a systemd unit whose KillMode would reap
800/// spawned children on unit stop (`control-group`, systemd's default), or
801/// when systemd-managed but the KillMode cannot be determined.
802pub fn systemd_killmode_check() -> DoctorCheck {
803    const NAME: &str = "platform:systemd-killmode";
804    use crate::systemd_killmode::{probe, KillModeAssessment};
805    let assessment = probe();
806    match assessment.warning() {
807        Some(warning) => DoctorCheck::warn(NAME, warning),
808        None => match assessment {
809            KillModeAssessment::Safe { unit, kill_mode } => DoctorCheck::pass(
810                NAME,
811                format!("systemd unit {unit} uses KillMode={kill_mode} (children survive stop)"),
812            ),
813            _ => DoctorCheck::pass(
814                NAME,
815                if crate::platform::host::process_cgroup().is_some() {
816                    "not running under systemd"
817                } else {
818                    "not applicable on this platform"
819                },
820            ),
821        },
822    }
823}
824
825// ---------------------------------------------------------------------------
826// 7. Version/build info
827// ---------------------------------------------------------------------------
828
829/// Report crate, protocol, and framing versions. Always PASS.
830pub fn version_check() -> DoctorCheck {
831    DoctorCheck::pass(
832        "build:version",
833        format!(
834            "running-process {} — protocol v{PROTOCOL_VERSION}, framing v{FRAMING_VERSION_V1}",
835            env!("CARGO_PKG_VERSION")
836        ),
837    )
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    fn check(status: DoctorStatus) -> DoctorCheck {
845        DoctorCheck {
846            name: "test:check".into(),
847            status,
848            detail: "detail".into(),
849        }
850    }
851
852    #[test]
853    fn exit_code_is_zero_without_failures() {
854        let report = DoctorReport {
855            checks: vec![check(DoctorStatus::Pass), check(DoctorStatus::Warn)],
856        };
857        assert!(!report.has_failures());
858        assert_eq!(report.exit_code(), 0);
859    }
860
861    #[test]
862    fn exit_code_is_one_with_any_failure() {
863        let report = DoctorReport {
864            checks: vec![check(DoctorStatus::Pass), check(DoctorStatus::Fail)],
865        };
866        assert!(report.has_failures());
867        assert_eq!(report.exit_code(), 1);
868    }
869
870    #[test]
871    fn isolated_converts_panics_into_fail_checks() {
872        let checks = isolated("area:test", || panic!("boom"));
873        assert_eq!(checks.len(), 1);
874        assert_eq!(checks[0].status, DoctorStatus::Fail);
875        assert!(checks[0].detail.contains("boom"));
876    }
877
878    #[test]
879    fn describe_capabilities_names_known_bits() {
880        assert_eq!(describe_capabilities(0), "none");
881        assert_eq!(describe_capabilities(1), "0x1 [HANDLE_PASSING]");
882        let mixed = describe_capabilities(0b11);
883        assert!(mixed.contains("HANDLE_PASSING"));
884        assert!(mixed.contains("unknown:0x2"));
885    }
886
887    #[test]
888    fn render_text_includes_summary_line() {
889        let report = DoctorReport {
890            checks: vec![check(DoctorStatus::Pass), check(DoctorStatus::Warn)],
891        };
892        let text = report.render_text();
893        assert!(text.contains("PASS"));
894        assert!(text.contains("WARN"));
895        assert!(text.contains("doctor: 2 checks — 1 pass, 1 warn, 0 fail"));
896    }
897}
898
899#[cfg(test)]
900#[path = "../tests/broker_doctor_coverage.rs"]
901mod coverage_tests;