Skip to main content

netcfg/network/
reachability.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
5use assert_matches::assert_matches;
6use std::collections::HashMap;
7use std::collections::hash_map::Entry;
8
9use fidl::endpoints::{ControlHandle as _, RequestStream as _};
10use fidl_fuchsia_net_policy_socketproxy as fnp_socketproxy;
11use fidl_fuchsia_net_reachability as freachability;
12use log::{error, warn};
13
14use async_utils::stream::{WithEpitaph as _, WithTag as _};
15
16use super::{ConnectionStream, NetworkProperties};
17
18pub(crate) const MAX_REACHABILITY_WATCHERS: usize = 128;
19
20mod id {
21    use crate::network::connection_id;
22
23    // A `fuchsia.net.reachability.Monitor` watcher connection.
24    connection_id!(ReachabilityWatcherConnectionId => ReachabilityWatcherConnectionIdAllocator);
25}
26
27pub use id::ReachabilityWatcherConnectionId;
28use id::ReachabilityWatcherConnectionIdAllocator;
29
30#[derive(Debug)]
31struct ReachabilityWatcherClient {
32    /// Used to close the connection; see [`ReachabilityWatcherClient::close`].
33    control_handle: freachability::MonitorControlHandle,
34    /// The most recent snapshot observed by this client.
35    last_observed: Option<freachability::Snapshot>,
36    /// The responder for a hanging `Watch` call, if one is outstanding.
37    responder: Option<freachability::MonitorWatchResponder>,
38    /// Whether the client may still call `SetOptions`.
39    ///
40    /// `SetOptions` may only be called once, and only before the first
41    /// call to `Watch`.
42    can_set_options: bool,
43}
44
45impl ReachabilityWatcherClient {
46    /// Closes the client's connection, with `epitaph` if one is provided.
47    ///
48    /// The watcher is left in place: shutting the connection down guarantees its
49    /// [`ReachabilityStream`] yields the terminal item that the watcher is removed on.
50    fn close(&mut self, epitaph: Option<zx::Status>) {
51        // Shut down before releasing the responder below to communicate an epitaph
52        // when one is provided.
53        match epitaph {
54            Some(status) => self.control_handle.shutdown_with_epitaph(status),
55            None => self.control_handle.shutdown(),
56        }
57        // Drop the hanging responder, if any, so a closed client is no longer served.
58        self.responder = None;
59    }
60}
61
62/// Yields `(id, Some(request))` per request from the client, then exactly one `(id, None)` when
63/// the client's stream terminates, which is how the end of a connection is observed.
64pub(crate) type ReachabilityStream =
65    ConnectionStream<ReachabilityWatcherConnectionId, freachability::MonitorRequestStream>;
66
67#[derive(Default)]
68pub(crate) struct ReachabilityHandler {
69    watchers: HashMap<ReachabilityWatcherConnectionId, ReachabilityWatcherClient>,
70    next_id: ReachabilityWatcherConnectionIdAllocator,
71}
72
73impl ReachabilityHandler {
74    pub(crate) fn add_stream(
75        &mut self,
76        stream: freachability::MonitorRequestStream,
77    ) -> Option<ReachabilityStream> {
78        if self.watchers.len() >= MAX_REACHABILITY_WATCHERS {
79            warn!(
80                "Max reachability watchers ({MAX_REACHABILITY_WATCHERS}) reached; rejecting stream."
81            );
82            stream.control_handle().shutdown_with_epitaph(zx::Status::NO_RESOURCES);
83            return None;
84        }
85        let id = self.next_id.allocate();
86        let previous = self.watchers.insert(
87            id,
88            ReachabilityWatcherClient {
89                control_handle: stream.control_handle(),
90                last_observed: None,
91                responder: None,
92                can_set_options: true,
93            },
94        );
95        assert!(previous.is_none(), "reachability watcher {id:?} is already registered");
96        Some(stream.tagged(id).with_epitaph(id))
97    }
98
99    /// Synthesizes a reachability [`freachability::Snapshot`] from the active default network.
100    ///
101    /// On multi-network systems, the snapshot reflects the reachability and validation state of
102    /// the system's active default network, through which ambient, unbound network traffic is
103    /// routed.
104    pub(crate) fn synthesize_snapshot(
105        default_network: Option<&NetworkProperties>,
106    ) -> freachability::Snapshot {
107        // `ConnectivityState` does not report link-layer reachability directly, so
108        // `Monitor` states are inferred.
109        let (gateway_reachable, internet_available, dns_active, http_active) = match default_network
110            .and_then(|properties| properties.connectivity_state)
111        {
112            // Internet reachability has been verified end to end, including DNS resolution
113            // and HTTP/HTTPS fetches.
114            Some(fnp_socketproxy::ConnectivityState::FullConnectivity) => (true, true, true, true),
115            // The Internet is partially reachable. DNS resolution and at least one HTTP
116            // probe succeeded, but HTTPS probes were unsuccessful. We conservatively report this
117            // as a state with `http_active=false` since this probe did not provide strong evidence
118            // that all HTTP requests will be servicable.
119            Some(fnp_socketproxy::ConnectivityState::PartialConnectivity) => {
120                (true, true, true, false)
121            }
122            // The Internet is not reachable and we have not verified upper layer connectivity.
123            // LocalConnectivity implies that devices can be reached on the local network, but does
124            // not confirm that there is a gateway present and accessible.
125            Some(
126                fnp_socketproxy::ConnectivityState::LocalConnectivity
127                | fnp_socketproxy::ConnectivityState::NoConnectivity,
128            )
129            | None => (false, false, false, false),
130            Some(fnp_socketproxy::ConnectivityState::__SourceBreaking { unknown_ordinal }) => {
131                unreachable!(
132                    "New variants of ConnectivityState must be updated: {unknown_ordinal:?}"
133                )
134            }
135        };
136        freachability::Snapshot {
137            gateway_reachable: Some(gateway_reachable),
138            dns_active: Some(dns_active),
139            internet_available: Some(internet_available),
140            http_active: Some(http_active),
141            ..Default::default()
142        }
143    }
144
145    /// Handles a single item produced by a client's [`ReachabilityStream`].
146    ///
147    /// # Panics
148    ///
149    /// Panics if `id` does not correspond to a currently registered client. A client is removed
150    /// only when its stream yields the terminal `None` item, and the stream is fused, so every
151    /// item from the stream belongs to a live client.
152    pub(crate) fn handle_request(
153        &mut self,
154        current_snapshot: &freachability::Snapshot,
155        id: ReachabilityWatcherConnectionId,
156        request: Option<Result<freachability::MonitorRequest, fidl::Error>>,
157    ) {
158        let mut entry = assert_matches!(
159            self.watchers.entry(id),
160            Entry::Occupied(entry) => entry,
161            "request for unknown reachability watcher {id:?}"
162        );
163
164        let request = match request {
165            Some(Ok(request)) => request,
166            Some(Err(e)) => {
167                // A clean disconnect yields the terminal item below rather than an error, so
168                // this is always abnormal: a malformed request or a channel-level read
169                // failure. No epitaph is sent because the channel may no longer be writable.
170                error!("Reachability monitor client {id:?} stream error: {e}");
171                entry.get_mut().close(None);
172                return;
173            }
174            // The client's stream has terminated, either because the client went away or because
175            // of a `close` above. This is the only place watchers are removed.
176            None => {
177                let _: ReachabilityWatcherClient = entry.remove();
178                return;
179            }
180        };
181
182        let client = entry.get_mut();
183        match request {
184            freachability::MonitorRequest::SetOptions {
185                payload: freachability::MonitorOptions { __source_breaking },
186                control_handle: _,
187            } => {
188                if !client.can_set_options {
189                    warn!(
190                        "Client {id:?} called SetOptions after SetOptions or Watch; \
191                        closing channel."
192                    );
193                    client.close(Some(zx::Status::CONNECTION_ABORTED));
194                } else {
195                    client.can_set_options = false;
196                }
197            }
198            freachability::MonitorRequest::Watch { responder } => {
199                if client.responder.is_some() {
200                    warn!(
201                        "Client {id:?} called Watch while a previous Watch was pending; \
202                        closing channel."
203                    );
204                    client.close(Some(zx::Status::ALREADY_EXISTS));
205                    return;
206                }
207                client.can_set_options = false;
208                if client.last_observed.as_ref() != Some(current_snapshot) {
209                    client.last_observed = Some(current_snapshot.clone());
210                    if let Err(e) = responder.send(current_snapshot) {
211                        warn!("failed to send reachability snapshot to client {id:?}: {e}");
212                    }
213                } else {
214                    client.responder = Some(responder);
215                }
216            }
217        }
218    }
219
220    pub(crate) fn maybe_notify_watchers(&mut self, current_snapshot: &freachability::Snapshot) {
221        for (id, client) in self.watchers.iter_mut() {
222            if client.last_observed.as_ref() != Some(current_snapshot) {
223                if let Some(responder) = client.responder.take() {
224                    client.last_observed = Some(current_snapshot.clone());
225                    if let Err(e) = responder.send(current_snapshot) {
226                        warn!("failed to send updated reachability snapshot to client {id:?}: {e}");
227                    }
228                }
229            }
230        }
231    }
232
233    #[cfg(test)]
234    pub(crate) fn watcher_count(&self) -> usize {
235        self.watchers.len()
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::network::split_connection_item;
243    use futures::StreamExt as _;
244
245    /// The snapshot synthesized when there is no default network, or when it reports
246    /// `NoConnectivity` or `LocalConnectivity`.
247    fn disconnected_snapshot() -> freachability::Snapshot {
248        freachability::Snapshot {
249            gateway_reachable: Some(false),
250            dns_active: Some(false),
251            internet_available: Some(false),
252            http_active: Some(false),
253            ..Default::default()
254        }
255    }
256
257    /// The snapshot synthesized from a default network reporting `PartialConnectivity`.
258    fn limited_snapshot() -> freachability::Snapshot {
259        freachability::Snapshot {
260            gateway_reachable: Some(true),
261            dns_active: Some(true),
262            internet_available: Some(true),
263            http_active: Some(false),
264            ..Default::default()
265        }
266    }
267
268    /// The snapshot synthesized from a default network reporting `FullConnectivity`.
269    fn validated_snapshot() -> freachability::Snapshot {
270        freachability::Snapshot {
271            gateway_reachable: Some(true),
272            dns_active: Some(true),
273            internet_available: Some(true),
274            http_active: Some(true),
275            ..Default::default()
276        }
277    }
278
279    /// Dispatches every remaining item produced by `stream`, including its terminal item.
280    ///
281    /// Watchers are removed when their stream terminates, so a client that has been closed is
282    /// only reaped once its stream has been drained.
283    async fn drain_stream(
284        handler: &mut ReachabilityHandler,
285        current_snapshot: &freachability::Snapshot,
286        stream: &mut ReachabilityStream,
287    ) {
288        while let Some(item) = stream.next().await {
289            let (id, request) = split_connection_item(item);
290            handler.handle_request(current_snapshot, id, request);
291        }
292    }
293
294    #[test]
295    fn test_synthesize_snapshot() {
296        // Confirm that no network produces a fully disconnected snapshot.
297        assert_eq!(ReachabilityHandler::synthesize_snapshot(None), disconnected_snapshot());
298
299        // Confirm that a `NoConnectivity` connectivity state produces a disconnected snapshot.
300        let net_none = NetworkProperties {
301            connectivity_state: Some(fnp_socketproxy::ConnectivityState::NoConnectivity),
302            ..Default::default()
303        };
304        assert_eq!(
305            ReachabilityHandler::synthesize_snapshot(Some(&net_none)),
306            disconnected_snapshot()
307        );
308
309        // Confirm that a `LocalConnectivity` connectivity state produces a disconnected snapshot.
310        let net_local = NetworkProperties {
311            connectivity_state: Some(fnp_socketproxy::ConnectivityState::LocalConnectivity),
312            ..Default::default()
313        };
314        assert_eq!(
315            ReachabilityHandler::synthesize_snapshot(Some(&net_local)),
316            disconnected_snapshot()
317        );
318
319        // Confirm that a `PartialConnectivity` connectivity state reports DNS as active but
320        // HTTP as inactive.
321        let net_limited = NetworkProperties {
322            connectivity_state: Some(fnp_socketproxy::ConnectivityState::PartialConnectivity),
323            ..Default::default()
324        };
325        assert_eq!(
326            ReachabilityHandler::synthesize_snapshot(Some(&net_limited)),
327            limited_snapshot()
328        );
329
330        // Confirm that a `FullConnectivity` connectivity state produces a validated snapshot.
331        let net_full = NetworkProperties {
332            connectivity_state: Some(fnp_socketproxy::ConnectivityState::FullConnectivity),
333            ..Default::default()
334        };
335        assert_eq!(ReachabilityHandler::synthesize_snapshot(Some(&net_full)), validated_snapshot());
336    }
337
338    #[fuchsia::test]
339    async fn test_reachability_handler_hanging_get() {
340        let mut handler = ReachabilityHandler::default();
341        let disconnected = disconnected_snapshot();
342        let validated = validated_snapshot();
343
344        let (proxy1, stream1) =
345            fidl::endpoints::create_proxy_and_stream::<freachability::MonitorMarker>();
346        let mut s1 = handler.add_stream(stream1).expect("add stream");
347        assert_eq!(handler.watcher_count(), 1);
348
349        // Client 1 initial Watch() returns immediately with current snapshot.
350        let watch_fut1 = proxy1.watch();
351        let (id, req) = split_connection_item(s1.next().await.expect("stream item"));
352        handler.handle_request(&disconnected, id, req);
353        let snapshot = watch_fut1.await.expect("watch error");
354        assert_eq!(snapshot, disconnected);
355
356        // Client 2 connects
357        let (proxy2, stream2) =
358            fidl::endpoints::create_proxy_and_stream::<freachability::MonitorMarker>();
359        let mut s2 = handler.add_stream(stream2).expect("add stream");
360        assert_eq!(handler.watcher_count(), 2);
361
362        // Client 2 initial Watch() returns immediately with current snapshot.
363        let watch_fut2 = proxy2.watch();
364        let (id, req) = split_connection_item(s2.next().await.expect("stream item"));
365        handler.handle_request(&disconnected, id, req);
366        let snapshot2 = watch_fut2.await.expect("watch error");
367        assert_eq!(snapshot2, disconnected);
368
369        // Both clients call Watch() again: both will hang because snapshot hasn't changed.
370        let mut second_watch1 = proxy1.watch();
371        let (id, req) = split_connection_item(s1.next().await.expect("stream item"));
372        handler.handle_request(&disconnected, id, req);
373        assert_matches!(futures::poll!(&mut second_watch1), std::task::Poll::Pending);
374
375        let mut second_watch2 = proxy2.watch();
376        let (id, req) = split_connection_item(s2.next().await.expect("stream item"));
377        handler.handle_request(&disconnected, id, req);
378        assert_matches!(futures::poll!(&mut second_watch2), std::task::Poll::Pending);
379
380        // State changes to validated: both watchers unblock.
381        handler.maybe_notify_watchers(&validated);
382        let snap1 = second_watch1.await.expect("watch1 should succeed");
383        let snap2 = second_watch2.await.expect("watch2 should succeed");
384        assert_eq!(snap1, validated);
385        assert_eq!(snap2, validated);
386
387        // Client 2 disconnects, and is reaped once its stream terminates.
388        drop(proxy2);
389        drain_stream(&mut handler, &validated, &mut s2).await;
390        assert_matches!(futures::poll!(s2.next()), std::task::Poll::Ready(None));
391        assert_eq!(handler.watcher_count(), 1);
392    }
393
394    #[fuchsia::test]
395    async fn test_set_options_validation() {
396        let mut handler = ReachabilityHandler::default();
397        let disconnected = disconnected_snapshot();
398        let (proxy, stream) =
399            fidl::endpoints::create_proxy_and_stream::<freachability::MonitorMarker>();
400        let mut s = handler.add_stream(stream).expect("add stream");
401
402        // Calling SetOptions as the first call should succeed.
403        proxy.set_options(&freachability::MonitorOptions::default()).expect("set_options FIDL");
404        let (id, req) = split_connection_item(s.next().await.expect("stream item"));
405        handler.handle_request(&disconnected, id, req);
406        assert_eq!(handler.watcher_count(), 1);
407
408        // Calling SetOptions a second time aborts connection.
409        proxy.set_options(&freachability::MonitorOptions::default()).expect("set_options FIDL");
410        let (id, req) = split_connection_item(s.next().await.expect("stream item"));
411        handler.handle_request(&disconnected, id, req);
412
413        assert_matches!(
414            proxy.watch().await,
415            Err(fidl::Error::ClientChannelClosed { epitaph, .. })
416                if epitaph == zx::Status::CONNECTION_ABORTED
417        );
418
419        // The watcher is reaped once its now-shut-down stream terminates.
420        drain_stream(&mut handler, &disconnected, &mut s).await;
421        assert_matches!(futures::poll!(s.next()), std::task::Poll::Ready(None));
422        assert_eq!(handler.watcher_count(), 0);
423
424        // Calling SetOptions after calling Watch aborts connection.
425        let (proxy2, stream2) =
426            fidl::endpoints::create_proxy_and_stream::<freachability::MonitorMarker>();
427        let mut s2 = handler.add_stream(stream2).expect("add stream");
428
429        let watch_fut = proxy2.watch();
430        let (id, req) = split_connection_item(s2.next().await.expect("stream item"));
431        handler.handle_request(&disconnected, id, req);
432        let _ = watch_fut.await.expect("initial watch");
433        assert_eq!(handler.watcher_count(), 1);
434
435        proxy2.set_options(&freachability::MonitorOptions::default()).expect("set_options FIDL");
436        let (id, req) = split_connection_item(s2.next().await.expect("stream item"));
437        handler.handle_request(&disconnected, id, req);
438
439        assert_matches!(
440            proxy2.watch().await,
441            Err(fidl::Error::ClientChannelClosed { epitaph, .. })
442                if epitaph == zx::Status::CONNECTION_ABORTED
443        );
444
445        drain_stream(&mut handler, &disconnected, &mut s2).await;
446        assert_matches!(futures::poll!(s2.next()), std::task::Poll::Ready(None));
447        assert_eq!(handler.watcher_count(), 0);
448    }
449
450    #[fuchsia::test]
451    async fn test_concurrent_watch_closes_channel() {
452        let mut handler = ReachabilityHandler::default();
453        let disconnected = disconnected_snapshot();
454        let (proxy, stream) =
455            fidl::endpoints::create_proxy_and_stream::<freachability::MonitorMarker>();
456        let mut s = handler.add_stream(stream).expect("add stream");
457
458        // First watch consumes initial snapshot.
459        let watch_fut1 = proxy.watch();
460        let (id, req) = split_connection_item(s.next().await.expect("stream item"));
461        handler.handle_request(&disconnected, id, req);
462        let _ = watch_fut1.await.expect("initial watch");
463
464        // Second watch hangs.
465        let mut second_watch1 = proxy.watch();
466        let (id, req) = split_connection_item(s.next().await.expect("stream item"));
467        handler.handle_request(&disconnected, id, req);
468        assert_matches!(futures::poll!(&mut second_watch1), std::task::Poll::Pending);
469
470        // Illegal concurrent watch aborts the channel with ALREADY_EXISTS.
471        let second_watch2 = proxy.watch();
472        let (id, req) = split_connection_item(s.next().await.expect("stream item"));
473        handler.handle_request(&disconnected, id, req);
474
475        assert_matches!(
476            second_watch2.await,
477            Err(fidl::Error::ClientChannelClosed { epitaph, .. })
478                if epitaph == zx::Status::ALREADY_EXISTS
479        );
480
481        // Both the pending and the rejected watch are abandoned, and the watcher is reaped once
482        // its stream terminates.
483        assert_matches!(second_watch1.await, Err(fidl::Error::ClientChannelClosed { .. }));
484        drain_stream(&mut handler, &disconnected, &mut s).await;
485        assert_matches!(futures::poll!(s.next()), std::task::Poll::Ready(None));
486        assert_eq!(handler.watcher_count(), 0);
487    }
488
489    #[fuchsia::test]
490    async fn test_max_watchers_limit() {
491        let mut handler = ReachabilityHandler::default();
492        let mut proxies = Vec::new();
493        for _ in 0..MAX_REACHABILITY_WATCHERS {
494            let (proxy, stream) =
495                fidl::endpoints::create_proxy_and_stream::<freachability::MonitorMarker>();
496            assert!(handler.add_stream(stream).is_some());
497            proxies.push(proxy);
498        }
499        assert_eq!(handler.watcher_count(), MAX_REACHABILITY_WATCHERS);
500
501        // Next connection exceeds limit and is rejected with NO_RESOURCES.
502        let (overflow_proxy, overflow_stream) =
503            fidl::endpoints::create_proxy_and_stream::<freachability::MonitorMarker>();
504        assert!(handler.add_stream(overflow_stream).is_none());
505        assert_matches!(
506            overflow_proxy.watch().await,
507            Err(fidl::Error::ClientChannelClosed { epitaph, .. })
508                if epitaph == zx::Status::NO_RESOURCES
509        );
510    }
511}