reachability_core/telemetry/processors/
link_properties_state.rs

1// Copyright 2025 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// TODO(https://fxbug.dev/432298933): Localize the unused tag once reachability
6// starts reporting LinkProperties and LinkState time series data.
7#![allow(unused)]
8
9use fidl_fuchsia_net_interfaces_ext::PortClass;
10use fuchsia_inspect::Node as InspectNode;
11use fuchsia_inspect_contrib::id_enum::IdEnum;
12use fuchsia_sync::Mutex;
13use std::collections::HashMap;
14use std::sync::Arc;
15use windowed_stats::experimental::clock::Timed;
16use windowed_stats::experimental::series::interpolation::LastSample;
17use windowed_stats::experimental::series::metadata::{BitSetMap, BitSetNode};
18use windowed_stats::experimental::series::statistic::Union;
19use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
20use windowed_stats::experimental::serve::{InspectSender, InspectedTimeMatrix};
21
22use crate::{IpVersions, LinkState};
23
24// TODO(https://fxbug.dev/432299715): Share this definition with netcfg.
25//
26// The classification of the interface. This is not necessarily the same
27// as the PortClass of the interface.
28#[derive(Debug, PartialEq, Eq, Hash, Copy, Clone)]
29pub enum InterfaceType {
30    Ethernet,
31    WlanClient,
32    WlanAp,
33    Blackhole,
34    Bluetooth,
35    Virtual,
36}
37
38impl std::fmt::Display for InterfaceType {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        let name = format!("{:?}", self);
41        write!(f, "{}", name.to_lowercase())
42    }
43}
44
45// TODO(https://fxbug.dev/432301507): Read this from shared configuration.
46// TODO(https://fxbug.dev/432298588): Add Id and Name as alternate groupings.
47//
48// The specifier for how the time series are initialized. For example, if Type
49// is specified with Ethernet and WlanClient, then there will be a separate
50// time series for Ethernet and WlanClient updates, further broken down by
51// v4 and v6 protocols.
52pub enum InterfaceTimeSeriesGrouping {
53    Type(Vec<InterfaceType>),
54}
55
56// TODO(https://fxbug.dev/432298588): Add Id and Name as alternate groupings.
57//
58// The identifier for the interface. Used to determine which time series should
59// have updates applied.
60#[derive(Clone, Debug, Eq, Hash, PartialEq)]
61pub enum InterfaceIdentifier {
62    // An interface is expected to only have a single type.
63    Type(InterfaceType),
64}
65
66impl std::fmt::Display for InterfaceIdentifier {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        let name = match self {
69            Self::Type(ty) => format!("TYPE_{ty}"),
70        };
71        write!(f, "{}", name)
72    }
73}
74
75pub fn identifiers_from_port_class(port_class: PortClass) -> Vec<InterfaceIdentifier> {
76    match port_class {
77        PortClass::Ethernet => vec![InterfaceType::Ethernet],
78        PortClass::WlanClient => vec![InterfaceType::WlanClient],
79        PortClass::WlanAp => vec![InterfaceType::WlanAp],
80        PortClass::Blackhole => vec![InterfaceType::Blackhole],
81        PortClass::Loopback => vec![InterfaceType::Bluetooth, InterfaceType::Virtual],
82        PortClass::Virtual | PortClass::Ppp | PortClass::Bridge | PortClass::Lowpan => {
83            vec![InterfaceType::Virtual]
84        }
85    }
86    .into_iter()
87    .map(|port_class| InterfaceIdentifier::Type(port_class))
88    .collect()
89}
90
91#[derive(Debug)]
92enum TimeSeriesType {
93    // LinkProperties time series report `LinkProperties` structs, with each
94    // boolean field mapped to a bit in a bitset.
95    LinkProperties,
96    // LinkState time series report `LinkState` enum variants, with each
97    // variant mapped to a bit in a bitset.
98    LinkState,
99}
100
101impl std::fmt::Display for TimeSeriesType {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        let name = match *self {
104            Self::LinkProperties => "link_properties",
105            Self::LinkState => "link_state",
106        };
107        write!(f, "{}", name)
108    }
109}
110
111// A representation of an interface's provisioning status.
112#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
113pub struct LinkProperties {
114    // For IPv4, indicates the acquisition of an IPv4 address. For IPv6,
115    // indicates the acquisition of a non-link local IPv6 address.
116    pub has_address: bool,
117    pub has_default_route: bool,
118    // TODO(https://fxbug.dev/42175016): Perform DNS lookups across the
119    // specified network.
120    //
121    // This is currently a system-wide check. For example, if one interface
122    // can perform DNS resolution, then all of them will report `has_dns` true.
123    pub has_dns: bool,
124    // TODO(https://fxbug.dev/432303907): Separate the HTTP check from DNS.
125    // HTTP can succeed without requiring DNS to be enabled and functional.
126    //
127    // Whether a device can successfully use the HTTP protocol.
128    pub has_http_reachability: bool,
129}
130
131impl LinkProperties {
132    // Each field in the LinkProperties struct represents a single bit. The
133    // first field, `has_address` is the least significant bit. This function
134    // must maintain ordered alignment with `link_properties_metadata`
135    // in `InspectMetadataNode`.
136    fn to_bitset(&self) -> u64 {
137        let mut result = 0u64;
138        if self.has_address {
139            result |= 1;
140        }
141
142        if self.has_default_route {
143            result |= 1 << 1;
144        }
145
146        if self.has_dns {
147            result |= 1 << 2;
148        }
149
150        if self.has_http_reachability {
151            result |= 1 << 3;
152        }
153        result
154    }
155}
156
157#[cfg(test)]
158impl From<u64> for LinkProperties {
159    fn from(data: u64) -> Self {
160        Self {
161            has_address: (data & 1) == 1,
162            has_default_route: (data & 1 << 1) >= 1,
163            has_dns: (data & 1 << 2) >= 1,
164            has_http_reachability: (data & 1 << 3) >= 1,
165        }
166    }
167}
168
169impl IdEnum for LinkState {
170    type Id = u8;
171    // This function must maintain ordered alignment with `link_state_metadata`
172    // in `InspectMetadataNode`.
173    fn to_id(&self) -> Self::Id {
174        match self {
175            LinkState::None => 0,
176            LinkState::Removed => 1,
177            LinkState::Down => 2,
178            LinkState::Up => 3,
179            LinkState::Local => 4,
180            LinkState::Gateway => 5,
181            LinkState::Internet => 6,
182        }
183    }
184}
185
186#[cfg(test)]
187impl From<u64> for LinkState {
188    fn from(data: u64) -> Self {
189        match data {
190            0 => Self::None,
191            1 => Self::Removed,
192            2 => Self::Down,
193            3 => Self::Up,
194            4 => Self::Local,
195            5 => Self::Gateway,
196            6 => Self::Internet,
197            unknown => {
198                log::info!("invalid ordinal provided: {data:?}");
199                Self::None
200            }
201        }
202    }
203}
204
205// Create a time series for IPv4 and IPv6 stored in `IpVersions`.
206fn ip_versions_time_series<S: InspectSender>(
207    client: &S,
208    inspect_metadata_path: &str,
209    series_type: TimeSeriesType,
210    identifier: InterfaceIdentifier,
211) -> IpVersions<InspectedTimeMatrix<u64>> {
212    // Time series of the same type report the same metadata and can use the
213    // same metadata node.
214    let metadata_node = match series_type {
215        TimeSeriesType::LinkProperties => InspectMetadataNode::LINK_PROPERTIES,
216        TimeSeriesType::LinkState => InspectMetadataNode::LINK_STATE,
217    };
218    let bitset_node = BitSetNode::from_path(format!("{}/{}", inspect_metadata_path, metadata_node));
219    // A separate time matrix is created for IPv4 and IPv6.
220    IpVersions {
221        ipv4: single_time_matrix(
222            client,
223            format!("{}_v4_{}", series_type, identifier),
224            bitset_node.clone(),
225        ),
226        ipv6: single_time_matrix(client, format!("{}_v6_{}", series_type, identifier), bitset_node),
227    }
228}
229
230// Create a single u64 time matrix with a highly granular sampling profile.
231fn single_time_matrix<S: InspectSender>(
232    client: &S,
233    time_series_name: String,
234    bitset_node: BitSetNode,
235) -> InspectedTimeMatrix<u64> {
236    client.inspect_time_matrix_with_metadata(
237        time_series_name,
238        TimeMatrix::<Union<u64>, LastSample>::new(
239            SamplingProfile::highly_granular(),
240            LastSample::or(0),
241        ),
242        bitset_node,
243    )
244}
245
246impl IpVersions<InspectedTimeMatrix<u64>> {
247    // Helper functions to make logging based on protocol cleaner.
248    fn log_v4(&self, data: u64) {
249        self.ipv4.fold_or_log_error(Timed::now(data));
250    }
251
252    fn log_v6(&self, data: u64) {
253        self.ipv6.fold_or_log_error(Timed::now(data));
254    }
255}
256
257// State for tracking link properties and link state across an interface,
258// backed by time series.
259struct PerInterfaceTimeSeries {
260    // The most recent link properties for the interface.
261    link_properties: Arc<Mutex<IpVersions<LinkProperties>>>,
262    // Time matrix for tracking changes in `link_properties`.
263    link_properties_time_matrix: IpVersions<InspectedTimeMatrix<u64>>,
264    // The most recent link state for the interface.
265    link_state: Arc<Mutex<IpVersions<LinkState>>>,
266    // Time matrix for tracking changes in `link_state`.
267    link_state_time_matrix: IpVersions<InspectedTimeMatrix<u64>>,
268}
269
270impl PerInterfaceTimeSeries {
271    pub fn new<S: InspectSender>(
272        client: &S,
273        inspect_metadata_path: &str,
274        identifier: InterfaceIdentifier,
275    ) -> Self {
276        Self {
277            link_properties: Arc::new(Mutex::new(IpVersions::default())),
278            link_properties_time_matrix: ip_versions_time_series(
279                client,
280                inspect_metadata_path,
281                TimeSeriesType::LinkProperties,
282                identifier.clone(),
283            ),
284            link_state: Arc::new(Mutex::new(IpVersions::default())),
285            link_state_time_matrix: ip_versions_time_series(
286                client,
287                inspect_metadata_path,
288                TimeSeriesType::LinkState,
289                identifier,
290            ),
291        }
292    }
293
294    fn log_link_properties_v4(&self, link_properties: LinkProperties) {
295        self.link_properties_time_matrix.log_v4(link_properties.to_bitset());
296    }
297
298    fn log_link_properties_v6(&self, link_properties: LinkProperties) {
299        self.link_properties_time_matrix.log_v6(link_properties.to_bitset());
300    }
301
302    fn maybe_log_link_properties(&self, new: &IpVersions<LinkProperties>) {
303        let mut curr = self.link_properties.lock();
304
305        // TODO(https://fxbug.dev/432304519): `InterfaceType` groupings need to
306        // be specially handled. If multiple interfaces of the same type
307        // co-exist, then the one that is last updated will take precedence.
308        // It is desired that the 'highest' rating link properties of the
309        // provided type will be reported.
310        if new.ipv4 != curr.ipv4 {
311            curr.ipv4 = new.ipv4;
312            self.log_link_properties_v4(curr.ipv4);
313        }
314
315        if new.ipv6 != curr.ipv6 {
316            curr.ipv6 = new.ipv6;
317            self.log_link_properties_v6(curr.ipv6);
318        }
319    }
320
321    fn log_link_state_v4(&self, link_state: LinkState) {
322        self.link_state_time_matrix.log_v4(1 << (link_state.to_id() as u64));
323    }
324
325    fn log_link_state_v6(&self, link_state: LinkState) {
326        self.link_state_time_matrix.log_v6(1 << (link_state.to_id() as u64));
327    }
328
329    fn maybe_log_link_state(&self, new: &IpVersions<LinkState>) {
330        let mut curr = self.link_state.lock();
331
332        // TODO(https://fxbug.dev/432304519): See `maybe_log_link_properties`.
333        if new.ipv4 != curr.ipv4 {
334            curr.ipv4 = new.ipv4;
335            self.log_link_state_v4(curr.ipv4);
336        }
337
338        if new.ipv6 != curr.ipv6 {
339            curr.ipv6 = new.ipv6;
340            self.log_link_state_v6(curr.ipv6);
341        }
342    }
343}
344
345// The wrapper for the time series reporting for LinkProperties and LinkState.
346pub struct LinkPropertiesStateLogger {
347    // Tracks the provided `InterfaceIdentifier`s against the time series for
348    // that identifier. Entries are only created during initialization.
349    time_series_stats: HashMap<InterfaceIdentifier, PerInterfaceTimeSeries>,
350    inspect_metadata_node: InspectMetadataNode,
351}
352
353impl LinkPropertiesStateLogger {
354    pub fn new<S: InspectSender>(
355        inspect_metadata_node: &InspectNode,
356        inspect_metadata_path: &str,
357        interface_grouping: InterfaceTimeSeriesGrouping,
358        time_matrix_client: &S,
359    ) -> Self {
360        Self {
361            // Create a time series per interface type provided.
362            time_series_stats: match interface_grouping {
363                InterfaceTimeSeriesGrouping::Type(tys) => tys.into_iter().map(|ty| {
364                    let identifier = InterfaceIdentifier::Type(ty);
365                    (
366                        identifier.clone(),
367                        PerInterfaceTimeSeries::new(
368                            time_matrix_client,
369                            inspect_metadata_path,
370                            identifier,
371                        ),
372                    )
373                }),
374            }
375            .collect(),
376            inspect_metadata_node: InspectMetadataNode::new(inspect_metadata_node),
377        }
378    }
379
380    // Update an interface's `LinkProperties`. `interface_identifiers`
381    // represent the various ways that an interface can be identified. When an
382    // identifier matches an attribute that is being tracked in
383    // `time_series_stats`, attempt to log that `LinkProperties` update.
384    pub fn update_link_properties(
385        &self,
386        interface_identifiers: Vec<InterfaceIdentifier>,
387        link_properties: &IpVersions<LinkProperties>,
388    ) {
389        interface_identifiers.iter().for_each(|identifier| {
390            if let Some(time_series) = self.time_series_stats.get(identifier) {
391                time_series.maybe_log_link_properties(&link_properties);
392            }
393        });
394    }
395
396    // Update an interface's `LinkState`. `interface_identifiers` represent the
397    // various ways that an interface can be identified. When an identifier
398    // matches an attribute that is being tracked in `time_series_stats`,
399    // attempt to log that `LinkState` update.
400    pub fn update_link_state(
401        &self,
402        interface_identifiers: Vec<InterfaceIdentifier>,
403        link_state: &IpVersions<LinkState>,
404    ) {
405        interface_identifiers.iter().for_each(|identifier| {
406            if let Some(time_series) = self.time_series_stats.get(identifier) {
407                time_series.maybe_log_link_state(&link_state);
408            }
409        });
410    }
411}
412
413// Holds the inspect node children for the static metadata that correlates to
414// bits in each of the corresponding structs / enums.
415struct InspectMetadataNode {
416    link_properties: InspectNode,
417    link_state: InspectNode,
418}
419
420impl InspectMetadataNode {
421    const LINK_PROPERTIES: &'static str = "link_properties";
422    const LINK_STATE: &'static str = "link_state";
423
424    fn new(inspect_node: &InspectNode) -> Self {
425        let link_properties = inspect_node.create_child(Self::LINK_PROPERTIES);
426        let link_state = inspect_node.create_child(Self::LINK_STATE);
427
428        let link_properties_metadata = BitSetMap::from_ordered([
429            "has_address",
430            "has_default_route",
431            "has_dns",
432            "has_http_reachability",
433        ]);
434        let link_state_metadata = BitSetMap::from_ordered([
435            "None", "Removed", "Down", "Up", "Local", "Gateway", "Internet",
436        ]);
437
438        link_properties_metadata.record(&link_properties);
439        link_state_metadata.record(&link_state);
440
441        Self { link_properties, link_state }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448    use diagnostics_assertions::{assert_data_tree, AnyBytesProperty};
449
450    use crate::telemetry::testing::setup_test;
451    use windowed_stats::experimental::serve::serve_time_matrix_inspection;
452    use windowed_stats::experimental::testing::TimeMatrixCall;
453
454    #[fuchsia::test]
455    fn test_log_time_series_metadata_to_inspect() {
456        let mut harness = setup_test();
457
458        let (client, _server) = serve_time_matrix_inspection(
459            harness.inspect_node.create_child("link_properties_state"),
460        );
461        let _link_properties_state = LinkPropertiesStateLogger::new(
462            &harness.inspect_metadata_node,
463            &harness.inspect_metadata_path,
464            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
465            &client,
466        );
467
468        let tree = harness.get_inspect_data_tree();
469        assert_data_tree!(
470            @executor harness.exec,
471            tree,
472            root: contains {
473                test_stats: contains {
474                    metadata: {
475                        link_properties: {
476                            index: {
477                                "0": "has_address",
478                                "1": "has_default_route",
479                                "2": "has_dns",
480                                "3": "has_http_reachability",
481                            },
482                        },
483                        link_state: {
484                            index: {
485                                "0": "None",
486                                "1": "Removed",
487                                "2": "Down",
488                                "3": "Up",
489                                "4": "Local",
490                                "5": "Gateway",
491                                "6": "Internet",
492                            },
493                        },
494                    },
495                    link_properties_state: contains {
496                        link_properties_v4_TYPE_ethernet: {
497                            "type": "bitset",
498                            "data": AnyBytesProperty,
499                            metadata: {
500                                index_node_path: "root/test_stats/metadata/link_properties",
501                            }
502                        },
503                        link_properties_v6_TYPE_ethernet: {
504                            "type": "bitset",
505                            "data": AnyBytesProperty,
506                            metadata: {
507                                index_node_path: "root/test_stats/metadata/link_properties",
508                            }
509                        },
510                        link_state_v4_TYPE_ethernet: {
511                            "type": "bitset",
512                            "data": AnyBytesProperty,
513                            metadata: {
514                                index_node_path: "root/test_stats/metadata/link_state",
515                            }
516                        },
517                        link_state_v6_TYPE_ethernet: {
518                            "type": "bitset",
519                            "data": AnyBytesProperty,
520                            metadata: {
521                                index_node_path: "root/test_stats/metadata/link_state",
522                            }
523                        }
524                    }
525                }
526            }
527        )
528    }
529
530    #[fuchsia::test]
531    fn test_log_link_properties() {
532        let harness = setup_test();
533
534        let link_properties_state = LinkPropertiesStateLogger::new(
535            &harness.inspect_metadata_node,
536            &harness.inspect_metadata_path,
537            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
538            &harness.mock_time_matrix_client,
539        );
540
541        // Update the link properties with an interface type not present in the types
542        // provided to `LinkPropertiesStateLogger`.
543        link_properties_state.update_link_properties(
544            vec![InterfaceIdentifier::Type(InterfaceType::WlanClient)],
545            &IpVersions {
546                ipv4: LinkProperties { has_address: true, ..Default::default() },
547                ipv6: LinkProperties::default(),
548            },
549        );
550
551        // There should be no calls to the `TYPE_ethernet` time series since the
552        // update above was for `WlanClient`. There should be no calls to the
553        // `TYPE_wlanclient` field either since they were not initialized.
554        let mut time_matrix_calls = harness.mock_time_matrix_client.fold_buffered_samples();
555        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..], &[]);
556        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..], &[]);
557        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_wlanclient")[..], &[]);
558        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_wlanclient")[..], &[]);
559
560        // Update the link properties with a present interface type.
561        link_properties_state.update_link_properties(
562            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
563            &IpVersions {
564                ipv4: LinkProperties { has_address: true, ..Default::default() },
565                ipv6: LinkProperties {
566                    has_address: true,
567                    has_default_route: true,
568                    ..Default::default()
569                },
570            },
571        );
572
573        time_matrix_calls = harness.mock_time_matrix_client.fold_buffered_samples();
574        // The first bit is set for the v4 call, since `has_address` is true.
575        assert_eq!(
576            &time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..],
577            &[TimeMatrixCall::Fold(Timed::now(1 << 0)),]
578        );
579        // The first and second bit are set for the v6 call, since `has_address`
580        // and `has_default_route` are true.
581        assert_eq!(
582            &time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..],
583            &[TimeMatrixCall::Fold(Timed::now((1 << 0) | (1 << 1))),]
584        );
585
586        // Ensure that updating the same identifier with the same properties
587        // for v4 results in no calls to the v4 time matrix.
588        link_properties_state.update_link_properties(
589            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
590            &IpVersions {
591                ipv4: LinkProperties { has_address: true, ..Default::default() },
592                ipv6: LinkProperties {
593                    has_address: true,
594                    has_default_route: true,
595                    has_dns: true,
596                    ..Default::default()
597                },
598            },
599        );
600        time_matrix_calls = harness.mock_time_matrix_client.fold_buffered_samples();
601        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_ethernet")[..], &[]);
602        assert_eq!(
603            &time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_ethernet")[..],
604            &[TimeMatrixCall::Fold(Timed::now((1 << 0) | (1 << 1) | (1 << 2))),]
605        );
606    }
607
608    #[fuchsia::test]
609    fn test_log_link_state() {
610        let harness = setup_test();
611
612        let link_properties_state = LinkPropertiesStateLogger::new(
613            &harness.inspect_metadata_node,
614            &harness.inspect_metadata_path,
615            InterfaceTimeSeriesGrouping::Type(vec![InterfaceType::Ethernet]),
616            &harness.mock_time_matrix_client,
617        );
618
619        // Update the link state with an interface type not present in the types
620        // provided to `LinkPropertiesStateLogger`.
621        link_properties_state.update_link_state(
622            vec![InterfaceIdentifier::Type(InterfaceType::WlanClient)],
623            &IpVersions { ipv4: Default::default(), ipv6: LinkState::Gateway },
624        );
625
626        // There should be no calls to the `TYPE_ethernet` time series since the
627        // update above was for `WlanClient`. There should be no calls to the
628        // `TYPE_wlanclient` field either since they were not initialized.
629        let mut time_matrix_calls = harness.mock_time_matrix_client.fold_buffered_samples();
630        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..], &[]);
631        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..], &[]);
632        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v4_TYPE_wlanclient")[..], &[]);
633        assert_eq!(&time_matrix_calls.drain::<u64>("link_properties_v6_TYPE_wlanclient")[..], &[]);
634
635        // Update the link state with a present interface type.
636        link_properties_state.update_link_state(
637            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
638            &IpVersions { ipv4: LinkState::Internet, ipv6: LinkState::Local },
639        );
640
641        time_matrix_calls = harness.mock_time_matrix_client.fold_buffered_samples();
642        assert_eq!(
643            &time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..],
644            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Internet.to_id())),]
645        );
646        assert_eq!(
647            &time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..],
648            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Local.to_id())),]
649        );
650
651        // Ensure that updating the same identifier with the same properties
652        // for v4 results in no calls to the v4 time matrix.
653        link_properties_state.update_link_state(
654            vec![InterfaceIdentifier::Type(InterfaceType::Ethernet)],
655            &IpVersions { ipv4: LinkState::Internet, ipv6: LinkState::Gateway },
656        );
657        time_matrix_calls = harness.mock_time_matrix_client.fold_buffered_samples();
658        assert_eq!(&time_matrix_calls.drain::<u64>("link_state_v4_TYPE_ethernet")[..], &[]);
659        assert_eq!(
660            &time_matrix_calls.drain::<u64>("link_state_v6_TYPE_ethernet")[..],
661            &[TimeMatrixCall::Fold(Timed::now(1 << LinkState::Gateway.to_id())),]
662        );
663    }
664}