Skip to main content

driver_tools/subcommands/health_check/
mod.rs

1// Copyright 2026 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5pub mod args;
6
7use anyhow::{Result, bail, format_err};
8use args::{HealthCheckCommand, HealthCheckLevelArg};
9use flex_fuchsia_driver_development as fdd;
10use flex_fuchsia_driver_framework as fdf;
11#[cfg(not(feature = "fdomain"))]
12use fuchsia_driver_dev::get_device_info;
13#[cfg(feature = "fdomain")]
14use fuchsia_driver_dev_fdomain::get_device_info;
15use futures::StreamExt;
16use std::io::Write;
17
18/// Upper bound on the number of health checks in flight at once in `--all` mode.
19const MAX_CONCURRENT_CHECKS: usize = 8;
20
21impl From<HealthCheckLevelArg> for fdf::HealthCheckRequest {
22    fn from(level: HealthCheckLevelArg) -> Self {
23        match level {
24            HealthCheckLevelArg::Ping => fdf::HealthCheckRequest::Ping,
25            HealthCheckLevelArg::Fast => fdf::HealthCheckRequest::Fast,
26            HealthCheckLevelArg::Deep => fdf::HealthCheckRequest::Deep,
27        }
28    }
29}
30
31/// How a single health check outcome is tallied, and whether it should fail the command.
32#[derive(Clone, Copy, Debug, PartialEq)]
33enum Outcome {
34    Ok,
35    Degraded,
36    Unhealthy,
37    /// `TIMEOUT`, `INTERNAL_ERROR`, or a FIDL transport error: the driver or the framework is in
38    /// trouble.
39    Error,
40    /// `NOT_SUPPORTED`, `UNAVAILABLE`, `COMMUNICATION_ERROR`, `INVALID_STATE`, `NOT_FOUND`, or an
41    /// unknown status: the level could not be evaluated, which is not evidence of a problem and
42    /// therefore does not fail `--all` (a single explicit target still fails on anything but OK).
43    NoInfo,
44}
45
46fn derive_health_status(resp: &fdf::HealthCheckResponse) -> fdf::HealthStatus {
47    let mut overall = fdf::HealthStatus::Ok;
48    if let Some(conditions) = &resp.conditions {
49        for condition in conditions {
50            let status = condition.status.unwrap_or(fdf::HealthStatus::Unhealthy);
51            if status == fdf::HealthStatus::Degraded {
52                overall = fdf::HealthStatus::Degraded;
53            } else if status != fdf::HealthStatus::Ok {
54                return fdf::HealthStatus::Unhealthy;
55            }
56        }
57    }
58    overall
59}
60
61fn classify(res: &Result<fdf::HealthCheckResponse, fdf::HealthCheckError>) -> Outcome {
62    match res {
63        Ok(resp) => match derive_health_status(resp) {
64            fdf::HealthStatus::Ok => Outcome::Ok,
65            fdf::HealthStatus::Degraded => Outcome::Degraded,
66            fdf::HealthStatus::Unhealthy => Outcome::Unhealthy,
67            _ => Outcome::NoInfo,
68        },
69        Err(fdf::HealthCheckError::Timeout | fdf::HealthCheckError::InternalError) => {
70            Outcome::Error
71        }
72        Err(_) => Outcome::NoInfo,
73    }
74}
75
76#[derive(Default)]
77struct Summary {
78    ok: usize,
79    degraded: usize,
80    unhealthy: usize,
81    errors: usize,
82    no_info: usize,
83}
84
85impl Summary {
86    fn record(&mut self, outcome: Outcome) {
87        match outcome {
88            Outcome::Ok => self.ok += 1,
89            Outcome::Degraded => self.degraded += 1,
90            Outcome::Unhealthy => self.unhealthy += 1,
91            Outcome::Error => self.errors += 1,
92            Outcome::NoInfo => self.no_info += 1,
93        }
94    }
95
96    fn checked(&self) -> usize {
97        self.ok + self.degraded + self.unhealthy + self.errors + self.no_info
98    }
99
100    /// The number of nodes that reported a problem. "No information" outcomes never count.
101    fn problems(&self) -> usize {
102        self.degraded + self.unhealthy + self.errors
103    }
104}
105
106fn health_status_str(status: Option<fdf::HealthStatus>) -> &'static str {
107    match status {
108        Some(fdf::HealthStatus::Ok) => "OK",
109        Some(fdf::HealthStatus::Degraded) => "DEGRADED",
110        Some(fdf::HealthStatus::Unhealthy) => "UNHEALTHY",
111        _ => "UNKNOWN",
112    }
113}
114
115fn diagnostic_value_str(value: &fdf::HealthDiagnosticValue) -> String {
116    match value {
117        fdf::HealthDiagnosticValue::StringValue(s) => s.clone(),
118        fdf::HealthDiagnosticValue::IntValue(i) => i.to_string(),
119        fdf::HealthDiagnosticValue::UintValue(u) => format!("0x{:x} ({})", u, u),
120        fdf::HealthDiagnosticValue::BoolValue(b) => b.to_string(),
121        fdf::HealthDiagnosticValue::Timestamp(t) => format!("{} (boot ns)", t.into_nanos()),
122        _ => String::from("<unknown>"),
123    }
124}
125
126/// Formats a boot-timeline duration in nanoseconds as a short human-readable age.
127fn format_age(age_ns: i64) -> String {
128    const NS_PER_MS: i64 = 1_000_000;
129    const NS_PER_S: i64 = 1_000 * NS_PER_MS;
130    const NS_PER_MIN: i64 = 60 * NS_PER_S;
131    const NS_PER_HOUR: i64 = 60 * NS_PER_MIN;
132    if age_ns < 0 {
133        return String::from("in the future");
134    }
135    if age_ns < NS_PER_S {
136        format!("{}ms", age_ns / NS_PER_MS)
137    } else if age_ns < NS_PER_MIN {
138        format!("{:.1}s", age_ns as f64 / NS_PER_S as f64)
139    } else if age_ns < NS_PER_HOUR {
140        format!("{}m{}s", age_ns / NS_PER_MIN, (age_ns % NS_PER_MIN) / NS_PER_S)
141    } else {
142        format!("{}h{}m", age_ns / NS_PER_HOUR, (age_ns % NS_PER_HOUR) / NS_PER_MIN)
143    }
144}
145
146fn write_diagnostics(
147    writer: &mut dyn Write,
148    indent: &str,
149    diagnostics: &[fdf::HealthDiagnosticItem],
150) -> Result<()> {
151    for item in diagnostics {
152        writeln!(writer, "{}{} = {}", indent, item.key, diagnostic_value_str(&item.value))?;
153    }
154    Ok(())
155}
156
157/// Prints one health check result. `now_boot_ns` is the current boot-timeline instant on the
158/// target, used to turn `last_observed` timestamps into ages; when unknown (e.g. on the host) the
159/// raw timestamp is printed instead.
160fn print_response(
161    writer: &mut dyn Write,
162    target: &str,
163    res: &Result<fdf::HealthCheckResponse, fdf::HealthCheckError>,
164    now_boot_ns: Option<i64>,
165) -> Result<()> {
166    match res {
167        Ok(resp) => {
168            let status = derive_health_status(resp);
169            writeln!(writer, "[{}] Status: {}", target, health_status_str(Some(status)))?;
170            if let Some(conditions) = &resp.conditions {
171                if !conditions.is_empty() {
172                    writeln!(writer, "  Conditions:")?;
173                    for condition in conditions {
174                        let mut details = Vec::new();
175                        if let Some(last_observed) = condition.last_observed.map(|t| t.into_nanos())
176                        {
177                            match now_boot_ns {
178                                Some(now) => details.push(format!(
179                                    "last observed {} ago",
180                                    format_age(now - last_observed)
181                                )),
182                                None => details
183                                    .push(format!("last observed at {} boot ns", last_observed)),
184                            }
185                        }
186                        if let Some(count) = condition.occurrence_count {
187                            details.push(format!("{}x", count));
188                        }
189                        let suffix = if details.is_empty() {
190                            String::new()
191                        } else {
192                            format!("  ({})", details.join(", "))
193                        };
194                        writeln!(
195                            writer,
196                            "    [{}] {}{}",
197                            health_status_str(condition.status),
198                            condition.reason_code.as_deref().unwrap_or("-"),
199                            suffix
200                        )?;
201                        if let Some(message) = &condition.message {
202                            if !message.is_empty() {
203                                writeln!(writer, "        {}", message)?;
204                            }
205                        }
206                        if let Some(diagnostics) = &condition.diagnostics {
207                            write_diagnostics(writer, "        ", diagnostics)?;
208                        }
209                    }
210                }
211            }
212            if let Some(diagnostics) = &resp.diagnostics {
213                if !diagnostics.is_empty() {
214                    writeln!(writer, "  Diagnostics:")?;
215                    write_diagnostics(writer, "    ", diagnostics)?;
216                }
217            }
218        }
219        Err(err) => {
220            writeln!(writer, "[{}] ERROR: {:?}", target, err)?;
221        }
222    }
223    Ok(())
224}
225
226/// Returns the current boot-timeline instant when running on a Fuchsia target, where it shares a
227/// clock with the drivers' `last_observed` timestamps.
228fn current_boot_ns() -> Option<i64> {
229    #[cfg(target_os = "fuchsia")]
230    {
231        Some(zx::BootInstant::get().into_nanos())
232    }
233    #[cfg(not(target_os = "fuchsia"))]
234    {
235        None
236    }
237}
238
239pub async fn health_check(
240    cmd: HealthCheckCommand,
241    writer: &mut dyn Write,
242    driver_development_proxy: fdd::ManagerProxy,
243) -> Result<()> {
244    health_check_with_now(cmd, writer, driver_development_proxy, current_boot_ns).await
245}
246
247/// Runs the health check(s) described by `cmd`. `now_boot_ns` returns the current boot-timeline
248/// instant on the target (see `print_response`) and is sampled after each health check completes
249/// so conditions recorded during the check are never reported as in the future; condition ages are
250/// only computed when the tool runs on the target, a host `ffx` prints the raw boot-timeline
251/// nanoseconds instead.
252///
253/// Returns an error (after printing the results) when the single target did not report `OK`, or
254/// when any node checked by `--all` is `DEGRADED`, `UNHEALTHY`, or failed with `TIMEOUT`,
255/// `INTERNAL_ERROR`, or a transport error. "No information" errors (`NOT_SUPPORTED`,
256/// `UNAVAILABLE`, `COMMUNICATION_ERROR`, `INVALID_STATE`, `NOT_FOUND`) never fail `--all`.
257async fn health_check_with_now(
258    cmd: HealthCheckCommand,
259    writer: &mut dyn Write,
260    driver_development_proxy: fdd::ManagerProxy,
261    now_boot_ns: impl Fn() -> Option<i64>,
262) -> Result<()> {
263    let request: fdf::HealthCheckRequest = cmd.level.into();
264
265    let (monikers, is_all) = match (cmd.moniker, cmd.all) {
266        (Some(_), true) => {
267            bail!("`--all` cannot be combined with a moniker. Please specify one or the other.");
268        }
269        (Some(moniker), false) => (vec![moniker], false),
270        (None, true) => {
271            let nodes = get_device_info(&driver_development_proxy, &[], false).await?;
272            let monikers = nodes
273                .into_iter()
274                .filter(|node| {
275                    // Quarantined nodes failed to bind and have no running driver instance
276                    // (even though `bound_driver_url` retains the failed driver's URL), so skip
277                    // them alongside the placeholder URLs that driver_manager publishes for nodes
278                    // without a driver of their own (kUnboundUrl, kOwnedByParentUrl and
279                    // kCompositeParent in //src/devices/bin/driver_manager/node.cc).
280                    if node.quarantined == Some(true) {
281                        return false;
282                    }
283                    !matches!(
284                        node.bound_driver_url.as_deref(),
285                        None | Some("" | "unbound" | "owned by parent" | "owned by composite(s)")
286                    )
287                })
288                .filter_map(|node| node.moniker)
289                .collect();
290            (monikers, true)
291        }
292        (None, false) => {
293            bail!(
294                "Please specify the exact component moniker or topological path of a node, or pass `--all` to check all bound drivers."
295            );
296        }
297    };
298
299    // `buffered` bounds the number of in-flight checks while keeping the results in topology
300    // order. A transport error on one node is reported and counted rather than aborting the sweep.
301    let mut results = futures::stream::iter(monikers)
302        .map(|moniker| {
303            let proxy = &driver_development_proxy;
304            let now = &now_boot_ns;
305            async move {
306                let res = proxy.run_health_check(&moniker, request).await;
307                (moniker, res, now())
308            }
309        })
310        .buffered(MAX_CONCURRENT_CHECKS);
311
312    let mut summary = Summary::default();
313    while let Some((moniker, res, checked_at_boot_ns)) = results.next().await {
314        match res {
315            Ok(res) => {
316                print_response(writer, &moniker, &res, checked_at_boot_ns)?;
317                let outcome = classify(&res);
318                if !is_all {
319                    return match outcome {
320                        Outcome::Ok => Ok(()),
321                        _ => Err(format_err!("The health check of {} did not return OK.", moniker)),
322                    };
323                }
324                summary.record(outcome);
325            }
326            Err(e) => {
327                if !is_all {
328                    return Err(e.into());
329                }
330                writeln!(writer, "[{}] ERROR: {}", moniker, e)?;
331                summary.record(Outcome::Error);
332            }
333        }
334    }
335
336    writeln!(
337        writer,
338        "{} checked: ok {}, degraded {}, unhealthy {}, errors {}, no-info {}",
339        summary.checked(),
340        summary.ok,
341        summary.degraded,
342        summary.unhealthy,
343        summary.errors,
344        summary.no_info
345    )?;
346    if summary.problems() > 0 {
347        bail!("{} of {} drivers reported a problem.", summary.problems(), summary.checked());
348    }
349    Ok(())
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use anyhow::Context;
356    use argh::FromArgs;
357    use flex_client::fidl::ServerEnd;
358    use fuchsia_async as fasync;
359    use futures::future::{Future, FutureExt};
360    use std::sync::Arc;
361    use std::sync::atomic::{AtomicI64, Ordering};
362
363    /// Runs the command against a fake driver development service and returns the command's
364    /// result together with everything it printed.
365    async fn run_health_check_test<F, Fut>(
366        #[cfg(feature = "fdomain")] client: Arc<flex_client::Client>,
367        cmd: HealthCheckCommand,
368        on_driver_development_request: F,
369    ) -> (Result<()>, String)
370    where
371        F: Fn(fdd::ManagerRequest) -> Fut + Send + Sync + 'static,
372        Fut: Future<Output = Result<()>> + Send + Sync,
373    {
374        #[cfg(not(feature = "fdomain"))]
375        let client = flex_client::fidl::ZirconClient;
376        let (driver_development_proxy, mut driver_development_requests) =
377            client.create_proxy_and_stream::<fdd::ManagerMarker>();
378
379        let mut writer = Vec::new();
380        let clock_boot_ns = Arc::new(AtomicI64::new(5_000_000_000));
381        let request_clock = Arc::clone(&clock_boot_ns);
382        let request_handler_task = fasync::Task::spawn(async move {
383            while let Some(res) = driver_development_requests.next().await {
384                let request = res.context("Failed to get next request")?;
385                // Advance the clock while handling `RunHealthCheck` so any `last_observed`
386                // timestamp recorded during the check (e.g. 8.5s) would appear "in the future"
387                // if `now` had been captured before the RPC started (at 5s).
388                if matches!(request, fdd::ManagerRequest::RunHealthCheck { .. }) {
389                    request_clock.store(10_000_000_000, Ordering::SeqCst);
390                }
391                on_driver_development_request(request).await.context("Failed to handle request")?;
392            }
393            anyhow::bail!("Driver development request stream unexpectedly closed");
394        });
395        let result = futures::select! {
396            res = request_handler_task.fuse() => {
397                res.and_then(|()| Err::<(), _>(format_err!("Request handler task unexpectedly finished")))
398            }
399            res = health_check_with_now(
400                cmd,
401                &mut writer,
402                driver_development_proxy,
403                || Some(clock_boot_ns.load(Ordering::SeqCst)),
404            ).fuse() => {
405                res
406            }
407        };
408
409        let output = String::from_utf8(writer).expect("health_check output is valid UTF-8");
410        (result, output)
411    }
412
413    #[fuchsia::test]
414    fn test_format_age() {
415        assert_eq!(format_age(-1), "in the future");
416        assert_eq!(format_age(250_000_000), "250ms");
417        assert_eq!(format_age(1_500_000_000), "1.5s");
418        assert_eq!(format_age(90_000_000_000), "1m30s");
419        assert_eq!(format_age(3_660_000_000_000), "1h1m");
420    }
421
422    #[fuchsia::test]
423    async fn test_health_check_single_driver_and_missing_target() {
424        #[cfg(feature = "fdomain")]
425        let client = fdomain_local::local_client_empty();
426
427        // 1. Missing moniker without --all fails immediately.
428        let empty_cmd = HealthCheckCommand::from_args(&["health-check"], &[]).unwrap();
429        let (result, _) = run_health_check_test(
430            #[cfg(feature = "fdomain")]
431            Arc::clone(&client),
432            empty_cmd,
433            |_request: fdd::ManagerRequest| async move { Ok(()) },
434        )
435        .await;
436        let err = result.expect_err("a missing moniker without --all must fail");
437        assert!(err.to_string().contains("--all"), "unexpected error: {err}");
438
439        // 2. A moniker together with --all is rejected rather than silently ignoring --all.
440        let both_cmd =
441            HealthCheckCommand::from_args(&["health-check"], &["vivante-display", "--all"])
442                .unwrap();
443        let (result, _) = run_health_check_test(
444            #[cfg(feature = "fdomain")]
445            Arc::clone(&client),
446            both_cmd,
447            |_request: fdd::ManagerRequest| async move { anyhow::bail!("Unexpected request") },
448        )
449        .await;
450        let err = result.expect_err("a moniker combined with --all must fail");
451        assert!(err.to_string().contains("cannot be combined"), "unexpected error: {err}");
452
453        // 3. Single driver health check formats status, reason code, message, and diagnostics,
454        //    and a non-OK status makes the command fail.
455        let cmd = HealthCheckCommand::from_args(
456            &["health-check"],
457            &["vivante-display", "--level", "deep"],
458        )
459        .unwrap();
460        let (result, output) = run_health_check_test(
461            #[cfg(feature = "fdomain")]
462            Arc::clone(&client),
463            cmd,
464            |request: fdd::ManagerRequest| async move {
465                match request {
466                    fdd::ManagerRequest::RunHealthCheck { moniker, request, responder } => {
467                        assert_eq!(moniker, "vivante-display");
468                        assert_eq!(request, fdf::HealthCheckRequest::Deep);
469                        let resp = fdf::HealthCheckResponse {
470                            conditions: Some(vec![
471                                fdf::HealthCondition {
472                                    status: Some(fdf::HealthStatus::Degraded),
473                                    reason_code: Some(String::from("FIFO_UNDERRUN")),
474                                    message: Some(String::from("Recovered from underrun")),
475                                    diagnostics: Some(vec![fdf::HealthDiagnosticItem {
476                                        key: String::from("underruns"),
477                                        value: fdf::HealthDiagnosticValue::UintValue(2),
478                                    }]),
479                                    last_observed: Some(fidl::BootInstant::from_nanos(
480                                        8_500_000_000,
481                                    )),
482                                    occurrence_count: Some(2),
483                                    ..Default::default()
484                                },
485                                fdf::HealthCondition {
486                                    status: Some(fdf::HealthStatus::Ok),
487                                    reason_code: Some(String::from("TE_JITTER")),
488                                    ..Default::default()
489                                },
490                            ]),
491                            diagnostics: Some(vec![
492                                fdf::HealthDiagnosticItem {
493                                    key: String::from("pipe_state"),
494                                    value: fdf::HealthDiagnosticValue::StringValue(String::from(
495                                        "active",
496                                    )),
497                                },
498                                fdf::HealthDiagnosticItem {
499                                    key: String::from("drift_ms"),
500                                    value: fdf::HealthDiagnosticValue::IntValue(-4),
501                                },
502                                fdf::HealthDiagnosticItem {
503                                    key: String::from("status_reg"),
504                                    value: fdf::HealthDiagnosticValue::UintValue(0x1a),
505                                },
506                                fdf::HealthDiagnosticItem {
507                                    key: String::from("phy_locked"),
508                                    value: fdf::HealthDiagnosticValue::BoolValue(true),
509                                },
510                                fdf::HealthDiagnosticItem {
511                                    key: String::from("last_vsync"),
512                                    value: fdf::HealthDiagnosticValue::Timestamp(
513                                        fidl::BootInstant::from_nanos(9_000_000_000),
514                                    ),
515                                },
516                            ]),
517                            ..Default::default()
518                        };
519                        responder.send(Ok(&resp)).context("Failed to send response")?;
520                    }
521                    _ => anyhow::bail!("Unexpected request"),
522                }
523                Ok(())
524            },
525        )
526        .await;
527
528        assert!(result.is_err(), "a DEGRADED status must fail the command");
529        assert_eq!(
530            output,
531            concat!(
532                "[vivante-display] Status: DEGRADED\n",
533                "  Conditions:\n",
534                "    [DEGRADED] FIFO_UNDERRUN  (last observed 1.5s ago, 2x)\n",
535                "        Recovered from underrun\n",
536                "        underruns = 0x2 (2)\n",
537                "    [OK] TE_JITTER\n",
538                "  Diagnostics:\n",
539                "    pipe_state = active\n",
540                "    drift_ms = -4\n",
541                "    status_reg = 0x1a (26)\n",
542                "    phy_locked = true\n",
543                "    last_vsync = 9000000000 (boot ns)\n",
544            )
545        );
546
547        // 4. An OK status (empty conditions) succeeds.
548        let cmd = HealthCheckCommand::from_args(&["health-check"], &["vivante-display"]).unwrap();
549        let (result, output) = run_health_check_test(
550            #[cfg(feature = "fdomain")]
551            Arc::clone(&client),
552            cmd,
553            |request: fdd::ManagerRequest| async move {
554                match request {
555                    fdd::ManagerRequest::RunHealthCheck { request, responder, .. } => {
556                        // The level defaults to FAST.
557                        assert_eq!(request, fdf::HealthCheckRequest::Fast);
558                        let resp = fdf::HealthCheckResponse::default();
559                        responder.send(Ok(&resp)).context("Failed to send response")?;
560                    }
561                    _ => anyhow::bail!("Unexpected request"),
562                }
563                Ok(())
564            },
565        )
566        .await;
567        result.unwrap();
568        assert_eq!(output, "[vivante-display] Status: OK\n");
569
570        // 5. A health check error is printed and fails the command.
571        let cmd = HealthCheckCommand::from_args(&["health-check"], &["vivante-display"]).unwrap();
572        let (result, output) = run_health_check_test(
573            #[cfg(feature = "fdomain")]
574            Arc::clone(&client),
575            cmd,
576            |request: fdd::ManagerRequest| async move {
577                match request {
578                    fdd::ManagerRequest::RunHealthCheck { responder, .. } => {
579                        responder
580                            .send(Err(fdf::HealthCheckError::Timeout))
581                            .context("Failed to send response")?;
582                    }
583                    _ => anyhow::bail!("Unexpected request"),
584                }
585                Ok(())
586            },
587        )
588        .await;
589        assert!(result.is_err(), "a TIMEOUT error must fail the command");
590        assert_eq!(output, "[vivante-display] ERROR: Timeout\n");
591
592        // 6. A NOT_FOUND error is printed and fails a single-target command.
593        let cmd = HealthCheckCommand::from_args(&["health-check"], &["nonexistent"]).unwrap();
594        let (result, output) = run_health_check_test(
595            #[cfg(feature = "fdomain")]
596            Arc::clone(&client),
597            cmd,
598            |request: fdd::ManagerRequest| async move {
599                match request {
600                    fdd::ManagerRequest::RunHealthCheck { responder, .. } => {
601                        responder
602                            .send(Err(fdf::HealthCheckError::NotFound))
603                            .context("Failed to send response")?;
604                    }
605                    _ => anyhow::bail!("Unexpected request"),
606                }
607                Ok(())
608            },
609        )
610        .await;
611        assert!(result.is_err(), "a NOT_FOUND error must fail the command");
612        assert_eq!(output, "[nonexistent] ERROR: NotFound\n");
613    }
614
615    fn node_info(id: u64, moniker: &str, bound_driver_url: Option<&str>) -> fdd::NodeInfo {
616        fdd::NodeInfo {
617            id: Some(id),
618            moniker: Some(String::from(moniker)),
619            bound_driver_url: bound_driver_url.map(String::from),
620            ..Default::default()
621        }
622    }
623
624    /// Serves `nodes` over the node info iterator, then responds to each health check with the
625    /// result returned by `respond`.
626    async fn handle_all_request<R>(
627        request: fdd::ManagerRequest,
628        nodes: Vec<fdd::NodeInfo>,
629        respond: R,
630    ) -> Result<()>
631    where
632        R: Fn(&str) -> Result<fdf::HealthCheckResponse, fdf::HealthCheckError>,
633    {
634        match request {
635            fdd::ManagerRequest::GetNodeInfo { iterator, .. } => {
636                let mut device_infos = nodes;
637                let mut stream =
638                    ServerEnd::<fdd::NodeInfoIteratorMarker>::from(iterator).into_stream();
639                while let Some(Ok(fdd::NodeInfoIteratorRequest::GetNext { responder })) =
640                    stream.next().await
641                {
642                    responder.send(&device_infos)?;
643                    device_infos.clear();
644                }
645            }
646            fdd::ManagerRequest::RunHealthCheck { moniker, request, responder } => {
647                assert_eq!(request, fdf::HealthCheckRequest::Ping);
648                responder.send(respond(&moniker).as_ref().map_err(|e| *e))?;
649            }
650            _ => anyhow::bail!("Unexpected request"),
651        }
652        Ok(())
653    }
654
655    #[fuchsia::test]
656    async fn test_health_check_all_bound_drivers() {
657        #[cfg(feature = "fdomain")]
658        let client = fdomain_local::local_client_empty();
659        let cmd =
660            HealthCheckCommand::from_args(&["health-check"], &["--all", "-l", "ping"]).unwrap();
661
662        // Only nodes with a real bound driver are checked; quarantined nodes and placeholder URLs
663        // are skipped.
664        let (result, output) = run_health_check_test(
665            #[cfg(feature = "fdomain")]
666            Arc::clone(&client),
667            cmd,
668            |request: fdd::ManagerRequest| async move {
669                let mut quarantined_node =
670                    node_info(5, "dev.quarantined", Some("fuchsia-boot:///#meta/broken.cm"));
671                quarantined_node.quarantined = Some(true);
672                let nodes = vec![
673                    node_info(1, "dev.none", None),
674                    node_info(2, "dev.unbound", Some("unbound")),
675                    node_info(3, "dev.owned", Some("owned by parent")),
676                    node_info(4, "dev.composite_parent", Some("owned by composite(s)")),
677                    quarantined_node,
678                    node_info(6, "dev.display", Some("fuchsia-boot:///#meta/display.cm")),
679                ];
680                handle_all_request(request, nodes, |moniker| {
681                    assert_eq!(moniker, "dev.display");
682                    Ok(fdf::HealthCheckResponse::default())
683                })
684                .await
685            },
686        )
687        .await;
688        result.unwrap();
689        assert_eq!(
690            output,
691            concat!(
692                "[dev.display] Status: OK\n",
693                "1 checked: ok 1, degraded 0, unhealthy 0, errors 0, no-info 0\n",
694            )
695        );
696
697        // Results are printed in topology order. "No information" errors do not fail the sweep,
698        // UNHEALTHY and TIMEOUT do.
699        let cmd =
700            HealthCheckCommand::from_args(&["health-check"], &["--all", "-l", "ping"]).unwrap();
701        let (result, output) = run_health_check_test(
702            #[cfg(feature = "fdomain")]
703            Arc::clone(&client),
704            cmd,
705            |request: fdd::ManagerRequest| async move {
706                let nodes = vec![
707                    node_info(1, "dev.display", Some("fuchsia-boot:///#meta/display.cm")),
708                    node_info(2, "dev.touch", Some("fuchsia-boot:///#meta/touch.cm")),
709                    node_info(3, "dev.i2c", Some("fuchsia-boot:///#meta/i2c.cm")),
710                    node_info(4, "dev.spi", Some("fuchsia-boot:///#meta/spi.cm")),
711                ];
712                handle_all_request(request, nodes, |moniker| match moniker {
713                    "dev.display" => Ok(fdf::HealthCheckResponse::default()),
714                    "dev.touch" => Err(fdf::HealthCheckError::NotSupported),
715                    "dev.i2c" => Ok(fdf::HealthCheckResponse {
716                        conditions: Some(vec![fdf::HealthCondition {
717                            status: Some(fdf::HealthStatus::Unhealthy),
718                            reason_code: Some(String::from("BUS_LOCKUP")),
719                            ..Default::default()
720                        }]),
721                        ..Default::default()
722                    }),
723                    "dev.spi" => Err(fdf::HealthCheckError::Timeout),
724                    other => panic!("Unexpected moniker {other}"),
725                })
726                .await
727            },
728        )
729        .await;
730        assert!(result.is_err(), "UNHEALTHY and TIMEOUT outcomes must fail the sweep");
731        assert_eq!(
732            output,
733            concat!(
734                "[dev.display] Status: OK\n",
735                "[dev.touch] ERROR: NotSupported\n",
736                "[dev.i2c] Status: UNHEALTHY\n",
737                "  Conditions:\n",
738                "    [UNHEALTHY] BUS_LOCKUP\n",
739                "[dev.spi] ERROR: Timeout\n",
740                "4 checked: ok 1, degraded 0, unhealthy 1, errors 1, no-info 1\n",
741            )
742        );
743    }
744}