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}