Skip to main content

hopr_transport/path/
resolve.rs

1//! The stage that turns a [`DestinationRouting`] into a [`ResolvedTransportRouting`] for every
2//! packet this node originates.
3//!
4//! It sits between the merged outgoing-data stream and the SPHINX encoder, so **every** packet the
5//! node originates passes through it: session payloads, Start-protocol replies, SURB keep-alives,
6//! probes and cover traffic. Forwarded packets and acknowledgements do not — they are relayed from
7//! inside the ingress pipeline and never reach this stage.
8//!
9//! That asymmetry is why this stage deserves its own tests: a fault here silences origination
10//! node-wide while forwarding, acking and receiving carry on looking perfectly healthy.
11
12use futures::{Stream, StreamExt};
13use hopr_api::types::internal::routing::{DestinationRouting, ResolvedTransportRouting};
14use hopr_crypto_packet::prelude::{HoprSurb, PacketSignal};
15use hopr_protocol_app::prelude::ApplicationDataOut;
16use tracing::{Instrument, error, trace, warn};
17
18use super::errors::Result as PathResult;
19
20/// How often a return route whose SURBs have not arrived is retried.
21const SURB_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5);
22
23/// Default for [`PacketPipelineConfig::surb_resolution_wait`][cfg], used when it is unset.
24///
25/// A momentary gap is normal — the SURB pool refills asynchronously, and dropping a return packet
26/// on the first miss loses data on a session that was only waiting. That is why the retry exists.
27///
28/// What it must not do is wait indefinitely. When the counterparty is gone no SURB is ever coming,
29/// and because this stage preserves submission order, one such packet withholds every other packet
30/// the node would originate, for the life of the process.
31///
32/// Six seconds is deliberately generous, because this is a *library* default and the useful ceiling
33/// belongs to the caller. A packet held past the session's frame timeout — 3 s for the sessions
34/// hoprd runs — cannot be used by the receiver anyway, so a deployment that knows its own timeout
35/// should configure something tighter, as hoprd does. What a library default must not do is drop a
36/// packet a slower or reliable session would still have made use of, so it errs long and leaves the
37/// tightening to whoever knows the traffic.
38///
39/// [cfg]: crate::protocol::PacketPipelineConfig::surb_resolution_wait
40pub(crate) const DEFAULT_SURB_RESOLUTION_WAIT: std::time::Duration = std::time::Duration::from_secs(6);
41
42/// The wait a node should use, given what its configuration says.
43///
44/// Unset means the default; zero is honoured as "do not wait", which drops a return packet the
45/// first time its SURBs are missing. Kept as one function so both meanings live in one place
46/// rather than being re-derived at the call site.
47pub(crate) fn surb_resolution_wait(configured: Option<std::time::Duration>) -> std::time::Duration {
48    configured.unwrap_or(DEFAULT_SURB_RESOLUTION_WAIT)
49}
50
51/// Resolves the routing of every outgoing packet, emitting the resolved packets in submission
52/// order.
53///
54/// `resolve` is the resolution step itself — in production
55/// [`PathPlanner::resolve_routing`](super::PathPlanner::resolve_routing), taking the payload size
56/// hint, the maximum number of SURBs the packet can carry, and the unresolved routing.
57///
58/// A packet whose routing cannot be resolved is dropped and counted. A packet whose *return* routing
59/// finds no SURB is retried for up to `surb_wait` before being dropped the same way; see
60/// [`DEFAULT_SURB_RESOLUTION_WAIT`] for why that bound has to exist.
61///
62/// Ordering is deliberate: out-of-order delivery to the entry's reassembler makes the sequencer
63/// discard frames that arrive after `frame_timeout`. It is also why an unbounded wait here is fatal
64/// rather than merely slow — [`buffered`](futures::StreamExt::buffered) withholds completed futures
65/// behind an unfinished one, so the stall is node-wide rather than confined to one packet.
66pub(crate) fn resolve_routing_stage<St, F, Fut>(
67    input: St,
68    resolve: F,
69    distress_threshold: usize,
70    concurrency: usize,
71    surb_wait: std::time::Duration,
72) -> impl Stream<Item = (ResolvedTransportRouting<HoprSurb>, ApplicationDataOut)>
73where
74    St: Stream<Item = (DestinationRouting, ApplicationDataOut)>,
75    F: Fn(usize, usize, DestinationRouting) -> Fut + Clone,
76    Fut: Future<Output = PathResult<(ResolvedTransportRouting<HoprSurb>, Option<usize>)>>,
77{
78    input
79        .map(move |(unresolved, mut data)| {
80            let resolve = resolve.clone();
81            async move {
82                // Retry on SURB starvation: the SURB pool on the exit side refills asynchronously
83                // (target 600, ~300/sec via keep-alive). Silently dropping return-path packets when
84                // the pool is momentarily empty causes irreversible data loss; instead we yield
85                // briefly so the pool can replenish before retrying — but only for `surb_wait`,
86                // after which no SURB is coming and continuing to wait costs the node its egress.
87                let deadline = std::time::Instant::now() + surb_wait;
88                loop {
89                    hopr_transport_session::counters::ROUTING_RESOLUTION_ATTEMPTS
90                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
91                    trace!(?unresolved, "resolving routing for packet");
92                    match resolve(
93                        data.data.total_len(),
94                        data.estimate_surbs_with_msg(),
95                        unresolved.clone(),
96                    )
97                    .await
98                    {
99                        Ok((resolved, rem_surbs)) => {
100                            // Set the SURB distress/out-of-SURBs flag if applicable.
101                            // These flags are translated into HOPR protocol packet signals and are
102                            // applicable only on the return path.
103                            let mut signals_to_dst = data
104                                .packet_info
105                                .as_ref()
106                                .map(|info| info.signals_to_destination)
107                                .unwrap_or_default();
108
109                            if resolved.is_return() {
110                                signals_to_dst = match rem_surbs {
111                                    Some(rem) if (1..distress_threshold.max(2)).contains(&rem) => {
112                                        signals_to_dst | PacketSignal::SurbDistress
113                                    }
114                                    Some(0) => signals_to_dst | PacketSignal::OutOfSurbs,
115                                    _ => signals_to_dst - (PacketSignal::OutOfSurbs | PacketSignal::SurbDistress),
116                                };
117                            } else {
118                                // Unset these flags as they make no sense on the forward path.
119                                signals_to_dst -= PacketSignal::SurbDistress | PacketSignal::OutOfSurbs;
120                            }
121
122                            data.packet_info.get_or_insert_default().signals_to_destination = signals_to_dst;
123                            trace!(?resolved, "resolved routing for packet");
124                            return Some((resolved, data));
125                        }
126                        Err(error) if error.is_surb() && std::time::Instant::now() >= deadline => {
127                            hopr_transport_session::counters::ROUTING_RESOLUTION_SURB_TIMEOUTS
128                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
129                            // Warn rather than trace: this is the only externally visible sign that
130                            // a counterparty has stopped replenishing, and the outage it replaces
131                            // was invisible precisely because nothing on this path said anything.
132                            warn!(
133                                ?unresolved,
134                                ?surb_wait,
135                                %error,
136                                "dropping an outgoing packet: no SURB for its return path within the wait"
137                            );
138                            return None;
139                        }
140                        Err(error) if error.is_surb() => {
141                            // No SURB available yet (possibly cache-wrapped); yield briefly so the
142                            // pool can refill.
143                            futures_timer::Delay::new(SURB_RETRY_INTERVAL).await;
144                        }
145                        Err(error) => {
146                            hopr_transport_session::counters::ROUTING_RESOLUTION_FAILURES
147                                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
148                            error!(%error, "failed to resolve routing");
149                            return None;
150                        }
151                    }
152                }
153            }
154            .in_current_span()
155        })
156        .buffered(concurrency)
157        .filter_map(futures::future::ready)
158}
159
160#[cfg(test)]
161mod tests {
162    use std::sync::{
163        Arc,
164        atomic::{AtomicUsize, Ordering},
165    };
166
167    use futures_time::future::FutureExt as _;
168    use hopr_api::types::{
169        crypto::{crypto_traits::Randomizable, prelude::*},
170        internal::{
171            path::ValidatedPath,
172            prelude::HoprPseudonym,
173            routing::{RoutingOptions, SurbMatcher},
174        },
175    };
176    use hopr_protocol_app::prelude::{ApplicationData, Tag};
177
178    use super::*;
179    use crate::path::errors::PathPlannerError;
180
181    /// Bound on every test in this module. The stall under test is unbounded, so a test that hits
182    /// this limit has reproduced it rather than merely run slowly.
183    const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
184
185    /// The stage's SURB wait, shortened for the tests.
186    ///
187    /// Well clear of [`SURB_RETRY_INTERVAL`] so a retry-then-succeed case has room, and far enough
188    /// below [`TEST_TIMEOUT`] that "the bound fired" and "the test timed out" cannot be confused.
189    const TEST_SURB_WAIT: std::time::Duration = std::time::Duration::from_millis(200);
190
191    const TEST_TAG: u64 = 1234;
192
193    /// Concurrency of the stage under test. Any value ≥ 2 exhibits the ordering behaviour; a small
194    /// one keeps the failure legible.
195    const TEST_CONCURRENCY: usize = 8;
196
197    const TEST_DISTRESS_THRESHOLD: usize = 2;
198
199    /// The stage under test with the test constants applied, so each test differs only in the two
200    /// things that matter to it: what goes in, and what resolution does.
201    fn stage<St, F, Fut>(
202        input: St,
203        resolve: F,
204    ) -> impl Stream<Item = (ResolvedTransportRouting<HoprSurb>, ApplicationDataOut)>
205    where
206        St: Stream<Item = (DestinationRouting, ApplicationDataOut)>,
207        F: Fn(usize, usize, DestinationRouting) -> Fut + Clone,
208        Fut: Future<Output = PathResult<(ResolvedTransportRouting<HoprSurb>, Option<usize>)>>,
209    {
210        resolve_routing_stage(
211            input,
212            resolve,
213            TEST_DISTRESS_THRESHOLD,
214            TEST_CONCURRENCY,
215            TEST_SURB_WAIT,
216        )
217    }
218
219    /// A resolution result the stage accepts. The variant is a forward one because a
220    /// [`ResolvedTransportRouting::Return`] needs a real `HoprSurb`, and no test here depends on
221    /// which variant came back — only on *whether* the packet was emitted.
222    fn resolved() -> ResolvedTransportRouting<HoprSurb> {
223        ResolvedTransportRouting::Forward {
224            pseudonym: HoprPseudonym::random(),
225            forward_path: ValidatedPath::direct(
226                *OffchainKeypair::random().public(),
227                ChainKeypair::random().public().to_address(),
228            ),
229            return_paths: vec![],
230        }
231    }
232
233    fn forward_routing() -> DestinationRouting {
234        DestinationRouting::forward_only(
235            *OffchainKeypair::random().public(),
236            RoutingOptions::Hops(1.try_into().expect("1 is a valid hop count")),
237        )
238    }
239
240    fn return_routing(pseudonym: HoprPseudonym) -> DestinationRouting {
241        DestinationRouting::Return(SurbMatcher::Pseudonym(pseudonym))
242    }
243
244    /// A packet carrying `marker` as its only payload byte, so emitted packets can be identified.
245    fn packet(marker: u8) -> ApplicationDataOut {
246        ApplicationDataOut::with_no_packet_info(
247            ApplicationData::new(Tag::from(TEST_TAG), &[marker]).expect("a one-byte payload is valid"),
248        )
249    }
250
251    fn marker_of(data: &ApplicationDataOut) -> u8 {
252        data.data.plain_text[0]
253    }
254
255    /// The error the planner raises when the SURB store holds nothing for a pseudonym.
256    ///
257    /// This is a *permanent* condition once the counterparty is gone — it is the same error whether
258    /// the pool is momentarily empty or will never be refilled again, which is precisely what makes
259    /// retrying on it indefinitely unsafe.
260    fn no_surb(routing: &DestinationRouting) -> PathPlannerError {
261        let pseudonym = match routing {
262            DestinationRouting::Return(matcher) => matcher.pseudonym().to_string(),
263            DestinationRouting::Forward { .. } => unreachable!("only return routing can starve for SURBs"),
264        };
265        PathPlannerError::Surb(format!("no surb for pseudonym {pseudonym}"))
266    }
267
268    /// A return packet for a pseudonym whose SURBs will never arrive must not withhold the packets
269    /// queued behind it.
270    ///
271    /// This is the `london-01` outage in miniature. The exit kept a session slot alive after its
272    /// initiator had gone, so its keep-alive stream went on emitting return-routed packets for a
273    /// pseudonym with no SURBs. Resolution for that packet can never succeed, and because the stage
274    /// preserves submission order, every subsequent packet was withheld behind it: the node
275    /// originated nothing for 1h44m while forwarding, acking and receiving carried on normally.
276    ///
277    /// The assertion is that the packets behind the starved one are **emitted** — not merely that
278    /// nothing errored. A stalled origination stage is externally indistinguishable from an idle
279    /// node, which is what made the live outage cost a day to find.
280    #[test_log::test(tokio::test)]
281    async fn a_starved_return_packet_should_not_withhold_the_packets_behind_it() -> anyhow::Result<()> {
282        let starved = HoprPseudonym::random();
283        let dropped_before = hopr_transport_session::counters::routing_resolution_surb_timeout_count();
284
285        let input = futures::stream::iter(vec![
286            (return_routing(starved), packet(0)),
287            (forward_routing(), packet(1)),
288            (forward_routing(), packet(2)),
289        ]);
290
291        let emitted = stage(
292            input,
293            |_size_hint, _max_surbs, routing: DestinationRouting| async move {
294                match routing {
295                    DestinationRouting::Return(_) => Err(no_surb(&routing)),
296                    DestinationRouting::Forward { .. } => Ok((resolved(), None)),
297                }
298            },
299        )
300        .take(2)
301        .collect::<Vec<_>>()
302        .timeout(futures_time::time::Duration::from(TEST_TIMEOUT))
303        .await;
304
305        let emitted = emitted.map_err(|_| {
306            anyhow::anyhow!(
307                "origination stalled: the two forward packets queued behind a return packet whose pseudonym has no \
308                 SURBs were never emitted within {TEST_TIMEOUT:?}. Every packet this node originates passes through \
309                 this stage, so this is a node-wide origination outage."
310            )
311        })?;
312
313        assert_eq!(
314            emitted.iter().map(|(_, data)| marker_of(data)).collect::<Vec<_>>(),
315            vec![1, 2],
316            "the packets behind the starved one must be emitted, in order"
317        );
318
319        // The starved packet is dropped, and that has to be *countable*. A silent drop leaves the
320        // operator with the same nothing the unbounded wait did: traffic missing and no signal
321        // saying why.
322        assert!(
323            hopr_transport_session::counters::routing_resolution_surb_timeout_count() > dropped_before,
324            "the starved packet was dropped without being counted, so the condition stays invisible"
325        );
326
327        Ok(())
328    }
329
330    #[test]
331    fn an_unset_wait_should_fall_back_to_the_default() {
332        assert_eq!(DEFAULT_SURB_RESOLUTION_WAIT, surb_resolution_wait(None));
333    }
334
335    #[test]
336    fn a_configured_wait_should_be_honoured() {
337        let configured = std::time::Duration::from_millis(2_500);
338        assert_eq!(configured, surb_resolution_wait(Some(configured)));
339    }
340
341    /// Zero is honoured rather than treated as "unset".
342    ///
343    /// The sibling concurrency knobs in the same config read `Some(0)` as "use the default", so the
344    /// difference is worth pinning: for a wait, zero has a meaning of its own — do not wait at all.
345    #[test]
346    fn a_zero_wait_should_mean_no_wait_rather_than_the_default() {
347        assert_eq!(
348            std::time::Duration::ZERO,
349            surb_resolution_wait(Some(std::time::Duration::ZERO))
350        );
351    }
352
353    /// With the wait disabled, a starved return packet is dropped on its first miss.
354    ///
355    /// This is the behaviour a zero wait buys, and the reason it is a footgun rather than a
356    /// tuning: any momentary SURB gap becomes loss. Asserting the attempt count is what separates
357    /// "did not wait" from "waited briefly".
358    #[test_log::test(tokio::test)]
359    async fn a_zero_wait_should_drop_a_starved_return_packet_without_retrying() -> anyhow::Result<()> {
360        let attempts = Arc::new(AtomicUsize::new(0));
361        let input = futures::stream::iter(vec![(return_routing(HoprPseudonym::random()), packet(0))]);
362
363        let emitted = {
364            let attempts = attempts.clone();
365            resolve_routing_stage(
366                input,
367                move |_size_hint, _max_surbs, routing: DestinationRouting| {
368                    let attempts = attempts.clone();
369                    async move {
370                        attempts.fetch_add(1, Ordering::Relaxed);
371                        Err(no_surb(&routing))
372                    }
373                },
374                TEST_DISTRESS_THRESHOLD,
375                TEST_CONCURRENCY,
376                surb_resolution_wait(Some(std::time::Duration::ZERO)),
377            )
378            .collect::<Vec<_>>()
379            .timeout(futures_time::time::Duration::from(TEST_TIMEOUT))
380            .await
381            .map_err(|_| anyhow::anyhow!("a zero wait still stalled instead of dropping immediately"))?
382        };
383
384        assert!(emitted.is_empty(), "the starved packet must not be emitted");
385        assert_eq!(
386            1,
387            attempts.load(Ordering::Relaxed),
388            "a zero wait must resolve once and give up, not retry"
389        );
390
391        Ok(())
392    }
393
394    /// A return packet whose SURBs are only momentarily absent must still be emitted once they
395    /// arrive.
396    ///
397    /// This is the behaviour the retry exists for, and it constrains any bound placed on that
398    /// retry: dropping a return packet on the first SURB error would lose data on a session that
399    /// was merely waiting for its pool to refill.
400    #[test_log::test(tokio::test)]
401    async fn a_return_packet_should_be_emitted_once_its_surbs_arrive() -> anyhow::Result<()> {
402        const FAILURES_BEFORE_SUCCESS: usize = 3;
403
404        let attempts = Arc::new(AtomicUsize::new(0));
405        let input = futures::stream::iter(vec![(return_routing(HoprPseudonym::random()), packet(7))]);
406
407        let emitted = {
408            let attempts = attempts.clone();
409            stage(input, move |_size_hint, _max_surbs, routing: DestinationRouting| {
410                let attempts = attempts.clone();
411                async move {
412                    if attempts.fetch_add(1, Ordering::Relaxed) < FAILURES_BEFORE_SUCCESS {
413                        Err(no_surb(&routing))
414                    } else {
415                        Ok((resolved(), Some(0)))
416                    }
417                }
418            })
419            .take(1)
420            .collect::<Vec<_>>()
421            .timeout(futures_time::time::Duration::from(TEST_TIMEOUT))
422            .await
423            .map_err(|_| {
424                anyhow::anyhow!(
425                    "a return packet whose SURBs arrived after {FAILURES_BEFORE_SUCCESS} retries was never emitted"
426                )
427            })?
428        };
429
430        assert_eq!(
431            emitted.len(),
432            1,
433            "the return packet must be emitted once its SURBs arrive"
434        );
435        assert_eq!(marker_of(&emitted[0].1), 7);
436        assert!(
437            attempts.load(Ordering::Relaxed) > FAILURES_BEFORE_SUCCESS,
438            "the stage must retry rather than drop on the first SURB error"
439        );
440
441        Ok(())
442    }
443
444    /// A hard (non-SURB) resolution failure drops that packet and lets the rest through.
445    ///
446    /// The contrast with the starvation case is the point: the stage already knows how to drop a
447    /// packet it cannot route and carry on. Only the SURB branch retries without a bound.
448    #[test_log::test(tokio::test)]
449    async fn a_hard_resolution_failure_should_drop_only_its_own_packet() -> anyhow::Result<()> {
450        let input = futures::stream::iter(vec![
451            (forward_routing(), packet(0)),
452            (forward_routing(), packet(1)),
453            (forward_routing(), packet(2)),
454        ]);
455
456        let emitted = stage(
457            input,
458            |_size_hint, _max_surbs, _routing: DestinationRouting| async move {
459                static SEEN: AtomicUsize = AtomicUsize::new(0);
460                if SEEN.fetch_add(1, Ordering::Relaxed) == 0 {
461                    Err(PathPlannerError::Api("no path".into()))
462                } else {
463                    Ok((resolved(), None))
464                }
465            },
466        )
467        .collect::<Vec<_>>()
468        .timeout(futures_time::time::Duration::from(TEST_TIMEOUT))
469        .await
470        .map_err(|_| anyhow::anyhow!("a hard resolution failure stalled the stage instead of dropping its packet"))?;
471
472        assert_eq!(
473            emitted.iter().map(|(_, data)| marker_of(data)).collect::<Vec<_>>(),
474            vec![1, 2],
475            "only the unroutable packet is dropped; the rest keep their order"
476        );
477
478        Ok(())
479    }
480}