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