Skip to main content

fdf_component/
health.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
5//! Support for running a driver's [`crate::Driver::health_check`] on behalf of the lifecycle
6//! server (the per-level deadlines and the root dispatcher liveness check that precedes every
7//! check), the [`HealthAlertReporter`] through which a driver proactively reports faults, and the
8//! opt-in [`HealthCheckInspect`] helper that mirrors results into Inspect.
9
10use std::sync::Arc;
11
12use fdf::{AsAsyncDispatcherRef, DriverDispatcherRef};
13use fidl_next::Server;
14use fidl_next_fuchsia_driver_framework::{
15    Driver as DriverProtocol, HealthAlert, HealthCheckError, HealthCheckRequest,
16    HealthCheckResponse, HealthDiagnosticItem, HealthDiagnosticValue, HealthStatus,
17};
18use fuchsia_sync::Mutex;
19use futures::channel::{mpsc, oneshot};
20use log::{debug, warn};
21
22use crate::Driver;
23use crate::server::Transport;
24
25/// The result of a single health check.
26pub type HealthCheckResult = Result<HealthCheckResponse, HealthCheckError>;
27
28// Per-level health check deadlines. These mirror the deadlines driver-host enforces for each
29// `HealthCheckRequest`; the server adds `HEALTH_CHECK_TIMEOUT_MARGIN` so the host's own TIMEOUT is
30// reported first, and the margin only bounds how long a hung check (or wedged root dispatcher) can
31// keep its future alive on the driver side.
32const PING_HEALTH_CHECK_DEADLINE: zx::MonotonicDuration = zx::MonotonicDuration::from_millis(250);
33const FAST_HEALTH_CHECK_DEADLINE: zx::MonotonicDuration = zx::MonotonicDuration::from_seconds(2);
34const DEEP_HEALTH_CHECK_DEADLINE: zx::MonotonicDuration = zx::MonotonicDuration::from_seconds(120);
35const HEALTH_CHECK_TIMEOUT_MARGIN: zx::MonotonicDuration = zx::MonotonicDuration::from_millis(100);
36
37/// Returns how long the server lets a check at `level` run before answering `TIMEOUT` itself,
38/// or `None` for unknown flexible enum levels.
39fn health_check_timeout(level: HealthCheckRequest) -> Option<zx::MonotonicDuration> {
40    let deadline = match level {
41        HealthCheckRequest::Ping => PING_HEALTH_CHECK_DEADLINE,
42        HealthCheckRequest::Fast => FAST_HEALTH_CHECK_DEADLINE,
43        HealthCheckRequest::Deep => DEEP_HEALTH_CHECK_DEADLINE,
44        _ => return None,
45    };
46    Some(deadline + HEALTH_CHECK_TIMEOUT_MARGIN)
47}
48
49/// Confirms that `root_dispatcher` is still processing tasks.
50///
51/// The lifecycle server runs on a dedicated fuchsia-async thread, not the root dispatcher, so
52/// unlike the C++ server (whose hook runs on the root dispatcher and therefore proves its liveness
53/// implicitly) it must ping the dispatcher explicitly. If the dispatcher is wedged this never
54/// completes, and the caller's deadline turns that into a `TIMEOUT`.
55async fn ping_root_dispatcher(
56    root_dispatcher: DriverDispatcherRef<'static>,
57) -> Result<(), HealthCheckError> {
58    let (tx, rx) = oneshot::channel();
59    root_dispatcher
60        .post_task_sync(move |status| {
61            if status.is_ok() {
62                let _ = tx.send(());
63            }
64        })
65        .map_err(|_| HealthCheckError::InvalidState)?;
66    // The sender is dropped without sending if the dispatcher is shutting down.
67    rx.await.map_err(|_| HealthCheckError::InvalidState)
68}
69
70/// Runs one health check to completion: confirms `root_dispatcher` is live, then invokes
71/// [`Driver::health_check`], bounded by the per-level deadline.
72pub(crate) async fn run_health_check<D: Driver>(
73    driver: &D,
74    root_dispatcher: DriverDispatcherRef<'static>,
75    request: HealthCheckRequest,
76) -> HealthCheckResult {
77    let Some(timeout) = health_check_timeout(request) else {
78        return Err(HealthCheckError::NotSupported);
79    };
80    let check = async move {
81        ping_root_dispatcher(root_dispatcher).await?;
82        driver.health_check(request).await
83    };
84    fuchsia_async::TimeoutExt::on_timeout(check, timeout, || Err(HealthCheckError::Timeout)).await
85}
86
87/// The maximum number of health alerts that can be queued in a standalone test receiver created by
88/// [`HealthAlertReporter::new_for_testing`].
89const HEALTH_ALERT_QUEUE_CAPACITY: usize = 64;
90
91enum HealthAlertSink {
92    /// Production sink: writes `OnHealthAlert` events synchronously into the `Driver` channel.
93    Server(Server<DriverProtocol, Transport>),
94    /// Standalone unit-test queue created by [`HealthAlertReporter::new_for_testing`].
95    TestQueue { tx: mpsc::Sender<HealthAlert>, dropped: u64 },
96    /// Disconnected sink (`Default::default` or after the driver has been asked to stop).
97    Closed,
98}
99
100/// Lets a driver proactively report faults to the driver framework as `OnHealthAlert` events.
101///
102/// Obtained from [`crate::DriverContext::health_alert_reporter`] while the driver is starting.
103/// Unlike the rest of the driver's lifecycle, alerts are driver-initiated and may come from any
104/// task or thread, so the reporter is cheap to clone and drivers typically keep one in their own
105/// state.
106#[derive(Clone)]
107pub struct HealthAlertReporter {
108    sink: Arc<Mutex<HealthAlertSink>>,
109}
110
111impl HealthAlertReporter {
112    /// Creates a reporter backed by the lifecycle server's `Driver` FIDL channel.
113    pub(crate) fn new(server: Server<DriverProtocol, Transport>) -> Self {
114        Self { sink: Arc::new(Mutex::new(HealthAlertSink::Server(server))) }
115    }
116
117    /// Disconnects the reporter so subsequent alerts are dropped.
118    pub(crate) fn close(&self) {
119        *self.sink.lock() = HealthAlertSink::Closed;
120    }
121
122    /// Creates a reporter and alert receiver pair for unit tests that construct a driver directly
123    /// without a lifecycle server.
124    pub fn new_for_testing() -> (Self, mpsc::Receiver<HealthAlert>) {
125        // A futures mpsc channel holds `buffer + number of senders` messages. All clones share a
126        // single sender, so this bounds the queue at exactly `HEALTH_ALERT_QUEUE_CAPACITY`.
127        let (tx, rx) = mpsc::channel(HEALTH_ALERT_QUEUE_CAPACITY - 1);
128        (Self { sink: Arc::new(Mutex::new(HealthAlertSink::TestQueue { tx, dropped: 0 })) }, rx)
129    }
130
131    /// Emits an `OnHealthAlert` event over the `Driver` channel to the driver framework.
132    ///
133    /// This never blocks: on a running driver the event is written synchronously to the underlying
134    /// [`fdf_fidl::DriverChannel`] via [`fidl_next::SendFuture::send_immediately`]; alerts
135    /// reported after the driver has been asked to stop are dropped.
136    ///
137    /// Alerts are not mirrored into Inspect automatically; a driver that records its health there
138    /// can pass `SelfFault` alerts to [`HealthCheckInspect::record_alert`] as well.
139    pub fn report_health_alert(&self, alert: HealthAlert) {
140        let mut sink = self.sink.lock();
141        match &mut *sink {
142            HealthAlertSink::Server(server) => {
143                let server = server.clone();
144                drop(sink);
145                if let Err(e) = server.on_health_alert(alert).send_immediately() {
146                    debug!("Dropping health alert on closed or failed driver channel: {e}");
147                }
148            }
149            HealthAlertSink::TestQueue { tx, dropped } => {
150                let Err(e) = tx.try_send(alert) else {
151                    return;
152                };
153                if e.is_full() {
154                    *dropped += 1;
155                    let total_dropped = *dropped;
156                    drop(sink);
157                    if total_dropped.is_power_of_two() {
158                        warn!("Health alert queue is full; dropped {total_dropped} alerts so far");
159                    }
160                } else {
161                    drop(sink);
162                    debug!("Dropping health alert because the receiver was dropped");
163                }
164            }
165            HealthAlertSink::Closed => {
166                drop(sink);
167                debug!("Dropping health alert because the driver server has shut down");
168            }
169        }
170    }
171}
172
173impl Default for HealthAlertReporter {
174    /// Returns a disconnected reporter: every alert reported through it is dropped (logged at
175    /// debug). Intended only for tests or placeholders; use
176    /// [`crate::DriverContext::health_alert_reporter`] in a running driver or
177    /// [`HealthAlertReporter::new_for_testing`] to observe alerts in a test.
178    fn default() -> Self {
179        Self { sink: Arc::new(Mutex::new(HealthAlertSink::Closed)) }
180    }
181}
182
183/// Name of the Inspect node that holds the most recent health check result or self-reported
184/// fault. Matches the C++ driver SDK (`root/driver_health_check`).
185pub const HEALTH_INSPECT_NODE_NAME: &str = "driver_health_check";
186
187/// Mirrors a driver's most recent health check result into its own Inspect hierarchy.
188///
189/// The Rust SDK does not record health check results into a driver's Inspect tree automatically.
190/// A driver that wants its latest health check visible in its own component Inspect hierarchy (at
191/// `root/driver_health_check`, matching the C++ driver SDK schema) creates one of these with the
192/// node it wants the record under and calls [`Self::record`] from its
193/// [`crate::Driver::health_check`]:
194///
195/// ```ignore
196/// async fn health_check(&self, request: HealthCheckRequest) -> HealthCheckResult {
197///     let result = self.check_hardware(request).await;
198///     self.health_inspect.record(request, &result);
199///     result
200/// }
201/// ```
202///
203/// Nothing here depends on how the driver publishes Inspect: it works the same with
204/// [`crate::DriverContext::publish_inspect`], with
205/// [`crate::DriverContext::inspect_content_publisher`], or with an inspector the driver serves
206/// itself. Drivers that build their Inspect tree on demand can instead call
207/// [`record_health_check_to_inspect`] while populating it.
208pub struct HealthCheckInspect {
209    parent: fuchsia_inspect::Node,
210    /// The current [`HEALTH_INSPECT_NODE_NAME`] child and metadata about what produced it.
211    current: Mutex<Option<CurrentRecord>>,
212}
213
214struct CurrentRecord {
215    _node: fuchsia_inspect::Node,
216    level: Option<HealthCheckRequest>,
217    is_informative: bool,
218}
219
220impl HealthCheckInspect {
221    /// Creates a recorder that writes its [`HEALTH_INSPECT_NODE_NAME`] child under `parent`.
222    ///
223    /// Only a weak reference to `parent` is kept: the driver stays the owner of its Inspect
224    /// tree, and records are silently dropped once `parent` has been removed from it.
225    pub fn new(parent: &fuchsia_inspect::Node) -> Self {
226        Self { parent: parent.clone_weak(), current: Mutex::new(None) }
227    }
228
229    /// Records the result of a health check at `level`.
230    ///
231    /// To avoid erasing diagnostic state with a routine liveness sweep or a skipped check,
232    /// non-informative errors ([`HealthCheckError::NotSupported`],
233    /// [`HealthCheckError::Unavailable`], [`HealthCheckError::InvalidState`]) and a healthy
234    /// [`HealthCheckRequest::Ping`] do not overwrite an existing informative record from a deeper
235    /// check.
236    pub fn record(&self, level: HealthCheckRequest, result: &HealthCheckResult) {
237        self.replace(Some(level), result.as_ref().map_err(|e| *e));
238    }
239
240    /// Records the details of a [`HealthAlert::SelfFault`] the driver is reporting through its
241    /// [`HealthAlertReporter`], replacing the previous record, so Inspect also reflects faults
242    /// the driver notices between health checks. Neighbour faults describe other drivers and are
243    /// ignored.
244    pub fn record_alert(&self, alert: &HealthAlert) {
245        if let HealthAlert::SelfFault(details) = alert {
246            self.replace(None, Ok(details));
247        }
248    }
249
250    fn replace(
251        &self,
252        level: Option<HealthCheckRequest>,
253        result: Result<&HealthCheckResponse, HealthCheckError>,
254    ) {
255        let is_no_info_error = matches!(
256            result,
257            Err(HealthCheckError::NotSupported
258                | HealthCheckError::Unavailable
259                | HealthCheckError::InvalidState)
260        );
261        let is_healthy_ping = level == Some(HealthCheckRequest::Ping)
262            && matches!(result, Ok(resp) if derive_health_status(resp) == HealthStatus::Ok);
263
264        let mut current = self.current.lock();
265        if let Some(prev) = current.as_ref()
266            && prev.is_informative
267        {
268            if is_no_info_error {
269                return;
270            }
271            if is_healthy_ping && prev.level != Some(HealthCheckRequest::Ping) {
272                return;
273            }
274        }
275        // The previous node must be dropped *before* the new one is created: Inspect nodes are
276        // removed from the hierarchy on drop, so assigning directly would briefly leave two
277        // same-named children under `parent`, which readers may observe.
278        *current = None;
279        *current = Some(CurrentRecord {
280            _node: record_health_check_to_inspect(&self.parent, level, result),
281            level,
282            is_informative: !is_no_info_error,
283        });
284    }
285}
286
287/// Derives the overall status of `response` from its conditions: any non-`OK`, non-`DEGRADED`
288/// condition makes the whole response `UNHEALTHY`; otherwise any `DEGRADED` condition makes it
289/// `DEGRADED`.
290fn derive_health_status(response: &HealthCheckResponse) -> HealthStatus {
291    let mut overall = HealthStatus::Ok;
292    if let Some(conditions) = &response.conditions {
293        for condition in conditions {
294            let status = condition.status.unwrap_or(HealthStatus::Unhealthy);
295            if status == HealthStatus::Degraded {
296                overall = HealthStatus::Degraded;
297            } else if status != HealthStatus::Ok {
298                return HealthStatus::Unhealthy;
299            }
300        }
301    }
302    overall
303}
304
305fn health_status_str(status: HealthStatus) -> &'static str {
306    match status {
307        HealthStatus::Ok => "OK",
308        HealthStatus::Degraded => "DEGRADED",
309        HealthStatus::Unhealthy => "UNHEALTHY",
310        _ => "UNKNOWN",
311    }
312}
313
314fn health_level_str(level: HealthCheckRequest) -> &'static str {
315    match level {
316        HealthCheckRequest::Ping => "PING",
317        HealthCheckRequest::Fast => "FAST",
318        HealthCheckRequest::Deep => "DEEP",
319        _ => "UNKNOWN",
320    }
321}
322
323fn health_error_str(error: HealthCheckError) -> &'static str {
324    match error {
325        HealthCheckError::Timeout => "TIMEOUT",
326        HealthCheckError::NotSupported => "NOT_SUPPORTED",
327        HealthCheckError::InternalError => "INTERNAL_ERROR",
328        HealthCheckError::InvalidState => "INVALID_STATE",
329        HealthCheckError::Unavailable => "UNAVAILABLE",
330        HealthCheckError::CommunicationError => "COMMUNICATION_ERROR",
331        HealthCheckError::NotFound => "NOT_FOUND",
332        _ => "UNKNOWN",
333    }
334}
335
336/// Records `diagnostics` as properties of a new `diagnostics` child of `parent`.
337fn record_health_diagnostics(parent: &fuchsia_inspect::Node, diagnostics: &[HealthDiagnosticItem]) {
338    parent.record_child("diagnostics", |diag_node| {
339        for item in diagnostics {
340            match &item.value {
341                HealthDiagnosticValue::StringValue(s) => diag_node.record_string(&item.key, s),
342                HealthDiagnosticValue::IntValue(i) => diag_node.record_int(&item.key, *i),
343                HealthDiagnosticValue::UintValue(u) => diag_node.record_uint(&item.key, *u),
344                HealthDiagnosticValue::BoolValue(b) => diag_node.record_bool(&item.key, *b),
345                HealthDiagnosticValue::Timestamp(t) => diag_node.record_int(&item.key, *t),
346                _ => {}
347            }
348        }
349    });
350}
351
352/// Records the result of a health check under a new [`HEALTH_INSPECT_NODE_NAME`] child of
353/// `parent` and returns it. Dropping the returned node removes the record again.
354///
355/// `level` is the requested level of the check, or `None` when the record does not come from a
356/// health check request (i.e. a self-reported fault). Errors are recorded as `status = "ERROR"`
357/// with an `error` property. The `diagnostics` and `conditions` children are only created when
358/// non-empty, matching the C++ driver SDK mirror.
359///
360/// Drivers that publish Inspect on demand (for example via
361/// [`crate::DriverContext::inspect_content_publisher`]) can call this directly when populating
362/// their tree; drivers with a long-lived inspector should prefer [`HealthCheckInspect`].
363pub fn record_health_check_to_inspect(
364    parent: &fuchsia_inspect::Node,
365    level: Option<HealthCheckRequest>,
366    result: Result<&HealthCheckResponse, HealthCheckError>,
367) -> fuchsia_inspect::Node {
368    let node = parent.create_child(HEALTH_INSPECT_NODE_NAME);
369    if let Some(level) = level {
370        node.record_string("level", health_level_str(level));
371    }
372    let response = match result {
373        Ok(response) => response,
374        Err(error) => {
375            node.record_string("status", "ERROR");
376            node.record_string("error", health_error_str(error));
377            return node;
378        }
379    };
380    node.record_string("status", health_status_str(derive_health_status(response)));
381    if let Some(diagnostics) = response.diagnostics.as_deref().filter(|d| !d.is_empty()) {
382        record_health_diagnostics(&node, diagnostics);
383    }
384    if let Some(conditions) = response.conditions.as_deref().filter(|c| !c.is_empty()) {
385        node.record_child("conditions", |conditions_node| {
386            for condition in conditions {
387                let reason_code = condition.reason_code.as_deref().unwrap_or("UNKNOWN");
388                conditions_node.record_child(reason_code, |condition_node| {
389                    let status = condition.status.unwrap_or(HealthStatus::Unhealthy);
390                    condition_node.record_string("status", health_status_str(status));
391                    if let Some(message) = &condition.message {
392                        condition_node.record_string("message", message);
393                    }
394                    if let Some(first_observed) = condition.first_observed {
395                        condition_node.record_int("first_observed", first_observed);
396                    }
397                    if let Some(last_observed) = condition.last_observed {
398                        condition_node.record_int("last_observed", last_observed);
399                    }
400                    if let Some(occurrence_count) = condition.occurrence_count {
401                        condition_node.record_uint("occurrence_count", occurrence_count);
402                    }
403                    if let Some(diagnostics) =
404                        condition.diagnostics.as_deref().filter(|d| !d.is_empty())
405                    {
406                        record_health_diagnostics(condition_node, diagnostics);
407                    }
408                });
409            }
410        });
411    }
412    node
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use diagnostics_assertions::assert_data_tree;
419    use fidl_next_fuchsia_driver_framework::HealthCondition;
420    use futures::StreamExt;
421
422    #[fuchsia::test]
423    async fn record_health_check_to_inspect_mirrors_full_response() {
424        let inspector = fuchsia_inspect::Inspector::default();
425        let response = HealthCheckResponse {
426            diagnostics: Some(vec![
427                HealthDiagnosticItem {
428                    key: "state".to_string(),
429                    value: HealthDiagnosticValue::StringValue("active".to_string()),
430                },
431                HealthDiagnosticItem {
432                    key: "underruns".to_string(),
433                    value: HealthDiagnosticValue::UintValue(2),
434                },
435                HealthDiagnosticItem {
436                    key: "temp".to_string(),
437                    value: HealthDiagnosticValue::IntValue(-3),
438                },
439                HealthDiagnosticItem {
440                    key: "powered".to_string(),
441                    value: HealthDiagnosticValue::BoolValue(true),
442                },
443                HealthDiagnosticItem {
444                    key: "last_irq".to_string(),
445                    value: HealthDiagnosticValue::Timestamp(99),
446                },
447            ]),
448            conditions: Some(vec![
449                HealthCondition {
450                    status: Some(HealthStatus::Unhealthy),
451                    reason_code: Some("DSI_UNLOCKED".to_string()),
452                    message: Some("DSI PHY lost lock".to_string()),
453                    first_observed: Some(10),
454                    last_observed: Some(20),
455                    occurrence_count: Some(3),
456                    diagnostics: Some(vec![HealthDiagnosticItem {
457                        key: "phy_status".to_string(),
458                        value: HealthDiagnosticValue::UintValue(7),
459                    }]),
460                },
461                HealthCondition {
462                    status: Some(HealthStatus::Degraded),
463                    reason_code: Some("TE_JITTER".to_string()),
464                    ..Default::default()
465                },
466            ]),
467        };
468        let _node = record_health_check_to_inspect(
469            inspector.root(),
470            Some(HealthCheckRequest::Deep),
471            Ok(&response),
472        );
473        assert_data_tree!(inspector, root: {
474            driver_health_check: {
475                level: "DEEP",
476                status: "UNHEALTHY",
477                diagnostics: {
478                    state: "active",
479                    underruns: 2u64,
480                    temp: -3i64,
481                    powered: true,
482                    last_irq: 99i64,
483                },
484                conditions: {
485                    DSI_UNLOCKED: {
486                        status: "UNHEALTHY",
487                        message: "DSI PHY lost lock",
488                        first_observed: 10i64,
489                        last_observed: 20i64,
490                        occurrence_count: 3u64,
491                        diagnostics: { phy_status: 7u64 },
492                    },
493                    TE_JITTER: { status: "DEGRADED" },
494                },
495            },
496        });
497    }
498
499    #[fuchsia::test]
500    async fn record_health_check_to_inspect_mirrors_error() {
501        let inspector = fuchsia_inspect::Inspector::default();
502        let node = record_health_check_to_inspect(
503            inspector.root(),
504            Some(HealthCheckRequest::Ping),
505            Err(HealthCheckError::NotSupported),
506        );
507        assert_data_tree!(inspector, root: {
508            driver_health_check: { level: "PING", status: "ERROR", error: "NOT_SUPPORTED" },
509        });
510
511        // Dropping the node removes the record; records without a level (self-reported alerts)
512        // omit the level property.
513        drop(node);
514        let _node =
515            record_health_check_to_inspect(inspector.root(), None, Err(HealthCheckError::Timeout));
516        assert_data_tree!(inspector, root: {
517            driver_health_check: { status: "ERROR", error: "TIMEOUT" },
518        });
519    }
520
521    #[fuchsia::test]
522    async fn derive_status_prefers_unhealthy_over_degraded() {
523        let response = HealthCheckResponse {
524            conditions: Some(vec![
525                HealthCondition { status: Some(HealthStatus::Degraded), ..Default::default() },
526                HealthCondition { status: Some(HealthStatus::Unhealthy), ..Default::default() },
527            ]),
528            ..Default::default()
529        };
530        assert_eq!(derive_health_status(&response), HealthStatus::Unhealthy);
531        assert_eq!(derive_health_status(&HealthCheckResponse::default()), HealthStatus::Ok);
532    }
533
534    #[fuchsia::test]
535    async fn health_check_inspect_replaces_and_retains_records() {
536        let inspector = fuchsia_inspect::Inspector::default();
537        let health_inspect = HealthCheckInspect::new(inspector.root());
538        assert_data_tree!(inspector, root: {});
539
540        // If the first check is a no-info error, it is still recorded so the node exists.
541        health_inspect.record(HealthCheckRequest::Deep, &Err(HealthCheckError::NotSupported));
542        assert_data_tree!(inspector, root: {
543            driver_health_check: { level: "DEEP", status: "ERROR", error: "NOT_SUPPORTED" },
544        });
545
546        // A subsequent informative PING replaces the uninformative error.
547        health_inspect.record(HealthCheckRequest::Ping, &Ok(HealthCheckResponse::default()));
548        assert_data_tree!(inspector, root: {
549            driver_health_check: { level: "PING", status: "OK" },
550        });
551
552        // A deeper FAST check replaces PING.
553        health_inspect.record(
554            HealthCheckRequest::Fast,
555            &Ok(HealthCheckResponse {
556                conditions: Some(vec![HealthCondition {
557                    status: Some(HealthStatus::Degraded),
558                    reason_code: Some("FIFO_WARN".to_string()),
559                    ..Default::default()
560                }]),
561                ..Default::default()
562            }),
563        );
564        assert_data_tree!(inspector, root: {
565            driver_health_check: {
566                level: "FAST",
567                status: "DEGRADED",
568                conditions: {
569                    FIFO_WARN: { status: "DEGRADED" },
570                },
571            },
572        });
573
574        // No-information errors (NOT_SUPPORTED, UNAVAILABLE, INVALID_STATE) and a healthy PING
575        // must not overwrite the informative FAST record.
576        health_inspect.record(HealthCheckRequest::Deep, &Err(HealthCheckError::NotSupported));
577        health_inspect.record(HealthCheckRequest::Fast, &Err(HealthCheckError::Unavailable));
578        health_inspect.record(HealthCheckRequest::Fast, &Err(HealthCheckError::InvalidState));
579        health_inspect.record(HealthCheckRequest::Ping, &Ok(HealthCheckResponse::default()));
580        assert_data_tree!(inspector, root: {
581            driver_health_check: {
582                level: "FAST",
583                status: "DEGRADED",
584                conditions: {
585                    FIFO_WARN: { status: "DEGRADED" },
586                },
587            },
588        });
589
590        // A real failure error does replace the FAST record.
591        health_inspect.record(HealthCheckRequest::Fast, &Err(HealthCheckError::CommunicationError));
592        assert_data_tree!(inspector, root: {
593            driver_health_check: {
594                level: "FAST",
595                status: "ERROR",
596                error: "COMMUNICATION_ERROR",
597            },
598        });
599
600        // Dropping the recorder removes the record.
601        drop(health_inspect);
602        assert_data_tree!(inspector, root: {});
603    }
604
605    #[fuchsia::test]
606    async fn self_fault_alert_replaces_inspect_node() {
607        let inspector = fuchsia_inspect::Inspector::default();
608        let health_inspect = HealthCheckInspect::new(inspector.root());
609
610        health_inspect.record_alert(&HealthAlert::SelfFault(HealthCheckResponse {
611            conditions: Some(vec![HealthCondition {
612                status: Some(HealthStatus::Unhealthy),
613                reason_code: Some("DMA_STALLED".to_string()),
614                ..Default::default()
615            }]),
616            ..Default::default()
617        }));
618        assert_data_tree!(inspector, root: {
619            driver_health_check: {
620                status: "UNHEALTHY",
621                conditions: { DMA_STALLED: { status: "UNHEALTHY" } },
622            },
623        });
624
625        // Neighbour faults are not this driver's own state, so they do not touch the record.
626        health_inspect
627            .record_alert(&HealthAlert::UpstreamNeighborFault(HealthCheckResponse::default()));
628        assert_data_tree!(inspector, root: {
629            driver_health_check: {
630                status: "UNHEALTHY",
631                conditions: { DMA_STALLED: { status: "UNHEALTHY" } },
632            },
633        });
634
635        // Recovery is reported with empty conditions and replaces the record with OK.
636        health_inspect.record_alert(&HealthAlert::SelfFault(HealthCheckResponse::default()));
637        assert_data_tree!(inspector, root: { driver_health_check: { status: "OK" } });
638    }
639
640    #[fuchsia::test]
641    async fn alerts_are_dropped_when_queue_is_full() {
642        let (reporter, alerts) = HealthAlertReporter::new_for_testing();
643        // Report through clones to check that they all share one bounded queue.
644        for _ in 0..(HEALTH_ALERT_QUEUE_CAPACITY + 5) {
645            reporter.clone().report_health_alert(HealthAlert::SelfFault(Default::default()));
646        }
647        match &*reporter.sink.lock() {
648            HealthAlertSink::TestQueue { dropped, .. } => assert_eq!(*dropped, 5),
649            _ => panic!("expected TestQueue"),
650        }
651        // Dropping the last reporter closes the queue, so `collect` drains what was accepted.
652        drop(reporter);
653        let received: Vec<_> = alerts.collect().await;
654        assert_eq!(received.len(), HEALTH_ALERT_QUEUE_CAPACITY);
655    }
656}