Skip to main content

hopr_transport/protocol/pipeline/
mod.rs

1//! HOPR packet processing pipeline.
2
3mod builder;
4mod config;
5
6pub use builder::{PacketPipelineBuilder, Unset};
7use bytes::Bytes;
8pub use config::{AcknowledgementPipelineConfig, PacketPipelineConfig};
9use futures::{SinkExt, StreamExt, future::Either};
10use futures_time::{future::FutureExt as TimeExt, stream::StreamExt as TimeStreamExt};
11use hopr_api::{
12    PeerId,
13    node::TicketEvent,
14    types::{crypto::prelude::*, internal::prelude::*},
15};
16use hopr_crypto_packet::HoprSurb;
17use hopr_protocol_app::prelude::*;
18use hopr_protocol_hopr::prelude::*;
19use hopr_utils::{
20    network_types::timeout::{SinkTimeoutError, TimeoutSinkExt, TimeoutStreamExt},
21    runtime::AbortableList,
22};
23use rust_stream_ext_concurrent::then_concurrent::StreamThenConcurrentExt;
24use tracing::Instrument;
25
26use crate::PeerProtocolCounterRegistry;
27
28/// Default concurrency for the incoming acknowledgement processing pipeline when not overridden
29/// via [`AcknowledgementPipelineConfig::ack_input_concurrency`].
30const DEFAULT_ACK_INPUT_CONCURRENCY: usize = 10;
31/// Default concurrency for the outgoing acknowledgement processing pipeline when not overridden
32/// via [`AcknowledgementPipelineConfig::ack_output_concurrency`].
33const DEFAULT_ACK_OUTPUT_CONCURRENCY: usize = 10;
34const QUEUE_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50);
35const PACKET_DECODING_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(150);
36const PACKET_ENCODING_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(150);
37
38#[cfg(all(feature = "telemetry", not(test)))]
39lazy_static::lazy_static! {
40    static ref METRIC_PACKET_COUNT:  hopr_api::types::telemetry::MultiCounter =  hopr_api::types::telemetry::MultiCounter::new(
41        "hopr_packets_count",
42        "Number of processed packets of different types (sent, received, forwarded)",
43        &["type"]
44    ).unwrap();
45    static ref METRIC_PACKET_REJECTED_COUNT: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
46        "hopr_packet_rejected_count",
47        "Number of incoming packets rejected due various reasons",
48        &["reason"]
49    ).unwrap();
50    // Tracks how often the Rayon-backed packet decode path exceeds PACKET_DECODING_TIMEOUT.
51    // A sustained non-zero rate here indicates the Rayon pool is saturated—correlate with
52    // `hopr_rayon_tasks_cancelled_total` and hopr_rayon_queue_wait_seconds to diagnose whether
53    // the bottleneck is queue depth, individual task duration, or both.
54    static ref METRIC_PACKET_DECODE_TIMEOUTS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
55        "hopr_packet_decode_timeouts_total",
56        "Number of incoming packets dropped due to decode timeout (sustained rate indicates Rayon pool saturation)"
57    ).unwrap();
58    static ref METRIC_VALIDATION_ERRORS: hopr_api::types::telemetry::MultiCounter =  hopr_api::types::telemetry::MultiCounter::new(
59        "hopr_packet_ticket_validation_errors",
60        "Number of different ticket validation errors encountered during packet processing",
61        &["type"]
62    ).unwrap();
63    static ref METRIC_RECEIVED_ACKS: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
64        "hopr_protocol_ack_received_count",
65        "Number of received acknowledgements",
66        &["valid"]
67    ).unwrap();
68    static ref METRIC_SENT_ACKS: hopr_api::types::telemetry::SimpleCounter = hopr_api::types::telemetry::SimpleCounter::new(
69        "hopr_protocol_ack_sent_count",
70        "Number of sent message acknowledgements"
71    ).unwrap();
72    static ref METRIC_TICKETS_COUNT: hopr_api::types::telemetry::MultiCounter = hopr_api::types::telemetry::MultiCounter::new(
73        "hopr_tickets_count",
74        "Number of tickets by type (winning, losing, rejected)",
75        &["type"]
76    ).unwrap();
77}
78
79#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, strum::Display)]
80pub enum PacketPipelineProcesses {
81    #[strum(to_string = "HOPR [msg] - ingress")]
82    MsgIn,
83    #[strum(to_string = "HOPR [msg] - egress")]
84    MsgOut,
85    #[strum(to_string = "HOPR [ack] - egress")]
86    AckOut,
87    #[strum(to_string = "HOPR [ack] - ingress")]
88    AckIn,
89    #[strum(to_string = "HOPR [msg] - mixer")]
90    Mixer,
91}
92
93/// Performs encoding of outgoing Application protocol packets into HOPR protocol outgoing packets.
94async fn start_outgoing_packet_pipeline<AppOut, E, WOut, WOutErr>(
95    app_outgoing: AppOut,
96    encoder: std::sync::Arc<E>,
97    wire_outgoing: WOut,
98    counters: super::counters::PeerProtocolCounterRegistry,
99    concurrency: usize,
100) where
101    AppOut: futures::Stream<Item = (ResolvedTransportRouting<HoprSurb>, ApplicationDataOut)> + Send + 'static,
102    E: PacketEncoder + Send + Sync + 'static,
103    WOut: futures::Sink<(PeerId, Bytes), Error = SinkTimeoutError<WOutErr>> + Clone + Unpin + Send + 'static,
104    WOutErr: std::error::Error,
105{
106    let res = app_outgoing
107        .then_concurrent(
108            |(routing, data)| {
109                let encoder = encoder.clone();
110                let counters = counters.clone();
111                async move {
112                    match hopr_utils::parallelize::cpu::spawn_fifo_blocking(
113                        move || {
114                            encoder.encode_packet(
115                                data.data.to_bytes(),
116                                routing,
117                                data.packet_info
118                                    .map(|data| data.signals_to_destination)
119                                    .unwrap_or_default(),
120                            )
121                        },
122                        "packet_encode",
123                    )
124                    .timeout(futures_time::time::Duration::from(PACKET_ENCODING_TIMEOUT))
125                    .await
126                    {
127                        Ok(Ok(Ok(packet))) => {
128                            #[cfg(all(feature = "telemetry", not(test)))]
129                            METRIC_PACKET_COUNT.increment(&["sent"]);
130
131                            counters.get_or_create(&packet.next_hop).record_message_sent();
132                            tracing::trace!(peer = packet.next_hop.to_peerid_str(), "protocol message out");
133                            Some((packet.next_hop.into(), packet.data))
134                        }
135                        Ok(Ok(Err(error))) => {
136                            tracing::error!(%error, "outgoing packet could not be encoded");
137                            None
138                        }
139                        Ok(Err(error)) => {
140                            tracing::error!(%error, "parallel processing of the outgoing packet failed");
141                            None
142                        }
143                        Err(error) => {
144                            tracing::error!(%error, "timeout while processing the outgoing packet");
145                            None
146                        }
147                    }
148                }
149            },
150            concurrency,
151        )
152        .filter_map(futures::future::ready)
153        .map(Ok)
154        .forward_to_timeout(wire_outgoing)
155        .in_current_span()
156        .await;
157
158    if let Err(error) = res {
159        tracing::error!(
160            task = "transport (protocol - msg egress)",
161            %error,
162            "long-running background task finished with error"
163        );
164    } else {
165        tracing::warn!(
166            task = "transport (protocol - msg egress)",
167            "long-running background task finished"
168        )
169    }
170}
171
172/// Performs HOPR protocol decoding of incoming packets into Application protocol packets.
173///
174/// `wire_incoming` --> `decoder` --> `ack_outgoing` (final + forwarded)
175///                             | --> `wire_outgoing` (forwarded)
176///                             | --> `ack_incoming` (forwarded)
177///                             | --> `app_incoming` (final)
178#[allow(clippy::too_many_arguments)]
179async fn start_incoming_packet_pipeline<WIn, WOut, D, T, TEvt, AckIn, AckOut, AppIn, AppInErr>(
180    (wire_outgoing, wire_incoming): (WOut, WIn),
181    decoder: std::sync::Arc<D>,
182    ticket_proc: std::sync::Arc<T>,
183    ticket_events: TEvt,
184    (ack_outgoing, ack_incoming): (AckOut, AckIn),
185    app_incoming: AppIn,
186    counters: super::counters::PeerProtocolCounterRegistry,
187    concurrency: usize,
188) where
189    WIn: futures::Stream<Item = (PeerId, Bytes)> + Send + 'static,
190    WOut: futures::Sink<(PeerId, Bytes)> + Clone + Unpin + Send + 'static,
191    WOut::Error: std::error::Error,
192    D: PacketDecoder + Sync + Send + 'static,
193    T: UnacknowledgedTicketProcessor + Send + 'static,
194    TEvt: futures::Sink<TicketEvent> + Clone + Unpin + Send + 'static,
195    TEvt::Error: std::error::Error,
196    AckIn: futures::Sink<(OffchainPublicKey, Vec<Acknowledgement>)> + Send + Unpin + Clone + 'static,
197    AckIn::Error: std::error::Error,
198    AckOut: futures::Sink<(OffchainPublicKey, Option<HalfKey>)> + Send + Unpin + Clone + 'static,
199    AckOut::Error: std::error::Error,
200    AppIn: futures::Sink<(HoprPseudonym, ApplicationDataIn), Error = SinkTimeoutError<AppInErr>> + Send + 'static,
201    AppInErr: std::error::Error,
202{
203    let ack_outgoing_success = ack_outgoing.clone();
204    let ack_outgoing_failure = ack_outgoing;
205    let ticket_proc_success = ticket_proc;
206
207    let res = wire_incoming
208        .then_concurrent(move |(peer, data)| {
209            let decoder = decoder.clone();
210            let mut ack_outgoing_failure = ack_outgoing_failure.clone();
211            let mut ticket_events_reject = ticket_events.clone();
212
213            tracing::trace!(%peer, "protocol message in");
214
215            async move {
216                match hopr_utils::parallelize::cpu::spawn_fifo_blocking(move || decoder.decode(peer, data), "packet_decode")
217                    .timeout(futures_time::time::Duration::from(PACKET_DECODING_TIMEOUT))
218                    .await {
219                    Ok(Ok(Ok(packet))) => {
220                        tracing::trace!(%peer, ?packet, "successfully decoded incoming packet");
221                        Some(packet)
222                    },
223                    Ok(Ok(Err(IncomingPacketError::Undecodable(error)))) => {
224                        // Do not send an ack back if the packet could not be decoded at all
225                        //
226                        // Potentially adversarial behavior
227                        tracing::trace!(%peer, %error, "not sending ack back on undecodable packet - possible adversarial behavior");
228
229                        #[cfg(all(feature = "telemetry", not(test)))]
230                        METRIC_PACKET_REJECTED_COUNT.increment(&["undecodable"]);
231
232                        None
233                    },
234                    Ok(Ok(Err(IncomingPacketError::ProcessingError(sender, error)))) => {
235                        tracing::error!(%peer, %error, "failed to process the decoded packet");
236                        // On this failure, we send back a random acknowledgement
237                        ack_outgoing_failure
238                            .send((*sender, None))
239                            .await
240                            .unwrap_or_else(|error| {
241                                tracing::error!(%error, "failed to send ack to the egress queue");
242                            });
243
244                        #[cfg(all(feature = "telemetry", not(test)))]
245                        METRIC_PACKET_REJECTED_COUNT.increment(&["processing_error"]);
246
247                        None
248                    },
249                    Ok(Ok(Err(IncomingPacketError::InvalidTicket(sender, error)))) => {
250                        tracing::error!(%peer, %error, "failed to validate ticket on the received packet");
251                        if let Err(error) = ticket_events_reject
252                            .send(TicketEvent::RejectedTicket(error.ticket, error.issuer))
253                            .await {
254                            tracing::error!(%error, "failed to notify invalid ticket rejection");
255                        }
256                        // On this failure, we send back a random acknowledgement
257                        ack_outgoing_failure
258                            .send((*sender, None))
259                            .await
260                            .unwrap_or_else(|error| {
261                                tracing::error!(%error, "failed to send ack to the egress queue");
262                            });
263
264                        #[cfg(all(feature = "telemetry", not(test)))]
265                        {
266                            METRIC_VALIDATION_ERRORS.increment(&[error.kind.as_ref()]);
267                            METRIC_PACKET_REJECTED_COUNT.increment(&["invalid_ticket"]);
268                            METRIC_TICKETS_COUNT.increment(&["rejected"]);
269                        }
270
271                        None
272                    }
273                    Ok(Err(error)) => {
274                        tracing::error!(%error, "parallel processing of the incoming packet failed");
275                        None
276                    },
277                    Err(_) => {
278                        // If we cannot decode the packet within the time limit, just drop it
279                        tracing::error!(
280                            %peer,
281                            timeout_ms = PACKET_DECODING_TIMEOUT.as_millis() as u64,
282                            "dropped incoming packet: decode timeout - check the 'hopr_rayon_queue_wait_seconds' metric for pool saturation"
283                        );
284                        #[cfg(all(feature = "telemetry", not(test)))]
285                        {
286                            METRIC_PACKET_DECODE_TIMEOUTS.increment();
287                            METRIC_PACKET_REJECTED_COUNT.increment(&["timeout"]);
288                        }
289
290                        None
291                    }
292                }
293            }.instrument(tracing::debug_span!("incoming_packet_decode", %peer))
294        }, concurrency)
295        .filter_map(futures::future::ready)
296        // Branch on the packet type BEFORE building the async future so each arm only clones
297        // the handles it actually needs. `futures::future::Either` lets us return three
298        // distinct async blocks from one closure without boxing.
299        .then_concurrent(move |packet| {
300            match packet {
301                IncomingPacket::Acknowledgement(ack) => {
302                    let mut ack_incoming = ack_incoming.clone();
303                    let counters = counters.clone();
304                    Either::Left(async move {
305                        let IncomingAcknowledgementPacket { previous_hop, received_acks, .. } = *ack;
306                        tracing::trace!(previous_hop = previous_hop.to_peerid_str(), num_acks = received_acks.len(), "incoming acknowledgements");
307                        counters.get_or_create(&previous_hop).record_acks_received(received_acks.len() as u64);
308
309                        ack_incoming
310                            .send((previous_hop, received_acks))
311                            .await
312                            .unwrap_or_else(|error| {
313                                tracing::error!(%error, "failed dispatching received acknowledgement to the ticket ack queue");
314                            });
315
316                        // We do not acknowledge back acknowledgements.
317                        None
318                    })
319                }
320                IncomingPacket::Final(final_packet) => {
321                    let mut ack_outgoing_success = ack_outgoing_success.clone();
322                    Either::Right(Either::Left(async move {
323                        let IncomingFinalPacket {
324                            previous_hop,
325                            sender,
326                            plain_text,
327                            ack_key,
328                            info,
329                            ..
330                        } = *final_packet;
331                        tracing::trace!(previous_hop = previous_hop.to_peerid_str(), "incoming final packet");
332
333                        // Send acknowledgement back
334                        ack_outgoing_success
335                            .send((previous_hop, Some(ack_key)))
336                            .await
337                            .unwrap_or_else(|error| {
338                                tracing::error!(%error, "failed to send ack to the egress queue");
339                            });
340
341                        #[cfg(all(feature = "telemetry", not(test)))]
342                        METRIC_PACKET_COUNT.increment(&["received"]);
343
344                        Some((sender, plain_text, info))
345                    }))
346                }
347                IncomingPacket::Forwarded(fwd_packet) => {
348                    let ticket_proc = ticket_proc_success.clone();
349                    let mut wire_outgoing = wire_outgoing.clone();
350                    let mut ack_outgoing_success = ack_outgoing_success.clone();
351                    let counters = counters.clone();
352                    Either::Right(Either::Right(async move {
353                        let IncomingForwardedPacket {
354                            previous_hop,
355                            next_hop,
356                            data,
357                            ack_key_prev_hop,
358                            ack_challenge,
359                            received_ticket,
360                            ..
361                        } = *fwd_packet;
362                        // Per requirements, this call is not blocking
363                        if let Err(error) = ticket_proc.insert_unacknowledged_ticket(&next_hop, ack_challenge, received_ticket) {
364                            tracing::error!(
365                                previous_hop = previous_hop.to_peerid_str(),
366                                next_hop = next_hop.to_peerid_str(),
367                                %error,
368                                "failed to insert unacknowledged ticket into the ticket processor"
369                            );
370
371                            #[cfg(all(feature = "telemetry", not(test)))]
372                            METRIC_PACKET_REJECTED_COUNT.increment(&["unack_processing_error"]);
373
374                            return None;
375                        }
376
377                        // First, relay the packet to the next hop
378                        tracing::trace!(
379                            previous_hop = previous_hop.to_peerid_str(),
380                            next_hop = next_hop.to_peerid_str(),
381                            "forwarding packet to the next hop"
382                        );
383
384                        match wire_outgoing.send((next_hop.into(), data)).await {
385                            Ok(()) => {
386                                counters.get_or_create(&next_hop).record_message_sent();
387
388                                #[cfg(all(feature = "telemetry", not(test)))]
389                                METRIC_PACKET_COUNT.increment(&["forwarded"]);
390                            }
391                            Err(error) => {
392                                tracing::error!(%error, "failed to forward a packet to the transport layer");
393                                return None;
394                            }
395                        }
396
397                        // Send acknowledgement back
398                        tracing::trace!(previous_hop = previous_hop.to_peerid_str(), "acknowledging forwarded packet back");
399                        ack_outgoing_success
400                            .send((previous_hop, Some(ack_key_prev_hop)))
401                            .await
402                            .unwrap_or_else(|error| {
403                                tracing::error!(%error, "failed to send ack to the egress queue");
404                            });
405
406                        None
407                    }))
408                }
409            }
410        }, concurrency)
411        .filter_map(|maybe_data| futures::future::ready(
412            // Create the ApplicationDataIn data structure for incoming data
413            maybe_data
414                .and_then(|(sender, data, aux_info)| ApplicationData::try_from(data.as_ref())
415                    .inspect_err(|error| tracing::error!(%sender, %error, "failed to decode application data"))
416                    .ok()
417                    .map(|data| (sender, ApplicationDataIn {
418                        data,
419                        packet_info: IncomingPacketInfo {
420                            signals_from_sender: aux_info.packet_signals,
421                            num_saved_surbs: aux_info.num_surbs,
422                        }
423                    })))
424        ))
425        .map(Ok)
426        .forward_to_timeout(app_incoming)
427        .in_current_span()
428        .await;
429
430    if let Err(error) = res {
431        tracing::error!(
432            task = "transport (protocol - msg ingress)",
433            %error,
434            "long-running background task finished with error"
435        );
436    } else {
437        tracing::warn!(
438            task = "transport (protocol - msg ingress)",
439            "long-running background task finished"
440        )
441    }
442}
443
444async fn start_outgoing_ack_pipeline<AckOut, E, WOut>(
445    ack_outgoing: AckOut,
446    encoder: std::sync::Arc<E>,
447    cfg: AcknowledgementPipelineConfig,
448    packet_key: OffchainKeypair,
449    wire_outgoing: WOut,
450) where
451    AckOut: futures::Stream<Item = (OffchainPublicKey, Option<HalfKey>)> + Send + 'static,
452    E: PacketEncoder + Sync + Send + 'static,
453    WOut: futures::Sink<(PeerId, Bytes)> + Clone + Unpin + Send + 'static,
454    WOut::Error: std::error::Error,
455{
456    ack_outgoing
457        .map(move |(destination, maybe_ack_key)| {
458            let packet_key = packet_key.clone();
459            // Sign acknowledgement with the given half-key or generate a signed random one
460            let ack = maybe_ack_key
461                .map(|ack_key| VerifiedAcknowledgement::new(ack_key, &packet_key))
462                .unwrap_or_else(|| VerifiedAcknowledgement::random(&packet_key));
463            (destination, ack)
464        })
465        .buffer(futures_time::time::Duration::from(cfg.ack_buffer_interval))
466        .filter(|acks| futures::future::ready(!acks.is_empty()))
467        // Group by sender, reusing the same HashMap across buffer cycles so we don't
468        // re-allocate its bucket storage every `cfg.ack_buffer_interval` (default 200ms).
469        //
470        // The halfbrown map uses a Vec backing for a small number of distinct senders
471        // (<32) and transitions to hashbrown otherwise — calling `drain()` keeps the
472        // underlying allocation, leaving us with only the per-group Vec<Ack> to allocate
473        // (which downstream consumes as owned values).
474        .scan(
475            halfbrown::HashMap::<OffchainPublicKey, Vec<VerifiedAcknowledgement>, ahash::RandomState>::with_capacity_and_hasher(
476                cfg.ack_grouping_capacity,
477                ahash::RandomState::default(),
478            ),
479            |groups, buffered_acks| {
480                for (dst, ack) in buffered_acks {
481                    groups
482                        .entry(dst)
483                        .and_modify(|v| v.push(ack))
484                        .or_insert_with(|| vec![ack]);
485                }
486                tracing::trace!(
487                    num_groups = groups.len(),
488                    num_acks = groups.values().map(|v| v.len()).sum::<usize>(),
489                    "acknowledgements grouped"
490                );
491                let drained: Vec<_> = groups.drain().collect();
492                futures::future::ready(Some(futures::stream::iter(drained)))
493            },
494        )
495        .flatten()
496        .for_each_concurrent(
497            cfg.ack_output_concurrency.filter(|&n| n > 0).unwrap_or(DEFAULT_ACK_OUTPUT_CONCURRENCY),
498            move |(destination, acks)| {
499                let encoder = encoder.clone();
500                let mut wire_outgoing = wire_outgoing.clone();
501                async move {
502                    // Make sure that the acknowledgements are sent in batches of at most MAX_ACKNOWLEDGEMENTS_BATCH_SIZE
503                    // TODO: find better strategy to avoid reallocations
504                    let c = acks.chunks(MAX_ACKNOWLEDGEMENTS_BATCH_SIZE).map(|c| c.to_vec()).collect::<Vec<_>>();
505                    for ack_chunk in c {
506                        let encoder = encoder.clone();
507                        #[cfg(all(feature = "telemetry", not(test)))]
508                        let ack_chunk_len = ack_chunk.len() as u64;
509                        match hopr_utils::parallelize::cpu::spawn_fifo_blocking(move || encoder.encode_acknowledgements(&ack_chunk, &destination), "ack_encode").await {
510                            Ok(Ok(ack_packet)) => {
511                                wire_outgoing
512                                    .feed((ack_packet.next_hop.into(), ack_packet.data))
513                                    .await
514                                    .unwrap_or_else(|error| {
515                                        tracing::error!(%error, "failed to forward an acknowledgement to the transport layer");
516                                    });
517
518                                #[cfg(all(feature = "telemetry", not(test)))]
519                                METRIC_SENT_ACKS.increment_by(ack_chunk_len);
520                            }
521                            Ok(Err(error)) => tracing::error!(%error, "failed to encode acknowledgements"),
522                            Err(error) => tracing::error!(%error, "parallel processing of the outgoing acknowledgements failed"),
523                        }
524                    }
525                    if let Err(error) = wire_outgoing.flush().await {
526                        tracing::error!(%error, "failed to flush acknowledgements batch to the transport layer");
527                    }
528                    tracing::trace!("acknowledgements out");
529                }.instrument(tracing::debug_span!("outgoing_ack_batch", peer = destination.to_peerid_str()))
530            }
531        )
532        .in_current_span()
533        .await;
534
535    tracing::warn!(
536        task = "transport (protocol - ack outgoing)",
537        "long-running background task finished"
538    );
539}
540
541/// Drains incoming acknowledgements without forwarding them to an [`UnacknowledgedTicketProcessor`].
542///
543/// Used by Entry and Exit nodes — neither processes incoming ticket acknowledgements.
544/// Entry nodes receive acks from relays (they pay for forwarding), Exit nodes keep
545/// the pipeline alive for future PIX use. In both cases the queue must be actively
546/// drained; dropping the receiver causes every inbound ack dispatch to fail with
547/// `SendError(disconnected)`.
548async fn start_drain_incoming_ack_pipeline<AckIn>(ack_incoming: AckIn)
549where
550    AckIn: futures::Stream<Item = (OffchainPublicKey, Vec<Acknowledgement>)> + Send + 'static,
551{
552    ack_incoming
553        .for_each(move |(peer, acks)| {
554            tracing::trace!(%peer, num = acks.len(), "received acknowledgements (drained, not processed)");
555            futures::future::ready(())
556        })
557        .in_current_span()
558        .await;
559
560    tracing::warn!(
561        task = "transport (protocol - ticket acknowledgement drain)",
562        "long-running background task finished"
563    );
564}
565
566async fn start_relay_incoming_ack_pipeline<AckIn, T, TEvt>(
567    ack_incoming: AckIn,
568    ticket_events: TEvt,
569    ticket_proc: std::sync::Arc<T>,
570    concurrency: usize,
571) where
572    AckIn: futures::Stream<Item = (OffchainPublicKey, Vec<Acknowledgement>)> + Send + 'static,
573    T: UnacknowledgedTicketProcessor + Sync + Send + 'static,
574    TEvt: futures::Sink<TicketEvent> + Clone + Unpin + Send + 'static,
575    TEvt::Error: std::error::Error,
576{
577    ack_incoming
578        .for_each_concurrent(concurrency, move |(peer, acks)| {
579            let ticket_proc = ticket_proc.clone();
580            let mut ticket_evt = ticket_events.clone();
581            async move {
582                tracing::trace!(num = acks.len(), "received acknowledgements");
583                match hopr_utils::parallelize::cpu::spawn_fifo_blocking(
584                    move || ticket_proc.acknowledge_tickets(peer, acks),
585                    "ack_decode",
586                )
587                .await
588                {
589                    Ok(Ok(resolutions)) if !resolutions.is_empty() => {
590                        let resolutions_iter = resolutions.into_iter().filter_map(|resolution| match resolution {
591                            ResolvedAcknowledgement::RelayingWin(redeemable_ticket) => {
592                                tracing::trace!("received ack for a winning ticket");
593                                #[cfg(all(feature = "telemetry", not(test)))]
594                                {
595                                    METRIC_RECEIVED_ACKS.increment(&["true"]);
596                                    METRIC_TICKETS_COUNT.increment(&["winning"]);
597                                }
598                                Some(Ok(TicketEvent::WinningTicket(redeemable_ticket)))
599                            }
600                            ResolvedAcknowledgement::RelayingLoss(_) => {
601                                // Losing tickets are not getting accounted for anywhere.
602                                tracing::trace!("received ack for a losing ticket");
603                                #[cfg(all(feature = "telemetry", not(test)))]
604                                {
605                                    METRIC_RECEIVED_ACKS.increment(&["true"]);
606                                    METRIC_TICKETS_COUNT.increment(&["losing"]);
607                                }
608                                None
609                            }
610                        });
611
612                        // All acknowledgements that resulted in winning tickets go upstream
613                        if let Err(error) = ticket_evt.send_all(&mut futures::stream::iter(resolutions_iter)).await {
614                            tracing::error!(%error, "failed to notify ticket resolutions");
615                        }
616                    }
617                    Ok(Ok(_)) => {
618                        tracing::debug!("acknowledgement batch could not acknowledge any ticket");
619                    }
620                    Ok(Err(TicketAcknowledgementError::UnexpectedAcknowledgement)) => {
621                        // Unexpected acknowledgements naturally happen
622                        // as acknowledgements of 0-hop packets
623                        tracing::trace!("received unexpected acknowledgement");
624                    }
625                    Ok(Err(error)) => {
626                        tracing::error!(%error, "failed to acknowledge ticket");
627                    }
628                    Err(error) => {
629                        tracing::error!(%error, "parallel processing of the incoming acknowledgements failed")
630                    }
631                }
632            }
633            .instrument(tracing::debug_span!("incoming_ack_batch", peer = peer.to_peerid_str()))
634        })
635        .in_current_span()
636        .await;
637
638    tracing::warn!(
639        task = "transport (protocol - ticket acknowledgement)",
640        "long-running background task finished"
641    );
642}
643/// Node type for which the packet processing pipeline is being constructed.
644///
645/// The three HOPR node types differ in how they treat tickets and incoming acknowledgements:
646/// * [`Relay`](NodeType::Relay) — full pipeline, processes tickets and incoming acknowledgements.
647/// * [`Entry`](NodeType::Entry) — does not process tickets and does not even start the incoming acknowledgement
648///   pipeline.
649/// * [`Exit`](NodeType::Exit) — does not process tickets, but still runs the incoming acknowledgement pipeline (which
650///   only drains the stream) for future use.
651#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
652pub enum NodeType {
653    Relay,
654    Entry,
655    Exit,
656}
657
658/// No-op [`UnacknowledgedTicketProcessor`] used by node types that do not process tickets
659/// (Entry and Exit). All methods are unreachable because the inner pipeline never invokes
660/// them on those node types (Entry skips the ack pipeline entirely, Exit uses the drain
661/// variant, and the forwarded packet branch never fires on a terminal/source node).
662#[derive(Debug, Default, Copy, Clone)]
663#[doc(hidden)]
664pub struct NoopTicketProcessor;
665
666impl UnacknowledgedTicketProcessor for NoopTicketProcessor {
667    type Error = std::convert::Infallible;
668
669    #[inline]
670    fn insert_unacknowledged_ticket(
671        &self,
672        _: &OffchainPublicKey,
673        _: HalfKeyChallenge,
674        _: UnacknowledgedTicket,
675    ) -> Result<(), Self::Error> {
676        Ok(())
677    }
678
679    #[inline]
680    fn acknowledge_tickets(
681        &self,
682        _: OffchainPublicKey,
683        _: Vec<Acknowledgement>,
684    ) -> Result<Vec<ResolvedAcknowledgement>, TicketAcknowledgementError<Self::Error>> {
685        Ok(Vec::with_capacity(0))
686    }
687}
688/// Shared implementation of the packet pipeline used by [`PacketPipelineBuilder`]'s
689/// terminal `build_for_*` methods.
690#[allow(clippy::too_many_arguments)]
691#[tracing::instrument(skip_all, level = "trace", fields(me = packet_key.public().to_peerid_str()))]
692pub(super) fn run_packet_pipeline_inner<WIn, WOut, C, D, T, TEvt, AppOut, AppIn>(
693    node_type: NodeType,
694    packet_key: OffchainKeypair,
695    wire_msg: (WOut, WIn),
696    codec: (C, D),
697    ticket_proc: T,
698    ticket_events: TEvt,
699    cfg: PacketPipelineConfig,
700    api: (AppOut, AppIn),
701    counters: PeerProtocolCounterRegistry,
702) -> AbortableList<PacketPipelineProcesses>
703where
704    WOut: futures::Sink<(PeerId, Bytes)> + Clone + Unpin + Send + 'static,
705    WOut::Error: std::error::Error,
706    WIn: futures::Stream<Item = (PeerId, Bytes)> + Send + 'static,
707    C: PacketEncoder + Sync + Send + 'static,
708    D: PacketDecoder + Sync + Send + 'static,
709    T: UnacknowledgedTicketProcessor + Sync + Send + 'static,
710    TEvt: futures::Sink<TicketEvent> + Clone + Unpin + Send + 'static,
711    TEvt::Error: std::error::Error,
712    AppOut: futures::Sink<(HoprPseudonym, ApplicationDataIn)> + Send + 'static,
713    AppOut::Error: std::error::Error,
714    AppIn: futures::Stream<Item = (ResolvedTransportRouting<HoprSurb>, ApplicationDataOut)> + Send + 'static,
715{
716    let mut processes = AbortableList::default();
717
718    #[cfg(all(feature = "telemetry", not(test)))]
719    {
720        // Initialize the lazy statics here
721        lazy_static::initialize(&METRIC_PACKET_COUNT);
722        lazy_static::initialize(&METRIC_PACKET_DECODE_TIMEOUTS);
723        lazy_static::initialize(&METRIC_PACKET_REJECTED_COUNT);
724        lazy_static::initialize(&METRIC_VALIDATION_ERRORS);
725    }
726
727    let (outgoing_ack_tx, outgoing_ack_rx) = hopr_utils::network_types::crossfire_sink::bounded_sink_channel::<(
728        OffchainPublicKey,
729        Option<HalfKey>,
730    )>(cfg.ack_config.ack_out_buffer_size);
731
732    let (incoming_ack_tx, incoming_ack_rx) = hopr_utils::network_types::crossfire_sink::bounded_sink_channel::<(
733        OffchainPublicKey,
734        Vec<Acknowledgement>,
735    )>(cfg.ack_config.ticket_ack_buffer_size);
736
737    // Attach timeouts to all Sinks so that the pipelines are not blocked when
738    // some channel is not being timely processed
739    let (wire_out, wire_in) = (wire_msg.0.with_timeout(QUEUE_SEND_TIMEOUT), wire_msg.1);
740    let (app_out, app_in) = (api.0.with_timeout(QUEUE_SEND_TIMEOUT), api.1);
741    let incoming_ack_tx = incoming_ack_tx.with_timeout(QUEUE_SEND_TIMEOUT);
742    let outgoing_ack_tx = outgoing_ack_tx.with_timeout(QUEUE_SEND_TIMEOUT);
743    let ticket_events = ticket_events.with_timeout(QUEUE_SEND_TIMEOUT);
744
745    let encoder = std::sync::Arc::new(codec.0);
746    let decoder = std::sync::Arc::new(codec.1);
747    let ticket_proc = std::sync::Arc::new(ticket_proc);
748
749    // The default maximum concurrency (if not set or zero) is 8 times the number of available cores.
750    // Zero is normalized to the default to prevent deadlock (0 concurrent tasks = no work).
751    let avail_concurrency = std::thread::available_parallelism()
752        .ok()
753        .map(|n| n.get())
754        .unwrap_or(1)
755        .max(1)
756        * 8;
757
758    let output_concurrency = cfg.output_concurrency.filter(|&n| n > 0).unwrap_or(avail_concurrency);
759    let input_concurrency = cfg.input_concurrency.filter(|&n| n > 0).unwrap_or(avail_concurrency);
760
761    processes.insert(
762        PacketPipelineProcesses::MsgOut,
763        hopr_utils::spawn_as_abortable!(
764            start_outgoing_packet_pipeline(
765                app_in,
766                encoder.clone(),
767                wire_out.clone(),
768                counters.clone(),
769                output_concurrency
770            )
771            .in_current_span()
772        ),
773    );
774
775    processes.insert(
776        PacketPipelineProcesses::MsgIn,
777        hopr_utils::spawn_as_abortable!(
778            start_incoming_packet_pipeline(
779                (wire_out.clone(), wire_in),
780                decoder,
781                ticket_proc.clone(),
782                ticket_events.clone(),
783                (outgoing_ack_tx, incoming_ack_tx),
784                app_out,
785                counters.clone(),
786                input_concurrency,
787            )
788            .in_current_span()
789        ),
790    );
791
792    processes.insert(
793        PacketPipelineProcesses::AckOut,
794        hopr_utils::spawn_as_abortable!(
795            start_outgoing_ack_pipeline(outgoing_ack_rx, encoder, cfg.ack_config, packet_key.clone(), wire_out,)
796                .in_current_span()
797        ),
798    );
799
800    let ack_input_concurrency = cfg
801        .ack_config
802        .ack_input_concurrency
803        .filter(|&n| n > 0)
804        .unwrap_or(DEFAULT_ACK_INPUT_CONCURRENCY);
805
806    match node_type {
807        NodeType::Relay => {
808            processes.insert(
809                PacketPipelineProcesses::AckIn,
810                hopr_utils::spawn_as_abortable!(
811                    start_relay_incoming_ack_pipeline(
812                        incoming_ack_rx,
813                        ticket_events,
814                        ticket_proc,
815                        ack_input_concurrency
816                    )
817                    .in_current_span()
818                ),
819            );
820        }
821        NodeType::Exit => {
822            // Exit nodes still run the incoming acknowledgement pipeline (for future PIX use),
823            // but only drain the stream — incoming acknowledgements are NOT forwarded to the
824            // UnacknowledgedTicketProcessor because Exit nodes do not process tickets.
825            let _ = (ticket_events, ticket_proc, ack_input_concurrency);
826            processes.insert(
827                PacketPipelineProcesses::AckIn,
828                hopr_utils::spawn_as_abortable!(start_drain_incoming_ack_pipeline(incoming_ack_rx).in_current_span()),
829            );
830        }
831        NodeType::Entry => {
832            // Entry nodes do not process tickets, but they DO receive ticket acknowledgements
833            // (they pay relays for forwarding). The queue must be actively drained so the
834            // inbound dispatcher can keep sending without hitting `SendError(disconnected)`.
835            let _ = (ticket_events, ticket_proc, ack_input_concurrency);
836            processes.insert(
837                PacketPipelineProcesses::AckIn,
838                hopr_utils::spawn_as_abortable!(start_drain_incoming_ack_pipeline(incoming_ack_rx).in_current_span()),
839            );
840        }
841    }
842
843    processes
844}
845
846#[cfg(test)]
847mod tests {
848    use futures::channel::mpsc;
849
850    use super::*;
851
852    /// Regression test for the Entry-node ack-sink bug.
853    ///
854    /// Before the fix, `NodeType::Entry` dropped `incoming_ack_rx` immediately at
855    /// pipeline startup. Every subsequent call to `incoming_ack_tx.send(…)` then
856    /// returned `SendError(disconnected)`, flooding logs with ~300 errors per run.
857    ///
858    /// `start_drain_incoming_ack_pipeline` must hold the receiver open for the
859    /// lifetime of its task; once the sender side is dropped the task completes
860    /// cleanly.
861    #[tokio::test]
862    async fn drain_pipeline_keeps_receiver_alive() {
863        let (tx, rx) = mpsc::channel::<(OffchainPublicKey, Vec<Acknowledgement>)>(16);
864        let drain = tokio::spawn(start_drain_incoming_ack_pipeline(rx));
865
866        // Give the drain task a chance to start up.
867        tokio::task::yield_now().await;
868
869        // With the old code (drop receiver) tx.is_closed() would be true here.
870        assert!(!tx.is_closed(), "drain task must hold the receiver alive");
871
872        // Drop the sender — drain task should complete cleanly.
873        drop(tx);
874        drain
875            .await
876            .expect("drain task must finish cleanly after sender is dropped");
877    }
878
879    /// Regression: the drain must complete cleanly when the sender is dropped (no deadlock/panic).
880    #[tokio::test]
881    async fn drain_pipeline_completes_on_empty_stream() {
882        let (tx, rx) = mpsc::channel::<(OffchainPublicKey, Vec<Acknowledgement>)>(32);
883        let drain = tokio::spawn(start_drain_incoming_ack_pipeline(rx));
884        drop(tx);
885        drain
886            .await
887            .expect("drain task must finish cleanly after sender is dropped");
888    }
889
890    /// A disconnected `futures::mpsc::Sender` must not panic; `send` returns `Err`.
891    #[tokio::test]
892    async fn disconnected_sender_returns_err_not_panic() {
893        let (tx, rx) = mpsc::channel::<u8>(4);
894        drop(rx);
895        let mut tx2 = tx.clone();
896        let result = tx2.send(42u8).await;
897        assert!(result.is_err(), "send to disconnected receiver must return Err");
898    }
899
900    /// The `for_each` loop used in `SessionsManagement(0)` must not terminate when
901    /// the receiver is dropped; it must continue consuming the upstream stream.
902    #[tokio::test]
903    async fn session_management_dispatcher_survives_disconnected_sink() -> anyhow::Result<()> {
904        use anyhow::Context;
905        use futures::SinkExt;
906
907        let (data_tx, data_rx) = mpsc::channel::<u8>(4);
908
909        // Drop the receiver immediately — simulates HoprSocket being dropped.
910        drop(data_rx);
911
912        // Upstream stream of 10 items.
913        let upstream = futures::stream::iter(0u8..10);
914
915        // Run the resilient for_each pattern used in SessionsManagement(0).
916        let dispatcher = tokio::spawn(async move {
917            upstream
918                .for_each(move |item| {
919                    let mut tx = data_tx.clone();
920                    async move {
921                        // Error is expected (disconnected); we must NOT abort the stream.
922                        let _ = tx.send(item).await;
923                    }
924                })
925                .await;
926        });
927
928        // Task must complete without panic even though every send fails.
929        dispatcher
930            .await
931            .context("dispatcher must complete cleanly even with a disconnected sink")?;
932        Ok(())
933    }
934
935    /// Regression: previously, the first `Unrelated` packet triggered task exit and
936    /// dropped `rx_from_protocol`.  After the fix the task must run to completion.
937    #[tokio::test]
938    async fn session_management_dispatcher_does_not_drop_upstream_on_disconnected_sink() -> anyhow::Result<()> {
939        use anyhow::Context;
940        use futures::SinkExt;
941
942        let (data_tx, data_rx) = mpsc::channel::<u8>(1);
943        drop(data_rx); // receiver gone from the start
944
945        let (upstream_tx, upstream_rx) = mpsc::channel::<u8>(16);
946        let upstream = upstream_rx;
947
948        let dispatcher = tokio::spawn(async move {
949            upstream
950                .for_each(move |item| {
951                    let mut tx = data_tx.clone();
952                    async move {
953                        let _ = tx.send(item).await;
954                    }
955                })
956                .await;
957        });
958
959        // Send several items; the dispatcher must process them all.
960        let mut sender = upstream_tx;
961        for i in 0u8..20 {
962            sender.send(i).await.context("upstream send must succeed")?;
963        }
964        drop(sender); // close upstream → dispatcher finishes
965
966        dispatcher
967            .await
968            .context("dispatcher must complete when upstream closes, even with disconnected sink")?;
969        Ok(())
970    }
971}