Skip to main content

hopr_transport_probe/
probe.rs

1use std::sync::Arc;
2
3use futures::{FutureExt, SinkExt, StreamExt};
4use futures_concurrency::stream::StreamExt as _;
5use hopr_api::{
6    ct::{ProbeRouting, ProbingTrafficGeneration},
7    graph::{EdgeTransportTelemetry, NetworkGraphError, NetworkGraphUpdate, NetworkGraphView},
8    types::{
9        crypto::types::OffchainPublicKey, crypto_random::Randomizable, internal::prelude::*,
10        primitive::traits::AsUnixTimestamp,
11    },
12};
13use hopr_protocol_app::{
14    prelude::{ApplicationDataIn, ApplicationDataOut, OutgoingPacketInfo, ReservedTag},
15    v1::Tag,
16};
17use hopr_transport_tag_allocator::{AllocatedTag, TagAllocator};
18use hopr_utils::{platform::time::native::current_time, runtime::AbortableList};
19
20use crate::{
21    HoprProbeProcess,
22    config::ProbeConfig,
23    content::Message,
24    ping::PingQueryReplier,
25    types::{NeighborProbe, NeighborTelemetry, PathTelemetry},
26};
27
28type CacheNeighborKey = (HoprPseudonym, NeighborProbe);
29type CacheNeighborValue = (Box<NodeId>, std::time::Duration, Option<PingQueryReplier>);
30
31/// Result of classifying one incoming message through the probe layer.
32pub enum ProbeDispatch {
33    /// The message was a probe message and has been consumed internally.
34    Consumed,
35    /// The message was not related to probing; caller should route it further.
36    Passthrough(HoprPseudonym, ApplicationDataIn),
37}
38
39/// Shared state used to classify incoming messages as probe or non-probe.
40///
41/// Obtained from [`Probe::continuously_scan`]. Use [`filter_stream`](ProbeClassifierState::filter_stream)
42/// to wrap an incoming stream so that probe messages are consumed internally and non-probe messages
43/// are yielded to the caller.
44#[derive(Clone)]
45pub struct ProbeClassifierState<G> {
46    active_neighbor_probes: moka::future::Cache<CacheNeighborKey, CacheNeighborValue>,
47    active_path_probes: moka::future::Cache<Tag, (PathTelemetry, Arc<AllocatedTag>)>,
48    network_graph: G,
49}
50
51impl<G> ProbeClassifierState<G>
52where
53    G: NetworkGraphUpdate + Clone + Send + Sync + 'static,
54{
55    /// Classify one incoming `(pseudonym, data)` pair.
56    ///
57    /// The `push_to_network` sink is used to send pong replies when the message is a Ping.
58    /// Returns `Consumed` if the message was a probe, or `Passthrough` for all other messages.
59    pub async fn classify<T>(
60        &self,
61        mut push_to_network: T,
62        pseudonym: HoprPseudonym,
63        in_data: ApplicationDataIn,
64    ) -> ProbeDispatch
65    where
66        T: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Unpin + Send + 'static,
67        T::Error: Send,
68    {
69        let tag: Tag = in_data.data.application_tag;
70
71        if let Some((path_telemetry, _allocated_tag)) = self.active_path_probes.remove(&tag).await {
72            tracing::debug!(%tag, "loopback probe successfully received");
73            self.network_graph
74                .record_edge::<NeighborTelemetry, PathTelemetry>(hopr_api::graph::MeasurableEdge::Probe(Ok(
75                    EdgeTransportTelemetry::Loopback(path_telemetry),
76                )));
77        } else if tag == ReservedTag::Ping.into() {
78            let message: anyhow::Result<Message> = in_data
79                .data
80                .try_into()
81                .map_err(|e| anyhow::anyhow!("failed to convert data into message: {e}"));
82
83            match message {
84                Ok(message) => match message {
85                    Message::Telemetry(_) => {
86                        tracing::warn!(%pseudonym, "received telemetry on reserved ping tag, ignoring");
87                    }
88                    Message::Probe(NeighborProbe::Ping(ping)) => {
89                        tracing::debug!(%pseudonym, nonce = const_hex::encode(ping), "received ping");
90                        tracing::trace!(%pseudonym, nonce = const_hex::encode(ping), "wrapping a pong in the found SURB");
91
92                        let message = Message::Probe(NeighborProbe::Pong(ping));
93                        if let Ok(data) = message.try_into() {
94                            let routing = DestinationRouting::Return(pseudonym.into());
95                            let data = ApplicationDataOut::with_no_packet_info(data);
96                            if let Err(_error) = push_to_network.send((routing, data)).await {
97                                tracing::error!(%pseudonym, "failed to send back a pong");
98                            }
99                        } else {
100                            tracing::error!(%pseudonym, "failed to convert pong message into data");
101                        }
102                    }
103                    Message::Probe(NeighborProbe::Pong(pong)) => {
104                        tracing::debug!(%pseudonym, nonce = const_hex::encode(pong), "received pong");
105                        if let Some((peer, start, replier)) = self
106                            .active_neighbor_probes
107                            .remove(&(pseudonym, NeighborProbe::Ping(pong)))
108                            .await
109                        {
110                            let latency = current_time().as_unix_timestamp().saturating_sub(start);
111
112                            if let NodeId::Offchain(opk) = peer.as_ref() {
113                                tracing::debug!(%pseudonym, nonce = const_hex::encode(pong), latency_ms = latency.as_millis(), "probe successful");
114                                self.network_graph.record_edge::<NeighborTelemetry, PathTelemetry>(
115                                    hopr_api::graph::MeasurableEdge::Probe(Ok(EdgeTransportTelemetry::Neighbor(
116                                        NeighborTelemetry {
117                                            peer: *opk,
118                                            rtt: latency,
119                                        },
120                                    ))),
121                                )
122                            } else {
123                                tracing::warn!(%pseudonym, nonce = const_hex::encode(pong), latency_ms = latency.as_millis(), "probe successful to non-offchain peer");
124                            }
125
126                            if let Some(replier) = replier {
127                                replier.notify(Ok(latency));
128                            }
129                        } else {
130                            tracing::warn!(%pseudonym, nonce = const_hex::encode(pong), possible_reasons = "[timeout, adversary]", "received pong for unknown probe");
131                        }
132                    }
133                },
134                Err(error) => tracing::error!(%pseudonym, %error, "cannot deserialize message"),
135            }
136        } else {
137            return ProbeDispatch::Passthrough(pseudonym, in_data);
138        }
139
140        ProbeDispatch::Consumed
141    }
142
143    /// Wraps `stream` as an in-place filter: probe messages are handled internally (telemetry,
144    /// pong replies via `push_to_network`), non-probe messages are yielded.
145    pub fn filter_stream<T, S>(
146        self,
147        push_to_network: T,
148        stream: S,
149    ) -> impl futures::Stream<Item = (HoprPseudonym, ApplicationDataIn)>
150    where
151        T: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Unpin + Send + Sync + 'static,
152        T::Error: Send,
153        S: futures::Stream<Item = (HoprPseudonym, ApplicationDataIn)>,
154    {
155        use futures::StreamExt;
156        stream.filter_map(move |(pseudonym, data)| {
157            let state = self.clone();
158            let push = push_to_network.clone();
159            async move {
160                match state.classify(push, pseudonym, data).await {
161                    ProbeDispatch::Consumed => None,
162                    ProbeDispatch::Passthrough(ps, d) => Some((ps, d)),
163                }
164            }
165        })
166    }
167}
168
169/// Probe functionality builder.
170///
171/// The builder holds information about this node's own addresses and the configuration for the probing process. It is
172/// then used to construct the probing process itself.
173pub struct Probe {
174    /// Probe configuration.
175    cfg: ProbeConfig,
176    /// Tag allocator for probing telemetry tags.
177    tag_allocator: Arc<dyn TagAllocator + Send + Sync>,
178}
179
180impl Probe {
181    pub fn new(cfg: ProbeConfig, tag_allocator: Arc<dyn TagAllocator + Send + Sync>) -> Self {
182        Self { cfg, tag_allocator }
183    }
184
185    /// The main function that assembles and starts the probing process.
186    ///
187    /// Returns the abortable list of background tasks (probe emission) and a
188    /// [`ProbeClassifierState`] for inline classification of incoming messages.
189    /// Use [`ProbeClassifierState::filter_stream`] to wrap the incoming stream.
190    pub async fn continuously_scan<T, V, Tr, G>(
191        self,
192        api_out: T,       // lower tx channel for sending outgoing probes and pong replies
193        manual_events: V, // explicit requests from the API
194        probing_traffic_generator: Tr,
195        network_graph: G,
196    ) -> (AbortableList<HoprProbeProcess>, ProbeClassifierState<G>)
197    where
198        T: futures::Sink<(DestinationRouting, ApplicationDataOut)> + Clone + Send + Sync + Unpin + 'static,
199        T::Error: Send,
200        V: futures::Stream<Item = (OffchainPublicKey, PingQueryReplier)> + Send + 'static,
201        Tr: ProbingTrafficGeneration + Send + Sync + 'static,
202        G: NetworkGraphView + NetworkGraphUpdate + Clone + Send + Sync + 'static,
203    {
204        let max_parallel_probes = self.cfg.max_parallel_probes;
205
206        let probing_routes = probing_traffic_generator.build();
207
208        // Currently active probes
209        let network_graph_internal_neighbor = network_graph.clone();
210        let network_graph_internal_path = network_graph.clone();
211        let timeout = self.cfg.timeout;
212        let active_neighbor_probes: moka::future::Cache<CacheNeighborKey, CacheNeighborValue> =
213            moka::future::Cache::builder()
214                .time_to_live(timeout)
215                .max_capacity(100_000)
216                .async_eviction_listener(
217                    move |k: Arc<CacheNeighborKey>,
218                          v: CacheNeighborValue,
219                          cause|
220                          -> moka::notification::ListenerFuture {
221                        if matches!(cause, moka::notification::RemovalCause::Expired) {
222                            // If the eviction cause is expiration => record as a failed probe
223                            let store = network_graph_internal_neighbor.clone();
224                            let (peer, _start, notifier) = v;
225
226                            tracing::debug!(%peer, pseudonym = %k.0, probe = %k.1, reason = "timeout", "neighbor probe failed");
227                            if let Some(replier) = notifier {
228                                if matches!(peer.as_ref(), NodeId::Offchain(_)) {
229                                    replier.notify(Err(()));
230                                } else {
231                                    tracing::warn!(
232                                        reason = "non-offchain peer",
233                                        "cannot notify timeout for non-offchain peer"
234                                    );
235                                }
236                            };
237
238                            if let NodeId::Offchain(opk) = peer.as_ref() {
239                                let opk: OffchainPublicKey = *opk;
240                                store
241                                    .record_edge::<NeighborTelemetry, PathTelemetry>(
242                                        hopr_api::graph::MeasurableEdge::Probe(Err(
243                                            NetworkGraphError::ProbeNeighborTimeout(Box::new(opk)),
244                                        )),
245                                    );
246                                futures::FutureExt::boxed(futures::future::ready(()))
247
248                            } else {
249                                futures::FutureExt::boxed(futures::future::ready(()))
250                            }
251                        } else {
252                            // If the eviction cause is not expiration, nothing needs to be done
253                            futures::FutureExt::boxed(futures::future::ready(()))
254                        }
255                    },
256                )
257                .build();
258
259        let active_path_probes: moka::future::Cache<Tag, (PathTelemetry, Arc<AllocatedTag>)> =
260            moka::future::Cache::builder()
261                .time_to_live(timeout)
262                .max_capacity(100_000)
263                .async_eviction_listener(
264                    move |tag: Arc<Tag>,
265                          (path, _allocated_tag): (PathTelemetry, Arc<AllocatedTag>),
266                          cause|
267                          -> moka::notification::ListenerFuture {
268                        if matches!(cause, moka::notification::RemovalCause::Expired) {
269                            // If the eviction cause is expiration => record as a failed probe
270                            let store = network_graph_internal_path.clone();
271
272                            tracing::debug!(%tag, reason = "timeout", "loopback probe failed");
273
274                            store.record_edge::<NeighborTelemetry, PathTelemetry>(
275                                hopr_api::graph::MeasurableEdge::Probe(Err(NetworkGraphError::ProbeLoopbackTimeout(
276                                    path,
277                                ))),
278                            );
279                            futures::FutureExt::boxed(futures::future::ready(()))
280                        } else {
281                            // If the eviction cause is not expiration, nothing needs to be done
282                            futures::FutureExt::boxed(futures::future::ready(()))
283                        }
284                    },
285                )
286                .build();
287
288        let push_to_network = api_out.clone();
289
290        let mut processes = AbortableList::default();
291
292        // -- Emit probes --
293        let direct_neighbors =
294            probing_routes
295                .map(|peer| (peer, None))
296                .merge(manual_events.filter_map(|(peer, notifier)| async move {
297                    let routing = DestinationRouting::Forward {
298                        destination: Box::new(peer.into()),
299                        pseudonym: Some(HoprPseudonym::random()),
300                        forward_options: RoutingOptions::Hops(0.try_into().expect("0 is a valid u8")),
301                        return_options: Some(RoutingOptions::Hops(0.try_into().expect("0 is a valid u8"))),
302                    };
303                    Some((ProbeRouting::Neighbor(routing), Some(notifier)))
304                }));
305
306        let tag_allocator = self.tag_allocator.clone();
307        let classifier_neighbor_probes = active_neighbor_probes.clone();
308        let classifier_path_probes = active_path_probes.clone();
309        let emit_diag = hopr_utils::runtime::diagnostics::ConcurrentDiagnostics::new(
310            "probe_emit_for_each_concurrent",
311            module_path!(),
312            file!(),
313            line!(),
314        );
315        processes.insert(
316            HoprProbeProcess::Emit,
317            hopr_utils::spawn_as_abortable_named!("probe_emit", async move {
318                direct_neighbors
319                    .for_each_concurrent(max_parallel_probes, move |(peer, notifier)| {
320                        let active_neighbor_probes = active_neighbor_probes.clone();
321                        let active_path_probes = active_path_probes.clone();
322                        let push_to_network = push_to_network.clone();
323                        let tag_allocator = tag_allocator.clone();
324                        let emit_diag = emit_diag.clone();
325
326                        emit_diag.wrap(|| async move {
327                            match peer {
328                                ProbeRouting::Neighbor(DestinationRouting::Forward {
329                                    destination,
330                                    pseudonym,
331                                    forward_options,
332                                    return_options,
333                                }) => {
334                                    let nonce = NeighborProbe::random_nonce();
335
336                                    let message = Message::Probe(nonce);
337
338                                    if let Ok(data) = message.try_into() {
339                                        let routing = DestinationRouting::Forward {
340                                            destination: destination.clone(),
341                                            pseudonym,
342                                            forward_options,
343                                            return_options,
344                                        };
345                                        // Neighbor probes are sent to a direct neighbor and returned via a zero-hop
346                                        // return path: only 1 SURB is ever consumed. See hoprnet/hoprnet#7972.
347                                        let data = ApplicationDataOut {
348                                            data,
349                                            packet_info: Some(OutgoingPacketInfo {
350                                                max_surbs_in_packet: 1,
351                                                ..Default::default()
352                                            }),
353                                        };
354                                        let mut push_to_network = push_to_network.clone();
355
356                                        if let Err(_error) = push_to_network.send((routing, data)).await {
357                                            tracing::error!("failed to send out a ping");
358                                        } else {
359                                            active_neighbor_probes
360                                                .insert(
361                                                    (
362                                                        pseudonym
363                                                            .expect("the pseudonym must be present in Forward routing"),
364                                                        nonce,
365                                                    ),
366                                                    (destination, current_time().as_unix_timestamp(), notifier),
367                                                )
368                                                .await;
369                                        }
370                                    } else {
371                                        tracing::error!("failed to convert ping message into data");
372                                    }
373                                }
374                                ProbeRouting::Neighbor(DestinationRouting::Return(_surb_matcher)) => tracing::error!(
375                                    error = "logical error",
376                                    "resolved transport routing is not forward"
377                                ),
378                                ProbeRouting::Looping((routing, path_id)) => {
379                                    let message = Message::Telemetry(PathTelemetry {
380                                        id: hopr_api::types::crypto_random::random_bytes(),
381                                        path: std::array::from_fn(|i| path_id[i / 8].to_le_bytes()[i % 8]),
382                                        timestamp: std::time::SystemTime::now()
383                                            .duration_since(std::time::UNIX_EPOCH)
384                                            .unwrap_or_default()
385                                            .as_millis(),
386                                    });
387
388                                    if let Some(allocated_tag) = tag_allocator.allocate() {
389                                        let tag_value = allocated_tag.value();
390
391                                        if let Ok(packet) = hopr_protocol_app::prelude::ApplicationData::new(
392                                            tag_value,
393                                            message.to_bytes().as_ref(),
394                                        ) {
395                                            let mut push_to_network = push_to_network.clone();
396
397                                            // Loopback telemetry probes are self-routed and never replied to via
398                                            // SURB, so no SURBs should be bundled. See hoprnet/hoprnet#7972.
399                                            if let Err(_error) = push_to_network
400                                                .send((
401                                                    routing,
402                                                    ApplicationDataOut {
403                                                        data: packet,
404                                                        packet_info: Some(OutgoingPacketInfo {
405                                                            max_surbs_in_packet: 0,
406                                                            ..Default::default()
407                                                        }),
408                                                    },
409                                                ))
410                                                .await
411                                            {
412                                                tracing::error!("failed to send out a ping");
413                                            } else {
414                                                // the object is constructed above, so will always match
415                                                if let Message::Telemetry(telemetry) = message {
416                                                    active_path_probes
417                                                        .insert(tag_value.into(), (telemetry, Arc::new(allocated_tag)))
418                                                        .await;
419                                                }
420                                            }
421                                        } else {
422                                            tracing::error!("failed to construct data for path telemetry")
423                                        }
424                                    } else {
425                                        tracing::warn!("probing telemetry tag pool exhausted, skipping loopback probe");
426                                    }
427                                }
428                            }
429                        })
430                    })
431                    .inspect(|_| {
432                        tracing::warn!(
433                            task = "transport (probe - generate outgoing)",
434                            "long-running background task finished"
435                        )
436                    })
437                    .await;
438            }),
439        );
440
441        let classifier = ProbeClassifierState {
442            active_neighbor_probes: classifier_neighbor_probes,
443            active_path_probes: classifier_path_probes,
444            network_graph,
445        };
446
447        (processes, classifier)
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use std::{collections::VecDeque, sync::RwLock, time::Duration};
454
455    use async_trait::async_trait;
456    use futures::future::BoxFuture;
457    use hopr_api::{
458        graph::{
459            EdgeLinkObservable, MeasurableEdge, NetworkGraphError,
460            traits::{EdgeNetworkObservableRead, EdgeObservableRead, EdgeObservableWrite, EdgeProtocolObservable},
461        },
462        types::crypto::keypairs::{ChainKeypair, Keypair, OffchainKeypair},
463    };
464    use hopr_protocol_app::prelude::{ApplicationData, ReservedTag, Tag};
465
466    use super::*;
467    use crate::errors::ProbeError;
468
469    lazy_static::lazy_static!(
470        static ref OFFCHAIN_KEYPAIR: OffchainKeypair = OffchainKeypair::random();
471        static ref ONCHAIN_KEYPAIR: ChainKeypair = ChainKeypair::random();
472        static ref NEIGHBOURS: Vec<OffchainPublicKey> = vec![
473            *OffchainKeypair::random().public(),
474            *OffchainKeypair::random().public(),
475            *OffchainKeypair::random().public(),
476            *OffchainKeypair::random().public(),
477        ];
478    );
479
480    /// Test stub implementation of Observable.
481    #[derive(Debug, Clone, Copy, Default)]
482    pub struct TestEdgeTransportObservations;
483
484    impl EdgeLinkObservable for TestEdgeTransportObservations {
485        fn record(&mut self, _latency: std::result::Result<Duration, ()>) {}
486
487        fn average_latency(&self) -> Option<Duration> {
488            None
489        }
490
491        // A measured, perfect link — so the probe tests see the same edge they did before the
492        // trait gained the presence distinction. `has_observations` derives from this.
493        fn average_probe_rate(&self) -> Option<f64> {
494            Some(1.0)
495        }
496
497        fn score(&self) -> Option<f64> {
498            Some(1.0)
499        }
500    }
501
502    impl EdgeNetworkObservableRead for TestEdgeTransportObservations {
503        fn is_connected(&self) -> Option<bool> {
504            Some(true)
505        }
506    }
507
508    impl EdgeProtocolObservable for TestEdgeTransportObservations {
509        fn balance(&self) -> Option<hopr_api::graph::traits::Balance> {
510            None
511        }
512    }
513
514    impl hopr_api::graph::EdgeImmediateProtocolObservable for TestEdgeTransportObservations {
515        fn ack_rate(&self) -> Option<f64> {
516            None
517        }
518    }
519
520    #[derive(Debug, Clone, Copy, Default)]
521    pub struct TestEdgeObservations;
522
523    impl EdgeObservableWrite for TestEdgeObservations {
524        fn record(&mut self, _measurement: hopr_api::graph::traits::EdgeWeightType) {}
525    }
526
527    impl EdgeObservableRead for TestEdgeObservations {
528        type ImmediateMeasurement = TestEdgeTransportObservations;
529        type IntermediateMeasurement = TestEdgeTransportObservations;
530
531        fn last_update(&self) -> std::time::Duration {
532            std::time::SystemTime::now()
533                .duration_since(std::time::UNIX_EPOCH)
534                .unwrap_or_default()
535        }
536
537        fn immediate_qos(&self) -> Option<&Self::ImmediateMeasurement> {
538            None
539        }
540
541        fn intermediate_qos(&self) -> Option<&Self::IntermediateMeasurement> {
542            None
543        }
544
545        fn score(&self) -> Option<f64> {
546            Some(1.0)
547        }
548    }
549
550    #[derive(Debug, Clone)]
551    pub struct PeerStore {
552        me: OffchainPublicKey,
553        get_peers: Arc<RwLock<VecDeque<Vec<OffchainPublicKey>>>>,
554        #[allow(clippy::type_complexity)]
555        on_finished: Arc<RwLock<Vec<(OffchainPublicKey, crate::errors::Result<Duration>)>>>,
556    }
557
558    impl NetworkGraphUpdate for PeerStore {
559        fn record_edge<N, P>(&self, telemetry: MeasurableEdge<N, P>)
560        where
561            N: hopr_api::graph::MeasurablePeer + Send + Clone,
562            P: hopr_api::graph::MeasurablePath + Send + Clone,
563        {
564            let mut on_finished = self.on_finished.write().unwrap();
565
566            match telemetry {
567                hopr_api::graph::MeasurableEdge::Probe(Ok(EdgeTransportTelemetry::Neighbor(neighbor_telemetry))) => {
568                    let peer: OffchainPublicKey = *neighbor_telemetry.peer();
569                    let duration = neighbor_telemetry.rtt();
570                    on_finished.push((peer, Ok(duration)));
571                }
572                hopr_api::graph::MeasurableEdge::Probe(Err(NetworkGraphError::ProbeNeighborTimeout(peer))) => {
573                    on_finished.push((
574                        *peer.as_ref(),
575                        Err(ProbeError::TrafficError(NetworkGraphError::ProbeNeighborTimeout(peer))),
576                    ));
577                }
578                _ => panic!("unexpected telemetry type, unimplemented"),
579            }
580        }
581
582        fn record_node<N>(&self, _node: N)
583        where
584            N: hopr_api::graph::MeasurableNode + Send + Clone,
585        {
586            unimplemented!()
587        }
588
589        fn set_ticket_face_value(&self, _ticket_face_value: hopr_api::graph::traits::Balance) {}
590    }
591
592    #[async_trait]
593    impl NetworkGraphView for PeerStore {
594        type NodeId = OffchainPublicKey;
595        type Observed = TestEdgeObservations;
596
597        fn identity(&self) -> &OffchainPublicKey {
598            &self.me
599        }
600
601        fn ticket_face_value(&self) -> Option<hopr_api::graph::traits::Balance> {
602            None
603        }
604
605        fn node_count(&self) -> usize {
606            self.get_peers.read().unwrap().front().map_or(0, |v| v.len())
607        }
608
609        fn contains_node(&self, _key: &OffchainPublicKey) -> bool {
610            false
611        }
612
613        /// Returns a stream of all known nodes in the network graph.
614        fn nodes(&self) -> futures::stream::BoxStream<'static, OffchainPublicKey> {
615            let mut get_peers = self.get_peers.write().unwrap();
616            Box::pin(futures::stream::iter(get_peers.pop_front().unwrap_or_default()))
617        }
618
619        fn edge(&self, _src: &OffchainPublicKey, _dest: &OffchainPublicKey) -> Option<TestEdgeObservations> {
620            Some(TestEdgeObservations)
621        }
622
623        /// Probing never identifies a path by slot, so nothing here depends on this mapping.
624        fn path_slot(&self, _key: &OffchainPublicKey) -> Option<u64> {
625            None
626        }
627    }
628
629    type TestClassifier = ProbeClassifierState<PeerStore>;
630
631    struct TestInterface {
632        probe_classifier: TestClassifier,
633        from_probing_to_network_rx: futures::channel::mpsc::Receiver<(DestinationRouting, ApplicationDataOut)>,
634        from_probing_to_network_tx: futures::channel::mpsc::Sender<(DestinationRouting, ApplicationDataOut)>,
635        manual_probe_tx: futures::channel::mpsc::Sender<(OffchainPublicKey, PingQueryReplier)>,
636    }
637
638    async fn test_with_probing<F, Fut>(cfg: ProbeConfig, store: PeerStore, test: F) -> anyhow::Result<()>
639    where
640        Fut: std::future::Future<Output = anyhow::Result<()>>,
641        F: Fn(TestInterface) -> Fut + Send + Sync + 'static,
642    {
643        let tag_allocators = hopr_transport_tag_allocator::create_allocators(
644            ReservedTag::range().end..u16::MAX as u64 + 1,
645            [
646                (hopr_transport_tag_allocator::Usage::Session, 2048),
647                (hopr_transport_tag_allocator::Usage::SessionTerminalTelemetry, 4000),
648                (hopr_transport_tag_allocator::Usage::ProvingTelemetry, 10000),
649            ],
650        )
651        .expect("tag allocators should be created");
652        let probing_allocator = tag_allocators
653            .into_iter()
654            .find_map(|(u, alloc)| matches!(u, hopr_transport_tag_allocator::Usage::ProvingTelemetry).then_some(alloc))
655            .expect("probing allocator should exist");
656
657        let probe = Probe::new(cfg, probing_allocator);
658
659        let (from_probing_to_network_tx, from_probing_to_network_rx) =
660            futures::channel::mpsc::channel::<(DestinationRouting, ApplicationDataOut)>(100);
661
662        let (manual_probe_tx, manual_probe_rx) =
663            futures::channel::mpsc::channel::<(OffchainPublicKey, PingQueryReplier)>(100);
664
665        let (jhs, probe_classifier) = probe
666            .continuously_scan(
667                from_probing_to_network_tx.clone(),
668                manual_probe_rx,
669                TestProbeStrategy::ImmediateNeighbor { store: store.clone() },
670                store,
671            )
672            .await;
673
674        let interface = TestInterface {
675            probe_classifier,
676            from_probing_to_network_rx,
677            from_probing_to_network_tx,
678            manual_probe_tx,
679        };
680
681        let result = test(interface).await;
682
683        jhs.abort_all();
684
685        result
686    }
687
688    const NO_PROBE_PASSES: f64 = 0.0;
689    const ALL_PROBES_PASS: f64 = 1.0;
690
691    /// Simulates the network: receives outgoing probe packets and feeds pong responses back
692    /// through the classifier (mirroring what the remote peer + packet pipeline would do).
693    fn concurrent_classify(
694        delay: Option<std::time::Duration>,
695        pass_rate: f64,
696        classifier: TestClassifier,
697        push_to_network: futures::channel::mpsc::Sender<(DestinationRouting, ApplicationDataOut)>,
698    ) -> impl Fn((DestinationRouting, ApplicationDataOut)) -> BoxFuture<'static, ()> {
699        debug_assert!(
700            (NO_PROBE_PASSES..=ALL_PROBES_PASS).contains(&pass_rate),
701            "Pass rate must be between {NO_PROBE_PASSES} and {ALL_PROBES_PASS}"
702        );
703
704        move |(path, data_out): (DestinationRouting, ApplicationDataOut)| -> BoxFuture<'static, ()> {
705            let classifier = classifier.clone();
706            let push_to_network = push_to_network.clone();
707
708            Box::pin(async move {
709                if let DestinationRouting::Forward { pseudonym, .. } = path {
710                    let message: Message = data_out.data.try_into().expect("failed to convert data into message");
711                    if let Message::Probe(NeighborProbe::Ping(ping)) = message {
712                        let pong_message = Message::Probe(NeighborProbe::Pong(ping));
713
714                        if let Some(delay) = delay {
715                            tokio::time::sleep(delay).await;
716                        }
717
718                        if rand::random_range(NO_PROBE_PASSES..=ALL_PROBES_PASS) < pass_rate {
719                            let pseudonym = pseudonym.expect("the pseudonym is always known from cache");
720                            classifier
721                                .classify(
722                                    push_to_network,
723                                    pseudonym,
724                                    ApplicationDataIn {
725                                        data: pong_message
726                                            .try_into()
727                                            .expect("failed to convert pong message into data"),
728                                        packet_info: Default::default(),
729                                    },
730                                )
731                                .await;
732                        }
733                    }
734                };
735            })
736        }
737    }
738
739    #[tokio::test]
740    // #[tracing_test::traced_test]
741    async fn probe_should_record_value_for_manual_neighbor_probe() -> anyhow::Result<()> {
742        let cfg = ProbeConfig {
743            timeout: std::time::Duration::from_millis(5),
744            interval: std::time::Duration::from_secs(0),
745            ..Default::default()
746        };
747
748        let store = PeerStore {
749            me: *OFFCHAIN_KEYPAIR.public(),
750            get_peers: Arc::new(RwLock::new(VecDeque::new())),
751            on_finished: Arc::new(RwLock::new(Vec::new())),
752        };
753
754        test_with_probing(cfg, store, move |iface: TestInterface| async move {
755            let mut manual_probe_tx = iface.manual_probe_tx;
756            let from_probing_to_network_rx = iface.from_probing_to_network_rx;
757            let from_probing_to_network_tx = iface.from_probing_to_network_tx;
758            let probe_classifier = iface.probe_classifier;
759
760            let (tx, mut rx) = futures::channel::mpsc::channel::<std::result::Result<Duration, ()>>(128);
761            manual_probe_tx.send((NEIGHBOURS[0], PingQueryReplier::new(tx))).await?;
762
763            let _jh: hopr_utils::runtime::prelude::JoinHandle<()> = tokio::spawn(async move {
764                from_probing_to_network_rx
765                    .for_each_concurrent(
766                        cfg.max_parallel_probes + 1,
767                        concurrent_classify(None, ALL_PROBES_PASS, probe_classifier, from_probing_to_network_tx),
768                    )
769                    .await;
770            });
771
772            let _duration = tokio::time::timeout(std::time::Duration::from_secs(1), rx.next())
773                .await?
774                .ok_or_else(|| anyhow::anyhow!("Probe did not return a result in time"))?
775                .map_err(|_| anyhow::anyhow!("Probe failed"))?;
776
777            Ok(())
778        })
779        .await
780    }
781
782    #[tokio::test]
783    // #[tracing_test::traced_test]
784    async fn probe_should_record_failure_on_manual_fail() -> anyhow::Result<()> {
785        let cfg = ProbeConfig {
786            timeout: std::time::Duration::from_millis(5),
787            interval: std::time::Duration::from_secs(0),
788            ..Default::default()
789        };
790
791        let store = PeerStore {
792            me: *OFFCHAIN_KEYPAIR.public(),
793            get_peers: Arc::new(RwLock::new(VecDeque::new())),
794            on_finished: Arc::new(RwLock::new(Vec::new())),
795        };
796
797        test_with_probing(cfg, store, move |iface: TestInterface| async move {
798            let mut manual_probe_tx = iface.manual_probe_tx;
799            let from_probing_to_network_rx = iface.from_probing_to_network_rx;
800            let from_probing_to_network_tx = iface.from_probing_to_network_tx;
801            let probe_classifier = iface.probe_classifier;
802
803            let (tx, mut rx) = futures::channel::mpsc::channel::<std::result::Result<Duration, ()>>(128);
804            manual_probe_tx.send((NEIGHBOURS[0], PingQueryReplier::new(tx))).await?;
805
806            let _jh: hopr_utils::runtime::prelude::JoinHandle<()> = tokio::spawn(async move {
807                from_probing_to_network_rx
808                    .for_each_concurrent(
809                        cfg.max_parallel_probes + 1,
810                        concurrent_classify(None, NO_PROBE_PASSES, probe_classifier, from_probing_to_network_tx),
811                    )
812                    .await;
813            });
814
815            assert!(tokio::time::timeout(cfg.timeout * 2, rx.next()).await.is_err());
816
817            Ok(())
818        })
819        .await
820    }
821
822    #[tokio::test]
823    // #[tracing_test::traced_test]
824    async fn probe_should_record_results_of_successful_automatically_generated_probes() -> anyhow::Result<()> {
825        let cfg = ProbeConfig {
826            timeout: std::time::Duration::from_millis(20),
827            max_parallel_probes: NEIGHBOURS.len(),
828            interval: std::time::Duration::from_secs(0),
829            ..Default::default()
830        };
831
832        let store = PeerStore {
833            me: *OFFCHAIN_KEYPAIR.public(),
834            get_peers: Arc::new(RwLock::new({
835                let mut neighbors = VecDeque::new();
836                neighbors.push_back(NEIGHBOURS.clone());
837                neighbors
838            })),
839            on_finished: Arc::new(RwLock::new(Vec::new())),
840        };
841
842        test_with_probing(cfg, store.clone(), move |iface: TestInterface| async move {
843            let from_probing_to_network_rx = iface.from_probing_to_network_rx;
844            let from_probing_to_network_tx = iface.from_probing_to_network_tx;
845            let probe_classifier = iface.probe_classifier;
846
847            let _jh: hopr_utils::runtime::prelude::JoinHandle<()> = tokio::spawn(async move {
848                from_probing_to_network_rx
849                    .for_each_concurrent(
850                        cfg.max_parallel_probes + 1,
851                        concurrent_classify(None, ALL_PROBES_PASS, probe_classifier, from_probing_to_network_tx),
852                    )
853                    .await;
854            });
855
856            // Wait for the full probe lifecycle: emit → network round trip → process.
857            // Must exceed cfg.timeout (the cache TTL) to avoid probes being evicted
858            // as timeouts before the pong response is processed.
859            tokio::time::sleep(cfg.timeout * 3).await;
860
861            Ok(())
862        })
863        .await?;
864
865        assert_eq!(
866            store
867                .on_finished
868                .read()
869                .expect("should be lockable")
870                .iter()
871                .filter(|(_peer, result)| result.is_ok())
872                .count(),
873            NEIGHBOURS.len()
874        );
875
876        Ok(())
877    }
878
879    #[tokio::test]
880    // #[tracing_test::traced_test]
881    async fn probe_should_record_results_of_timed_out_automatically_generated_probes() -> anyhow::Result<()> {
882        let cfg = ProbeConfig {
883            timeout: std::time::Duration::from_millis(10),
884            max_parallel_probes: NEIGHBOURS.len(),
885            interval: std::time::Duration::from_secs(0),
886            ..Default::default()
887        };
888
889        let store = PeerStore {
890            me: *OFFCHAIN_KEYPAIR.public(),
891            get_peers: Arc::new(RwLock::new({
892                let mut neighbors = VecDeque::new();
893                neighbors.push_back(NEIGHBOURS.clone());
894                neighbors
895            })),
896            on_finished: Arc::new(RwLock::new(Vec::new())),
897        };
898
899        let timeout = cfg.timeout * 2;
900
901        test_with_probing(cfg, store.clone(), move |iface: TestInterface| async move {
902            let from_probing_to_network_rx = iface.from_probing_to_network_rx;
903            let from_probing_to_network_tx = iface.from_probing_to_network_tx;
904            let probe_classifier = iface.probe_classifier;
905
906            let _jh: hopr_utils::runtime::prelude::JoinHandle<()> = tokio::spawn(async move {
907                from_probing_to_network_rx
908                    .for_each_concurrent(
909                        cfg.max_parallel_probes + 1,
910                        concurrent_classify(
911                            Some(timeout),
912                            ALL_PROBES_PASS,
913                            probe_classifier,
914                            from_probing_to_network_tx,
915                        ),
916                    )
917                    .await;
918            });
919
920            // wait for the probes to start and finish
921            tokio::time::sleep(timeout * 2).await;
922
923            Ok(())
924        })
925        .await?;
926
927        assert_eq!(
928            store
929                .on_finished
930                .read()
931                .expect("should be lockable")
932                .iter()
933                .filter(|(_peer, result)| result.is_err())
934                .count(),
935            NEIGHBOURS.len()
936        );
937
938        Ok(())
939    }
940
941    #[tokio::test]
942    async fn probe_should_reply_with_pong_when_receiving_ping() -> anyhow::Result<()> {
943        use anyhow::Context;
944
945        let cfg = ProbeConfig {
946            timeout: std::time::Duration::from_millis(100),
947            interval: std::time::Duration::from_secs(10),
948            ..Default::default()
949        };
950
951        let store = PeerStore {
952            me: *OFFCHAIN_KEYPAIR.public(),
953            get_peers: Arc::new(RwLock::new(VecDeque::new())),
954            on_finished: Arc::new(RwLock::new(Vec::new())),
955        };
956
957        test_with_probing(cfg, store, move |iface: TestInterface| async move {
958            let probe_classifier = iface.probe_classifier;
959            let from_probing_to_network_tx = iface.from_probing_to_network_tx;
960            let mut from_probing_to_network_rx = iface.from_probing_to_network_rx;
961
962            // Build a Ping message with the reserved Ping tag
963            let ping = NeighborProbe::random_nonce();
964            let ping_nonce = match ping {
965                NeighborProbe::Ping(n) => n,
966                _ => unreachable!(),
967            };
968            let ping_msg = Message::Probe(ping);
969            let app_data: ApplicationData = ping_msg.try_into().context("converting ping to ApplicationData")?;
970
971            // Classify the ping directly — the classifier sends the pong reply to push_to_network
972            let result = probe_classifier
973                .classify(
974                    from_probing_to_network_tx,
975                    HoprPseudonym::random(),
976                    ApplicationDataIn {
977                        data: app_data,
978                        packet_info: Default::default(),
979                    },
980                )
981                .await;
982            anyhow::ensure!(matches!(result, ProbeDispatch::Consumed), "ping should be consumed");
983
984            // The probe should reply with a Pong on the network channel
985            let (routing, data_out) = tokio::time::timeout(Duration::from_secs(2), from_probing_to_network_rx.next())
986                .await
987                .context("timeout waiting for pong")?
988                .context("probe should send pong reply")?;
989
990            // Verify it's a Return routing (SURB-based reply)
991            anyhow::ensure!(
992                matches!(routing, DestinationRouting::Return(_)),
993                "pong should use Return routing, got: {routing:?}"
994            );
995
996            // Verify the payload is a Pong with the same nonce
997            let response_msg: Message = data_out.data.try_into().context("converting response to Message")?;
998            anyhow::ensure!(
999                matches!(response_msg, Message::Probe(NeighborProbe::Pong(n)) if n == ping_nonce),
1000                "response should be Pong with matching nonce"
1001            );
1002
1003            Ok(())
1004        })
1005        .await
1006    }
1007
1008    #[tokio::test]
1009    // #[tracing_test::traced_test]
1010    async fn probe_should_pass_through_non_associated_tags() -> anyhow::Result<()> {
1011        let cfg = ProbeConfig {
1012            timeout: std::time::Duration::from_millis(20),
1013            interval: std::time::Duration::from_secs(0),
1014            ..Default::default()
1015        };
1016
1017        let store = PeerStore {
1018            me: *OFFCHAIN_KEYPAIR.public(),
1019            get_peers: Arc::new(RwLock::new({
1020                let mut neighbors = VecDeque::new();
1021                neighbors.push_back(NEIGHBOURS.clone());
1022                neighbors
1023            })),
1024            on_finished: Arc::new(RwLock::new(Vec::new())),
1025        };
1026
1027        test_with_probing(cfg, store.clone(), move |iface: TestInterface| async move {
1028            let probe_classifier = iface.probe_classifier;
1029            let from_probing_to_network_tx = iface.from_probing_to_network_tx;
1030
1031            let expected_data = ApplicationData::new(Tag::MAX, b"Hello, this is a test message!")?;
1032
1033            let result = probe_classifier
1034                .classify(
1035                    from_probing_to_network_tx,
1036                    HoprPseudonym::random(),
1037                    ApplicationDataIn {
1038                        data: expected_data.clone(),
1039                        packet_info: Default::default(),
1040                    },
1041                )
1042                .await;
1043
1044            match result {
1045                ProbeDispatch::Passthrough(_, actual) => assert_eq!(actual.data, expected_data),
1046                ProbeDispatch::Consumed => anyhow::bail!("expected Passthrough, got Consumed"),
1047            }
1048
1049            Ok(())
1050        })
1051        .await
1052    }
1053
1054    /// How a probe gets triggered inside the test: either via the manual ping channel
1055    /// (for neighbor probes) or by injecting a single [`ProbeRouting::Looping`] into the
1056    /// probing traffic generator (for loopback probes).
1057    #[derive(Clone)]
1058    enum TestProbeStrategy {
1059        ManualNeighbor,
1060        ImmediateNeighbor {
1061            store: PeerStore,
1062        },
1063        OneShotLoopback {
1064            routing: DestinationRouting,
1065            path_id: hopr_api::types::internal::routing::PathId,
1066        },
1067    }
1068
1069    impl hopr_api::ct::ProbingTrafficGeneration for TestProbeStrategy {
1070        fn build(&self) -> futures::stream::BoxStream<'static, hopr_api::ct::ProbeRouting> {
1071            match self {
1072                Self::ManualNeighbor => Box::pin(futures::stream::pending()),
1073                Self::ImmediateNeighbor { store } => {
1074                    let peers: Vec<OffchainPublicKey> =
1075                        store.get_peers.write().unwrap().pop_front().unwrap_or_default();
1076                    Box::pin(futures::StreamExt::chain(
1077                        futures::stream::iter(peers.into_iter().map(|peer| {
1078                            ProbeRouting::Neighbor(DestinationRouting::Forward {
1079                                destination: Box::new(peer.into()),
1080                                pseudonym: Some(HoprPseudonym::random()),
1081                                forward_options: RoutingOptions::Hops(0.try_into().expect("0 is a valid u8")),
1082                                return_options: Some(RoutingOptions::Hops(0.try_into().expect("0 is a valid u8"))),
1083                            })
1084                        })),
1085                        futures::stream::pending(),
1086                    ))
1087                }
1088                Self::OneShotLoopback { routing, path_id } => {
1089                    let probe = hopr_api::ct::ProbeRouting::Looping((routing.clone(), *path_id));
1090                    Box::pin(futures::StreamExt::chain(
1091                        futures::stream::iter(std::iter::once(probe)),
1092                        futures::stream::pending(),
1093                    ))
1094                }
1095            }
1096        }
1097    }
1098
1099    /// Regression test for hoprnet/hoprnet#7972: each probe type must be emitted with the
1100    /// exact SURB count it actually consumes — no more, no less — so the path planner does
1101    /// not resolve (and the SURB store does not accumulate) unused return paths.
1102    ///
1103    /// - Neighbor probe: 1 SURB (zero-hop pong return).
1104    /// - Loopback probe: 0 SURBs (self-routed, never replied to).
1105    #[rstest::rstest]
1106    #[case::neighbor_probe_requests_one_surb(TestProbeStrategy::ManualNeighbor, 1)]
1107    #[case::loopback_probe_requests_zero_surbs(
1108        TestProbeStrategy::OneShotLoopback {
1109            routing: DestinationRouting::Forward {
1110                destination: Box::new((*OFFCHAIN_KEYPAIR.public()).into()),
1111                pseudonym: Some(HoprPseudonym::random()),
1112                forward_options: RoutingOptions::Hops(1.try_into().expect("1 is a valid u8")),
1113                return_options: None,
1114            },
1115            path_id: [1, 2, 3, 4, 5],
1116        },
1117        0,
1118    )]
1119    #[tokio::test]
1120    async fn probe_should_emit_with_expected_surb_count(
1121        #[case] strategy: TestProbeStrategy,
1122        #[case] expected_max_surbs: usize,
1123    ) -> anyhow::Result<()> {
1124        let cfg = ProbeConfig {
1125            timeout: std::time::Duration::from_secs(1),
1126            interval: std::time::Duration::from_secs(0),
1127            ..Default::default()
1128        };
1129
1130        // Wire up a full probing harness with the parameterized strategy injected as the
1131        // traffic generator. Mirrors `test_with_probing` but allows arbitrary `ProbingTrafficGeneration`.
1132        let tag_allocators = hopr_transport_tag_allocator::create_allocators(
1133            ReservedTag::range().end..u16::MAX as u64 + 1,
1134            [
1135                (hopr_transport_tag_allocator::Usage::Session, 2048),
1136                (hopr_transport_tag_allocator::Usage::SessionTerminalTelemetry, 4000),
1137                (hopr_transport_tag_allocator::Usage::ProvingTelemetry, 10000),
1138            ],
1139        )
1140        .expect("tag allocators should be created");
1141        let probing_allocator = tag_allocators
1142            .into_iter()
1143            .find_map(|(u, alloc)| matches!(u, hopr_transport_tag_allocator::Usage::ProvingTelemetry).then_some(alloc))
1144            .expect("probing allocator should exist");
1145
1146        let probe = Probe::new(cfg, probing_allocator);
1147
1148        let (from_probing_to_network_tx, mut from_probing_to_network_rx) =
1149            futures::channel::mpsc::channel::<(DestinationRouting, ApplicationDataOut)>(100);
1150        let (mut manual_probe_tx, manual_probe_rx) =
1151            futures::channel::mpsc::channel::<(OffchainPublicKey, PingQueryReplier)>(100);
1152
1153        let store = PeerStore {
1154            me: *OFFCHAIN_KEYPAIR.public(),
1155            get_peers: Arc::new(RwLock::new(VecDeque::new())),
1156            on_finished: Arc::new(RwLock::new(Vec::new())),
1157        };
1158
1159        // Kick off the probing process before triggering — the strategy's stream drives
1160        // the loopback case, and we push to `manual_probe_tx` below for the neighbor case.
1161        let is_manual = matches!(strategy, TestProbeStrategy::ManualNeighbor);
1162        let (jhs, _probe_classifier) = probe
1163            .continuously_scan(from_probing_to_network_tx, manual_probe_rx, strategy, store)
1164            .await;
1165
1166        if is_manual {
1167            let (tx, _rx) = futures::channel::mpsc::channel::<std::result::Result<Duration, ()>>(128);
1168            manual_probe_tx.send((NEIGHBOURS[0], PingQueryReplier::new(tx))).await?;
1169        }
1170
1171        let (_routing, data_out) =
1172            tokio::time::timeout(std::time::Duration::from_secs(1), from_probing_to_network_rx.next())
1173                .await?
1174                .ok_or_else(|| anyhow::anyhow!("no probe emitted"))?;
1175
1176        jhs.abort_all();
1177
1178        let packet_info = data_out
1179            .packet_info
1180            .ok_or_else(|| anyhow::anyhow!("probe must carry explicit OutgoingPacketInfo"))?;
1181        assert_eq!(
1182            packet_info.max_surbs_in_packet, expected_max_surbs,
1183            "probe must request exactly {expected_max_surbs} SURB(s)"
1184        );
1185
1186        Ok(())
1187    }
1188}