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